diff --git a/linear/contracts/linear_type_classifier_v1.json b/linear/contracts/linear_type_classifier_v1.json new file mode 100644 index 0000000..c6e4982 --- /dev/null +++ b/linear/contracts/linear_type_classifier_v1.json @@ -0,0 +1,153 @@ +{ + "version": "linear_type_classifier_v1", + "status": "active", + "owner": "Product Development", + "supersedes": [ + "linear/docs/process/linear_issue_type_decision_guide_v1.md prose-only decision flow" + ], + "purpose": "Deterministic issue type classifier and advisory default QA/PM routing recommendation source for Linear ticket creation, triage, and hygiene automation.", + "required_intake_block": "Linear Classification", + "required_intake_fields": [ + "Output", + "Behavior change", + "Broken existing behavior", + "Evidence", + "Children expected", + "PM-testable" + ], + "output_values": [ + "code", + "docs/process", + "design artifact", + "design approval", + "release coordination", + "plan container" + ], + "output_aliases": { + "implementation": "code", + "docs": "docs/process", + "process": "docs/process" + }, + "strict_boolean_fields": [ + "Behavior change", + "Broken existing behavior", + "Children expected", + "PM-testable" + ], + "boolean_values": [ + "yes", + "no" + ], + "canonical_types": [ + "Bug", + "Feature", + "Chore", + "Design", + "Plan", + "Release" + ], + "type_decision_order": [ + { + "type": "Bug", + "when": { + "broken_existing_behavior": true, + "evidence_required": true + }, + "reason": "Expected behavior did not work and evidence exists. This remains Bug even if the implementation fix is refactor, cleanup, or hardening." + }, + { + "type": "Design", + "when": { + "output_any_of": [ + "design artifact", + "design approval" + ] + }, + "reason": "Ticket output is serious design artifact or design approval, usually Figma/mockup/design-system evidence." + }, + { + "type": "Release", + "when": { + "output_any_of": [ + "release coordination" + ] + }, + "reason": "Ticket is a real release coordination container with release mechanics." + }, + { + "type": "Plan", + "when": { + "output_any_of": [ + "plan container" + ], + "children_expected": true + }, + "reason": "Ticket is a mini-project/epic container with real subtickets toward one precise goal." + }, + { + "type": "Feature", + "when": { + "behavior_change": true + }, + "reason": "Ticket adds or upgrades functionality with real value to people or agents." + }, + { + "type": "Chore", + "when": { + "default": true + }, + "reason": "Maintenance, docs/process, cleanup, refactor, upgrade, or hygiene work without new user/agent-facing functionality." + } + ], + "routing_defaults_status": "advisory_until_wired_to_label_automation", + "routing_defaults": [ + { + "type": "Design", + "qa": "skip", + "pm": "required", + "reason": "Design acceptance is PM/design review; QA does not validate design artifacts." + }, + { + "type": "Chore", + "condition": { + "pm_testable": false + }, + "qa": "required", + "pm": "skip_allowed", + "reason": "Highly technical chores with no PM-testable behavior change can be accepted by QA evidence." + }, + { + "type": "Bug", + "condition": { + "pm_testable": false + }, + "qa": "required", + "pm": "skip_allowed", + "reason": "Tiny/narrow bugs can skip PM when QA evidence is enough." + }, + { + "type_any_of": [ + "Plan", + "Release" + ], + "qa": "subissues_decide", + "pm": "subissues_decide", + "reason": "Plan and Release are coordinating containers; real subtickets carry type and route." + }, + { + "type_any_of": [ + "Feature", + "Bug", + "Chore" + ], + "qa": "required", + "pm": "required", + "reason": "Default implementation route unless a skip condition above applies." + } + ], + "learning_loop": { + "human_correction_log": "linear/docs/process/linear_type_classifier_corrections_v1.md", + "rule": "When CJ corrects a hygiene audit classification, record one short correction in the markdown log, then convert it into this JSON classifier or a classifier test fixture in the same PR/run.", + "no_hidden_memory": true + } +} diff --git a/linear/docs/process/linear_issue_template_evidence_contract_v2.md b/linear/docs/process/linear_issue_template_evidence_contract_v2.md index 5363260..c71e05b 100644 --- a/linear/docs/process/linear_issue_template_evidence_contract_v2.md +++ b/linear/docs/process/linear_issue_template_evidence_contract_v2.md @@ -31,6 +31,18 @@ Every "Done" claim must include: - Label check: finalized labels applied only to finalized items; future items do not receive completion labels. - Bidirectional linking check: PR comment plus Linear issue comment/link verified, with no duplicate link-spam. +7. `Issue Type check` (required when creating, triaging, or normalizing issue type) +- Exactly one canonical issue type is set, or `needs-type` is present with a note naming the missing evidence. +- Type choice follows `linear/contracts/linear_type_classifier_v1.json` and `linear_issue_type_decision_guide_v1.md`. +- Type is not inferred from title alone. +- Include the compact `Linear Classification` intake block when moving an issue to `Ready`: + - `Output` + - `Behavior change` + - `Broken existing behavior` + - `Evidence` + - `Children expected` + - `PM-testable` + ## Taylor01 Portability Check (required when relevant) For issues touching agents, workflows, process docs, workspace policy, or tool integrations, also include: @@ -77,7 +89,7 @@ Commands/UI checks: - GitHub UI: Org Settings -> Security -> Require 2FA = enabled Artifacts: -- `/Users/cjarguello/bitpod-app/bitpod-tools/linear/docs/process/github_org_baseline_evidence_2026-03-06.md` +- `/Users/taylor01/BitPod-App/bitpod-tools/linear/docs/process/github_org_baseline_evidence_2026-03-06.md` - [PR #6](https://github.com/BitPod-App/bitpod-tools/pull/6) Pass/Fail: @@ -100,6 +112,11 @@ PR-to-Linear closeout check: - Labels: domain label + qa/pm result labels verified where finalized - Bidirectional links: PR comment verified; Linear attachment/comment verified; no duplicate retroactive comments +Issue Type check: +- Type: Feature / Bug / Chore / Design / Plan / Release / blocked by `needs-type` +- Evidence basis: acceptance criteria, defect evidence, design artifact, parent rollout scope, release checklist, or other current issue evidence +- Decision guide: `linear/contracts/linear_type_classifier_v1.json` and `linear_issue_type_decision_guide_v1.md` + Risk / follow-up: - `code_security` feature is plan-gated; tracked separately and not blocking this issue. ``` @@ -121,3 +138,4 @@ Risk / follow-up: - If a meaningful temporary bypass is used, add or update the active bypass register entry instead of silently relying on memory. - If a PR-to-Linear closeout check is required and missing, do not claim the issue/project/PR history is normalized. - If project membership cannot be corrected through available tooling, record that as an explicit blocker rather than marking the project cleanup fully complete. +- If issue type evidence is missing or ambiguous, keep or add `needs-type` rather than guessing. diff --git a/linear/docs/process/linear_issue_type_decision_guide_v1.md b/linear/docs/process/linear_issue_type_decision_guide_v1.md new file mode 100644 index 0000000..dbbc4d8 --- /dev/null +++ b/linear/docs/process/linear_issue_type_decision_guide_v1.md @@ -0,0 +1,210 @@ +# Linear Issue Type Decision Guide v1 + +Status: Active +Owner: Product Development +Applies to: BitPod Product Development Linear issues + +## Purpose + +Provide the canonical operational guide for assigning exactly one Linear `Issue Type` label. + +This guide is now machine-readable-first. The operative classifier is `linear/contracts/linear_type_classifier_v1.json`; this Markdown explains the intent and edge cases. If prose and JSON disagree, update both in the same PR and treat the JSON classifier as the automation source. + +Use this guide when creating, triaging, normalizing, or hygiene-auditing Linear issues. The goal is consistent evidence-based classification, not a redesigned taxonomy. + +## Required machine intake + +Every issue moving to `Ready` should include this compact intake block: + +```md +Linear Classification: +- Output: code | docs/process | design artifact | design approval | release coordination | plan container +- Behavior change: yes | no +- Broken existing behavior: yes | no +- Evidence: short link/text +- Children expected: yes | no +- PM-testable: yes | no +``` + +The bot and hygiene automation should classify from this block first. Humans and agents should only reason deeply when the block is missing, invalid, contradictory, or low-confidence. + +## Default rule + +Assign type from the machine intake and issue evidence, not from title alone. + +Evidence may include: + +- stated objective and acceptance criteria +- reproduction steps, observed behavior, logs, screenshots, or failing checks +- linked PRs, commits, designs, specs, release notes, or Figma/design assets +- issue relationships and whether the issue is a parent/container +- current scope, owner, due date, rollout, and checklist + +If evidence is insufficient, do not guess. Keep or add the appropriate readiness blocker such as `needs-type` and comment with the missing evidence needed to classify. + +## Deterministic decision order + +The machine classifier uses this order: + +1. `Broken existing behavior: yes` plus evidence -> `Bug`. +2. `Output: design artifact` or `design approval` -> `Design`. +3. `Output: release coordination` -> `Release`. +4. `Output: plan container` plus `Children expected: yes` -> `Plan`. +5. `Behavior change: yes` -> `Feature`. +6. Otherwise -> `Chore`. +7. If the intake is missing or contradictory, mark `needs-type` instead of guessing. + +## Type rules + +### `Bug` + +Use when evidence shows existing expected behavior is broken. + +Typical evidence: + +- reproducible failure or regression +- failing check, error log, exception, broken workflow, or incorrect output +- user-reported defect with observed/expected behavior +- fix restores intended behavior without materially expanding scope + +If it did not work and there is evidence, use `Bug` even when the implementation fix is refactoring, hardening, or cleanup. + +Do not use `Bug` only because the title says "fix". A cleanup, refactor, or missing feature can also use that wording. + +### `Feature` + +Use when the issue adds a meaningful capability or improvement for users, operators, or the team. + +Typical evidence: + +- new behavior, command, workflow, integration, API, UI capability, or team-facing automation +- upgrade to an existing capability that adds functionality that did not exist before and creates real value for people or agents +- acceptance criteria describe additive value rather than only restoring broken behavior +- implementation changes product or operating capability in a visible way + +If the work is mostly repo/process upkeep with no meaningful new capability, prefer `Chore`. + +### `Chore` + +Use for maintenance or operational upkeep that is necessary but not primarily a user/team-facing enhancement. + +Typical evidence: + +- dependency upgrades, cleanup, refactors, migrations, or repository hygiene +- docs/process maintenance that keeps existing operations accurate +- test/build/tooling upkeep without a new workflow capability +- audit follow-up that normalizes existing state + +Docs and process work are normally `Chore`, not `Plan`, unless the issue is explicitly a parent planning container with real sub-issues. + +If the work creates a substantial new reusable workflow or capability, or upgrades an existing capability with new real functionality, use `Feature` instead. + +### `Design` + +Use narrowly. `Design` means actual GUI, graphic, UI/UX, visual branding, or design-system work. + +Typical evidence: + +- linked Figma or design asset +- wireframes, mocks, visual design specifications, brand assets, or design-system components +- acceptance criteria owned by UI/UX or design review +- work output is a design artifact, not merely a documentation or planning artifact + +Use `Design` when the ticket output is the design itself or design approval. + +If a ticket requires both serious design output and implementation, split it: + +- the design ticket is `Design` +- the implementation ticket is usually `Feature` +- the implementation ticket should be blocked by the design ticket + +Do not use `Design` for generic product thinking, information architecture in prose, process docs, or issues that mention UI only as implementation context. + +Word-only UI tickets are usually not `Design`. They are usually `Feature` when they add meaningful UX/functionality, or `Chore` when they are minor visual upkeep without meaningful UX/functionality, such as a tiny color or spacing adjustment. + +### `Plan` + +Use when the issue is a parent planning ticket that structures multiple real sub-issues toward one precise goal. A `Plan` is closest to a mini-project or epic. + +Typical evidence: + +- the issue intentionally owns real sub-issues, not merely a checklist +- each sub-issue is expected to behave like a normal ticket, with its own type, PR and QA/PM route where applicable +- the issue captures a rollout or planned sequence that was actually planned beforehand +- scope needs decomposition before implementation +- acceptance criteria are about maintaining the plan, rollout shape, or child-ticket set +- parent issue is not the normal velocity unit + +Do not use `Plan` as a checklist type. If work items are not real sub-issues, split them or do not call the parent a `Plan`. + +Do not use `Plan` for every vague or large ticket. Vague tickets should be blocked by `needs-specs` and usually moved to `Icebox 🧊` until they become specific enough. + +### `Release` + +Use rarely. `Release` is a coordinated shipping object, not a synonym for "done soon". + +Typical evidence: + +- real release scope and target date or shipping window +- version bump, release notes, announcement, rollout, or post-release checklist +- grouped verification across multiple completed items +- explicit release owner or release coordination responsibility + +Do not use `Release` for ordinary implementation, PR merge, deployment task, or milestone-like grouping without actual release mechanics. + +Current operating note: `Release` tickets are not in regular use yet. Until the operating model changes, classify cautiously and prefer `Plan` unless the release mechanics above are explicit. + +Like `Plan`, a `Release` is a coordinating container. The real sub-issues under it still need their own issue types and normal routing. + +## Common tie-breakers + +| Ambiguous case | Use this rule | +|---|---| +| Feature vs Chore | New or upgraded functionality with real value to people/agents is `Feature`; upkeep, cleanup, refactor, docs/process maintenance, or normalization is `Chore`. | +| Bug vs Chore | If expected behavior did not work and there is evidence, use `Bug`; otherwise maintenance/hardening is usually `Chore`. | +| Plan vs Chore | A parent with real sub-issues toward a precise goal is `Plan`; docs/process planning or cleanup without real subtickets is usually `Chore`. | +| Design vs Feature | Design artifact or design approval is `Design`; implemented product/UI behavior is usually `Feature`. If both are required, split the tickets. | +| Release vs Plan | Real shipping scope/date/checklist/version impact is `Release`; coordinated multi-ticket work without release mechanics is `Plan`. | +| Audit follow-up | Usually `Chore`; use `Bug` if the audit found broken expected behavior, or `Feature` if the follow-up adds real new functionality. | + +## Learning loop + +The hygiene audit can mutate issue type/routing and report back to CJ. If CJ corrects the logic, the correction must become durable: + +1. append one short entry to `linear/docs/process/linear_type_classifier_corrections_v1.md` +2. update `linear/contracts/linear_type_classifier_v1.json` or add/update a classifier test fixture +3. include the learned rule in the next hygiene audit report + +This is how the automation learns without hidden memory or long prompt reasoning. + +## Routing note + +Issue type is not the same as QA/PM routing. + +Known routing direction: + +- Highly technical chores with no product behavior change may skip PM gating when QA evidence is sufficient. +- Design tickets normally skip QA and go to PM/design acceptance. +- Tiny bugs may skip PM gating when QA evidence is sufficient and the impact is narrow. +- Plans and Releases are coordinating containers, not normal implementation tickets; their sub-issues carry the real type and QA/PM route. + +Routing defaults are advisory in this PR. Detailed routing enforcement belongs in the QA/PM acceptance and Linear automation flow before automation sets or validates QA/PM labels from these defaults. + +## Ambiguity rules + +- Do not infer type from title alone. +- Do not retroactively reclassify ambiguous historical issues without evidence. +- Do not guess estimates while setting type. +- Preserve existing type when evidence supports it, even if a different type could also be plausible. +- When two types seem plausible, classify by the primary acceptance criteria and output. +- When evidence is missing, use `needs-type` and state the exact missing evidence. + +## Hygiene audit checklist + +For each issue checked: + +- Exactly one canonical issue type is present. +- The type is supported by issue evidence. +- `Design` is only used for actual UI/UX/graphic/visual/design-system work. +- `Release` is only used for real coordinated shipping objects. +- Missing or ambiguous type evidence is marked with `needs-type` rather than guessed. diff --git a/linear/docs/process/linear_operating_guide_changelog.md b/linear/docs/process/linear_operating_guide_changelog.md index 295a2a9..29a44d1 100644 --- a/linear/docs/process/linear_operating_guide_changelog.md +++ b/linear/docs/process/linear_operating_guide_changelog.md @@ -36,11 +36,22 @@ Maintenance update — 2026-04-28: - require single clean retroactive-link comments instead of duplicate or shorthand-only comments - make project-scope cleanup fail closed when available tooling cannot remove a wrong project assignment +Maintenance update — 2026-05-05: +- add `linear_issue_type_decision_guide_v1.md` as the canonical evidence-based issue-type decision guide +- require issue-type decisions to use evidence rather than title-only inference +- keep `Design` narrow and `Release` rare +- add `linear/contracts/linear_type_classifier_v1.json` as the machine-readable issue-type classifier +- add `linear_type_classifier_corrections_v1.md` as the human correction log that feeds classifier updates/tests +- document the temporary Backlog-status-ID workaround for the observed issue-creation path that can land new Product Development tickets in `Icebox 🧊` + Linked artifacts: -- `/Users/cjarguello/bitpod-app/bitpod-tools/linear/docs/process/linear_operating_guide_v3.md` -- `/Users/cjarguello/bitpod-app/bitpod-tools/linear/docs/process/linear_admin_change_control_v1.md` -- `/Users/cjarguello/bitpod-app/bitpod-tools/linear/docs/process/linear_change_proposal_template_v1.md` -- `/Users/cjarguello/bitpod-app/bitpod-tools/linear/docs/process/linear_process_v1_1_control_tower_change_proposal_2026-04-15.md` +- `/Users/taylor01/BitPod-App/bitpod-tools/linear/docs/process/linear_operating_guide_v3.md` +- `/Users/taylor01/BitPod-App/bitpod-tools/linear/contracts/linear_type_classifier_v1.json` +- `/Users/taylor01/BitPod-App/bitpod-tools/linear/docs/process/linear_issue_type_decision_guide_v1.md` +- `/Users/taylor01/BitPod-App/bitpod-tools/linear/docs/process/linear_type_classifier_corrections_v1.md` +- `/Users/taylor01/BitPod-App/bitpod-tools/linear/docs/process/linear_admin_change_control_v1.md` +- `/Users/taylor01/BitPod-App/bitpod-tools/linear/docs/process/linear_change_proposal_template_v1.md` +- `/Users/taylor01/BitPod-App/bitpod-tools/linear/docs/process/linear_process_v1_1_control_tower_change_proposal_2026-04-15.md` Rollback target: - `v2` @@ -57,12 +68,12 @@ Includes: - Explicit rollback procedure Linked artifacts: -- `/Users/cjarguello/bitpod-app/bitpod-tools/linear/docs/process/linear_operating_guide_v1.md` -- `/Users/cjarguello/bitpod-app/bitpod-tools/linear/docs/process/github_org_baseline_policy_v1.md` -- `/Users/cjarguello/bitpod-app/bitpod-tools/linear/docs/process/github_org_baseline_evidence_2026-03-06.md` -- `/Users/cjarguello/bitpod-app/bitpod-tools/linear/docs/process/github_org_team_access_map_2026-03-07.md` -- `/Users/cjarguello/bitpod-app/bitpod-tools/linear/docs/process/github_repo_security_matrix_2026-03-07.md` -- `/Users/cjarguello/bitpod-app/bitpod-tools/linear/docs/process/governance_parity_checklist_2026-03-07.md` +- `/Users/taylor01/BitPod-App/bitpod-tools/linear/docs/process/linear_operating_guide_v1.md` +- `/Users/taylor01/BitPod-App/bitpod-tools/linear/docs/process/github_org_baseline_policy_v1.md` +- `/Users/taylor01/BitPod-App/bitpod-tools/linear/docs/process/github_org_baseline_evidence_2026-03-06.md` +- `/Users/taylor01/BitPod-App/bitpod-tools/linear/docs/process/github_org_team_access_map_2026-03-07.md` +- `/Users/taylor01/BitPod-App/bitpod-tools/linear/docs/process/github_repo_security_matrix_2026-03-07.md` +- `/Users/taylor01/BitPod-App/bitpod-tools/linear/docs/process/governance_parity_checklist_2026-03-07.md` Rollback target: - This is baseline v1; rollback target is itself until v2 exists. @@ -78,9 +89,9 @@ Includes: - guidance for classifying reusable work as `core`, `policy`, `adapter`, `bitpod-embedding`, or `mixed` Linked artifacts: -- `/Users/cjarguello/bitpod-app/bitpod-tools/linear/docs/process/linear_operating_guide_v2.md` -- `/Users/cjarguello/bitpod-app/bitpod-tools/linear/docs/process/linear_issue_template_evidence_contract_v2.md` -- `/Users/cjarguello/bitpod-app/bitpod-tools/linear/docs/process/taylor01_portability_review_gate_v1.md` +- `/Users/taylor01/BitPod-App/bitpod-tools/linear/docs/process/linear_operating_guide_v2.md` +- `/Users/taylor01/BitPod-App/bitpod-tools/linear/docs/process/linear_issue_template_evidence_contract_v2.md` +- `/Users/taylor01/BitPod-App/bitpod-tools/linear/docs/process/taylor01_portability_review_gate_v1.md` Rollback target: - `v1` diff --git a/linear/docs/process/linear_operating_guide_v3.md b/linear/docs/process/linear_operating_guide_v3.md index 3fece86..2624d3d 100644 --- a/linear/docs/process/linear_operating_guide_v3.md +++ b/linear/docs/process/linear_operating_guide_v3.md @@ -60,6 +60,8 @@ Maintenance update — 2026-04-28: - what problem is being solved - what done looks like - who owns it right now +- Every issue that moves to `Ready` must have exactly one evidence-supported canonical issue type. +- Use `linear/contracts/linear_type_classifier_v1.json` plus `linear_issue_type_decision_guide_v1.md` for issue-type decisions; do not infer type from title alone. - If those are missing, the issue is not actually ready. 5. Keep issues small and action-oriented @@ -90,6 +92,7 @@ Maintenance update — 2026-04-28: - Treat this assignee/delegate restriction as a temporary safety rule until Codex is decoupled from the user's personal Linear identity. - Do not casually change priority, estimate, due date, or milestone when confidence is low. - Do not create duplicate retroactive issues when an existing issue already owns the scope. +- Temporary Product Development issue-creation workaround: if a new issue lands in `Icebox 🧊` despite being intended for `Backlog`, immediately correct it to `Backlog` using the concrete Backlog status ID `162716a8-ffa4-43ea-9e0d-c48fdb8054bc` and record the evidence. Tracked by BIT-442. 8. Capability degradation handling - If tool behavior is impaired, stop speculative actions and post minimal verified state. @@ -198,6 +201,9 @@ Artifacts: - `linear_operating_model_v1.md` - `linear_operating_guide_v3.md` +- `linear/contracts/linear_type_classifier_v1.json` +- `linear_issue_type_decision_guide_v1.md` +- `linear_type_classifier_corrections_v1.md` - `linear_issue_template_evidence_contract_v2.md` - `taylor01_portability_review_gate_v1.md` - `linear_admin_change_control_v1.md` diff --git a/linear/docs/process/linear_operating_model_v1.md b/linear/docs/process/linear_operating_model_v1.md index cd3166a..a63f20e 100644 --- a/linear/docs/process/linear_operating_model_v1.md +++ b/linear/docs/process/linear_operating_model_v1.md @@ -238,6 +238,9 @@ Canonical issue types: - no extra type taxonomy - `Plan` and `Release` are the only issue types where sub-issues are generally expected - outside of `Plan` and some `Release` tickets, sub-issues should usually be avoided +- Type assignment must be evidence-based; use `linear_issue_type_decision_guide_v1.md` when creating, triaging, normalizing, or hygiene-auditing issue types +- The automation source for issue-type decisions is `linear/contracts/linear_type_classifier_v1.json`; the decision guide explains intent and edge cases +- Do not infer issue type from title alone; if evidence is missing, leave the type unset and use `needs-type` ### Feature definition diff --git a/linear/docs/process/linear_type_classifier_corrections_v1.md b/linear/docs/process/linear_type_classifier_corrections_v1.md new file mode 100644 index 0000000..d65f93c --- /dev/null +++ b/linear/docs/process/linear_type_classifier_corrections_v1.md @@ -0,0 +1,39 @@ +# Linear Type Classifier Corrections v1 + +Status: Active learning log +Owner: Product Development +Machine source: `linear/contracts/linear_type_classifier_v1.json` + +## Purpose + +Record CJ corrections to automation hygiene issue-type or routing decisions in one durable place. + +This file is the human-readable learning surface. It is not enough by itself: every correction that changes future behavior must also be converted into the machine-readable classifier or a classifier test fixture. + +## Correction workflow + +When automation hygiene mutates Linear issue type/routing and CJ corrects it: + +1. Add one short entry below. +2. Summarize the corrected rule in plain language. +3. Update `linear/contracts/linear_type_classifier_v1.json` or add a classifier test fixture in the same run/PR. +4. Reference the changed rule/test in the audit report back to CJ. + +Helper: +- `python3 linear/scripts/record_type_classifier_correction.py` can append an entry here and (optionally) add a machine-enforced fixture row in `linear/tests/fixtures/linear_type_classifier_corrections_v1.json` (BIT-441). + +## Entry format + +```md +### YYYY-MM-DD — short correction title + +- Source: BIT-000 or hygiene audit run link +- Automation chose: Type / route +- CJ correction: Type / route +- Rule learned: one sentence +- Machine update: classifier rule/test path or `pending` +``` + +## Corrections + +_No corrections recorded yet._ diff --git a/linear/scripts/record_type_classifier_correction.py b/linear/scripts/record_type_classifier_correction.py new file mode 100644 index 0000000..43b1fc9 --- /dev/null +++ b/linear/scripts/record_type_classifier_correction.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +""" +Record a CJ correction to the Linear issue-type classifier in a durable way. + +BIT-441 intent: +- keep the human-readable correction log in one place +- ensure every correction also becomes machine-enforced via a test fixture + +This script is intentionally simple and local-only (repo mutation). +""" + +from __future__ import annotations + +import argparse +import json +from datetime import date +from pathlib import Path +from typing import Any, Dict, List + + +ROOT = Path(__file__).resolve().parents[1] +CORRECTIONS_MD = ROOT / "docs" / "process" / "linear_type_classifier_corrections_v1.md" +FIXTURE_JSON = ROOT / "tests" / "fixtures" / "linear_type_classifier_corrections_v1.json" + + +def today_iso() -> str: + return date.today().isoformat() + + +def append_md_entry( + *, + when: str, + title: str, + source: str, + automation_chose: str, + cj_correction: str, + rule_learned: str, + machine_update: str, +) -> None: + entry = ( + f"\n### {when} — {title}\n\n" + f"- Source: {source}\n" + f"- Automation chose: {automation_chose}\n" + f"- CJ correction: {cj_correction}\n" + f"- Rule learned: {rule_learned}\n" + f"- Machine update: {machine_update}\n" + ) + + text = CORRECTIONS_MD.read_text(encoding="utf-8") + marker = "_No corrections recorded yet._" + if marker in text: + text = text.replace(marker, "").rstrip() + "\n" + CORRECTIONS_MD.write_text(text.rstrip() + "\n" + entry.lstrip(), encoding="utf-8") + + +def load_fixture() -> List[Dict[str, Any]]: + if not FIXTURE_JSON.exists(): + return [] + payload = json.loads(FIXTURE_JSON.read_text(encoding="utf-8") or "[]") + if not isinstance(payload, list): + raise SystemExit(f"invalid fixture JSON (expected list): {FIXTURE_JSON}") + return payload + + +def write_fixture(rows: List[Dict[str, Any]]) -> None: + FIXTURE_JSON.parent.mkdir(parents=True, exist_ok=True) + FIXTURE_JSON.write_text(json.dumps(rows, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def main() -> int: + ap = argparse.ArgumentParser(description="Record a Linear type-classifier correction (BIT-441).") + ap.add_argument("--date", default=today_iso(), help="Entry date (YYYY-MM-DD). Defaults to today.") + ap.add_argument("--title", required=True, help="Short correction title.") + ap.add_argument("--source", required=True, help="BIT-000 or hygiene audit run link.") + ap.add_argument("--automation-chose", required=True, help="What automation chose (type/route).") + ap.add_argument("--cj-correction", required=True, help="CJ correction (type/route).") + ap.add_argument("--rule-learned", required=True, help="One-sentence learned rule.") + ap.add_argument( + "--machine-update", + default="pending", + help="Classifier rule/test path, or 'pending' (not recommended).", + ) + + ap.add_argument( + "--intake-json", + default="", + help="Optional JSON object containing the classifier intake fields to enforce via test fixture.", + ) + ap.add_argument( + "--expected-type", + default="", + help="Optional expected canonical type label to enforce for --intake-json (e.g., '⚙️ Chore').", + ) + + args = ap.parse_args() + + if not CORRECTIONS_MD.exists(): + raise SystemExit(f"missing corrections log: {CORRECTIONS_MD}") + + append_md_entry( + when=args.date, + title=args.title.strip(), + source=args.source.strip(), + automation_chose=args.automation_chose.strip(), + cj_correction=args.cj_correction.strip(), + rule_learned=args.rule_learned.strip(), + machine_update=args.machine_update.strip(), + ) + + if args.intake_json.strip(): + intake: Dict[str, Any] = json.loads(args.intake_json) + if not isinstance(intake, dict): + raise SystemExit("--intake-json must be a JSON object") + if not args.expected_type.strip(): + raise SystemExit("--expected-type is required when --intake-json is set") + rows = load_fixture() + rows.append( + { + "date": args.date, + "title": args.title.strip(), + "source": args.source.strip(), + "intake": intake, + "expected_type": args.expected_type.strip(), + } + ) + write_fixture(rows) + + print(f"updated: {CORRECTIONS_MD}") + if args.intake_json.strip(): + print(f"updated: {FIXTURE_JSON}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/linear/src/engine.py b/linear/src/engine.py index 1ad4d3a..2401849 100644 --- a/linear/src/engine.py +++ b/linear/src/engine.py @@ -1,8 +1,9 @@ import json import re +from pathlib import Path from dataclasses import dataclass from datetime import datetime, timezone -from typing import Any, Dict, List, Optional, Set +from typing import Any, Dict, List, Optional, Set, Tuple ISSUE_KEY_RE = re.compile(r"\b([A-Z]{2,}-\d+)\b") QA_TOKEN_RE = re.compile(r"QA_RESULT=(PASSED|FAILED|SKIPPED)") @@ -27,6 +28,22 @@ "🏁 Release": "Release", } VALID_ESTIMATES = {1, 2, 3, 5, 8} +CLASSIFIER_CONTRACT_PATH = Path(__file__).resolve().parents[1] / "contracts" / "linear_type_classifier_v1.json" + + +def _load_classifier_contract() -> Dict[str, Any]: + with CLASSIFIER_CONTRACT_PATH.open(encoding="utf-8") as f: + return json.load(f) + + +CLASSIFIER_CONTRACT = _load_classifier_contract() +CLASSIFICATION_FIELDS = set(CLASSIFIER_CONTRACT["required_intake_fields"]) +VALID_OUTPUTS = set(CLASSIFIER_CONTRACT["output_values"]) +OUTPUT_ALIASES = {value: value for value in VALID_OUTPUTS} +OUTPUT_ALIASES.update(CLASSIFIER_CONTRACT.get("output_aliases", {})) +STRICT_BOOLEAN_FIELDS = set(CLASSIFIER_CONTRACT["strict_boolean_fields"]) +BOOLEAN_VALUES = set(CLASSIFIER_CONTRACT["boolean_values"]) + @dataclass @@ -103,6 +120,99 @@ def _extract_pr_url_token(self, comment_body: str) -> str: def _labels(self, issue_labels: Optional[List[str]]) -> Set[str]: return set(issue_labels or []) + def _truthy(self, value: Any) -> bool: + return str(value or "").strip().lower() == "yes" + + def _parse_linear_classification(self, description: str) -> Dict[str, str]: + if not description: + return {} + lines = description.splitlines() + in_block = False + out: Dict[str, str] = {} + for raw in lines: + line = raw.strip() + if not in_block: + if line.rstrip(":").lower() == "linear classification": + in_block = True + continue + if not line: + if out: + break + continue + if line.startswith("##") or (not line.startswith("-") and out): + break + if not line.startswith("-") or ":" not in line: + continue + key, value = line[1:].split(":", 1) + key = key.strip() + value = value.strip() + if key in CLASSIFICATION_FIELDS: + out[key] = value + return out + + def _classification_missing_fields(self, intake: Dict[str, str]) -> Set[str]: + return {field for field in CLASSIFICATION_FIELDS if not intake.get(field)} + + def _normalize_output(self, value: Any) -> str: + raw = str(value or "").strip().lower() + return OUTPUT_ALIASES.get(raw, raw) + + def _classification_invalid_fields(self, intake: Dict[str, str]) -> Dict[str, str]: + invalid: Dict[str, str] = {} + output = self._normalize_output(intake.get("Output")) + if output not in VALID_OUTPUTS: + invalid["Output"] = str(intake.get("Output", "")) + for field in STRICT_BOOLEAN_FIELDS: + value = str(intake.get(field, "")).strip().lower() + if value not in BOOLEAN_VALUES: + invalid[field] = str(intake.get(field, "")) + return invalid + + def classify_issue_type(self, intake: Dict[str, str]) -> Tuple[Optional[str], str]: + invalid = self._classification_invalid_fields(intake) + if invalid: + return None, "invalid classifier intake: " + ", ".join(f"{k}={v!r}" for k, v in sorted(invalid.items())) + + output = self._normalize_output(intake.get("Output", "")) + evidence = str(intake.get("Evidence", "")).strip() + behavior_change = self._truthy(intake.get("Behavior change")) + broken = self._truthy(intake.get("Broken existing behavior")) + children_expected = self._truthy(intake.get("Children expected")) + + for rule in CLASSIFIER_CONTRACT["type_decision_order"]: + issue_type = rule["type"] + when = rule["when"] + if when.get("default"): + return issue_type, rule["reason"] + if when.get("evidence_required") and not evidence: + continue + if "broken_existing_behavior" in when and when["broken_existing_behavior"] != broken: + continue + if "children_expected" in when and when["children_expected"] != children_expected: + continue + if "behavior_change" in when and when["behavior_change"] != behavior_change: + continue + if "output_any_of" in when and output not in set(when["output_any_of"]): + continue + return issue_type, rule["reason"] + return None, "no classifier rule matched" + + def classify_route(self, issue_type: str, intake: Dict[str, str]) -> Tuple[str, str, str]: + invalid = self._classification_invalid_fields(intake) + if invalid: + return "blocked", "blocked", "invalid classifier intake" + pm_testable = self._truthy(intake.get("PM-testable")) + for rule in CLASSIFIER_CONTRACT["routing_defaults"]: + if "type" in rule and rule["type"] != issue_type: + continue + if "type_any_of" in rule and issue_type not in set(rule["type_any_of"]): + continue + condition = rule.get("condition", {}) + if "pm_testable" in condition and condition["pm_testable"] != pm_testable: + continue + return rule["qa"], rule["pm"], rule["reason"] + return "required", "required", "default implementation route" + def _normalize_type_label(self, label: str) -> Optional[str]: if not label: return None @@ -199,12 +309,53 @@ def on_github_pr_ready_for_review(self, event: Dict[str, Any]) -> List[Action]: def on_linear_ready_gate(self, issue: Dict[str, Any]) -> List[Action]: issue_key = issue.get("identifier", "") status_name = issue.get("status", "") - if status_name not in (self.cfg.todo_status, self.cfg.in_progress_status): + if status_name != self.cfg.todo_status: return [] labels = self._labels(issue.get("labels", [])) description = issue.get("description", "") + intake = self._parse_linear_classification(description) + missing_intake = self._classification_missing_fields(intake) + invalid_intake = self._classification_invalid_fields(intake) if not missing_intake else {} + if missing_intake: + return [ + Action( + "linear", + "set_label", + issue_key, + {"group": self.cfg.blocked_group, "value": self.cfg.blocked_needs_type}, + ), + Action( + "linear", + "comment", + issue_key, + { + "body": "Missing `Linear Classification` block or fields: " + ", ".join(sorted(missing_intake)) + ". Add Output / Behavior change / Broken existing behavior / Evidence / Children expected / PM-testable before moving to Ready." + }, + ), + Action("linear", "set_status", issue_key, {"status": self.cfg.backlog_status}), + ] + + if invalid_intake: + return [ + Action( + "linear", + "set_label", + issue_key, + {"group": self.cfg.blocked_group, "value": self.cfg.blocked_needs_type}, + ), + Action( + "linear", + "comment", + issue_key, + { + "body": "Invalid `Linear Classification` values: " + ", ".join(f"{k}={v!r}" for k, v in sorted(invalid_intake.items())) + ". Use constrained Output values and yes/no boolean fields before moving to Ready." + }, + ), + Action("linear", "set_status", issue_key, {"status": self.cfg.backlog_status}), + ] + type_labels = self._type_labels(labels) if len(type_labels) != 1: return [ @@ -219,7 +370,28 @@ def on_linear_ready_gate(self, issue: Dict[str, Any]) -> List[Action]: "comment", issue_key, { - "body": "Missing or invalid `Issue Type`. Set exactly one canonical type label: `📄 Plan` `⭐️ Feature` `🐞 Bug` `⚙️ Chore` `🎨 Design` `🏁 Release`" + "body": "Missing or invalid `Issue Type`. Set exactly one canonical type label: `📄 Plan` `⭐️ Feature` `🐞 Bug` `⚙️ Chore` `🎨 Design` `🏁 Release`. Use the machine classifier intake block, not title-only inference." + }, + ), + Action("linear", "set_status", issue_key, {"status": self.cfg.backlog_status}), + ] + + predicted_type, predicted_reason = self.classify_issue_type(intake) + actual_type = next(iter(type_labels)) + if predicted_type and actual_type != predicted_type: + return [ + Action( + "linear", + "set_label", + issue_key, + {"group": self.cfg.blocked_group, "value": self.cfg.blocked_needs_type}, + ), + Action( + "linear", + "comment", + issue_key, + { + "body": f"Issue Type does not match `Linear Classification` classifier. Label is `{actual_type}` but classifier predicts `{predicted_type}` because {predicted_reason}. Correct the intake block or type label before moving to Ready." }, ), Action("linear", "set_status", issue_key, {"status": self.cfg.backlog_status}), @@ -237,7 +409,7 @@ def on_linear_ready_gate(self, issue: Dict[str, Any]) -> List[Action]: "linear", "comment", issue_key, - {"body": "Missing or invalid estimate. Set one of: `1` `2` `3` `5` `8` before moving this issue into active execution."}, + {"body": "Missing or invalid estimate. Set one of: `1` `2` `3` `5` `8` before moving this issue to Ready."}, ), Action("linear", "set_status", issue_key, {"status": self.cfg.backlog_status}), ] diff --git a/linear/tests/fixtures/linear_type_classifier_corrections_v1.json b/linear/tests/fixtures/linear_type_classifier_corrections_v1.json new file mode 100644 index 0000000..7dd4387 --- /dev/null +++ b/linear/tests/fixtures/linear_type_classifier_corrections_v1.json @@ -0,0 +1,2 @@ +[] + diff --git a/linear/tests/test_classifier_corrections.py b/linear/tests/test_classifier_corrections.py new file mode 100644 index 0000000..54aa8fc --- /dev/null +++ b/linear/tests/test_classifier_corrections.py @@ -0,0 +1,33 @@ +import json +import unittest +from pathlib import Path + +from linear.src.engine import LinearBotEngine + + +FIXTURE_PATH = Path(__file__).resolve().parent / "fixtures" / "linear_type_classifier_corrections_v1.json" + + +class TestClassifierCorrections(unittest.TestCase): + def setUp(self) -> None: + self.bot = LinearBotEngine() + + def test_corrections_fixture_enforces_expected_types(self): + if not FIXTURE_PATH.exists(): + self.fail(f"missing fixture: {FIXTURE_PATH}") + + rows = json.loads(FIXTURE_PATH.read_text(encoding="utf-8") or "[]") + self.assertIsInstance(rows, list) + + for idx, row in enumerate(rows, start=1): + intake = row.get("intake") + expected = row.get("expected_type") + self.assertIsInstance(intake, dict, f"row {idx}: intake must be an object") + self.assertIsInstance(expected, str, f"row {idx}: expected_type must be a string") + predicted, reason = self.bot.classify_issue_type(intake) + self.assertEqual( + expected, + predicted, + f"row {idx}: expected {expected!r} but got {predicted!r}. reason={reason}", + ) + diff --git a/linear/tests/test_engine.py b/linear/tests/test_engine.py index feb31e8..b285dab 100644 --- a/linear/tests/test_engine.py +++ b/linear/tests/test_engine.py @@ -1,7 +1,15 @@ import unittest from datetime import datetime, timezone -from linear.src.engine import LinearBotEngine +from linear.src.engine import ( + BOOLEAN_VALUES, + CANONICAL_TYPES, + CLASSIFICATION_FIELDS, + CLASSIFIER_CONTRACT, + STRICT_BOOLEAN_FIELDS, + VALID_OUTPUTS, + LinearBotEngine, +) class EngineTests(unittest.TestCase): @@ -45,7 +53,7 @@ def test_ready_gate_missing_type(self): "status": "Ready", "labels": [], "estimate": 3, - "description": "Objective\nScope\nRequired outputs\nVerification plan\nRollback note\nAcceptance / closure criteria", + "description": "Linear Classification\n- Output: code\n- Behavior change: yes\n- Broken existing behavior: no\n- Evidence: acceptance criteria\n- Children expected: no\n- PM-testable: yes\n\nObjective\nScope\nRequired outputs\nVerification plan\nRollback note\nAcceptance / closure criteria", } actions = self.bot.on_linear_ready_gate(issue) self.assertTrue(any(a.kind == "set_status" and a.payload["status"] == "Backlog" for a in actions)) @@ -56,7 +64,7 @@ def test_ready_gate_missing_estimate(self): "identifier": "BIT-45", "status": "Ready", "labels": ["Feature"], - "description": "Objective\nScope\nRequired outputs\nVerification plan\nRollback note\nAcceptance / closure criteria", + "description": "Linear Classification\n- Output: code\n- Behavior change: yes\n- Broken existing behavior: no\n- Evidence: acceptance criteria\n- Children expected: no\n- PM-testable: yes\n\nObjective\nScope\nRequired outputs\nVerification plan\nRollback note\nAcceptance / closure criteria", } actions = self.bot.on_linear_ready_gate(issue) self.assertTrue(any(a.kind == "set_label" and a.payload["value"] == "needs-estimate" for a in actions)) @@ -67,7 +75,7 @@ def test_ready_gate_accepts_emoji_type_label(self): "status": "Ready", "labels": ["⭐️ Feature"], "estimate": 3, - "description": "Objective\nScope\nRequired outputs\nVerification plan\nRollback note\nAcceptance / closure criteria", + "description": "Linear Classification\n- Output: code\n- Behavior change: yes\n- Broken existing behavior: no\n- Evidence: acceptance criteria\n- Children expected: no\n- PM-testable: yes\n\nObjective\nScope\nRequired outputs\nVerification plan\nRollback note\nAcceptance / closure criteria", } actions = self.bot.on_linear_ready_gate(issue) self.assertEqual(actions, []) @@ -77,7 +85,7 @@ def test_ready_gate_plan_parent_requires_estimate(self): "identifier": "BIT-45", "status": "Ready", "labels": ["Plan"], - "description": "Objective\nScope\nRequired outputs\nVerification plan\nRollback note\nAcceptance / closure criteria", + "description": "Linear Classification\n- Output: plan container\n- Behavior change: no\n- Broken existing behavior: no\n- Evidence: child-ticket rollout scope\n- Children expected: yes\n- PM-testable: no\n\nObjective\nScope\nRequired outputs\nVerification plan\nRollback note\nAcceptance / closure criteria", } actions = self.bot.on_linear_ready_gate(issue) self.assertTrue(any(a.kind == "set_label" and a.payload["value"] == "needs-estimate" for a in actions)) @@ -88,10 +96,80 @@ def test_ready_gate_multiple_types_fails_closed(self): "status": "Ready", "labels": ["Feature", "Bug"], "estimate": 3, + "description": "Linear Classification\n- Output: code\n- Behavior change: yes\n- Broken existing behavior: no\n- Evidence: acceptance criteria\n- Children expected: no\n- PM-testable: yes\n\nObjective\nScope\nRequired outputs\nVerification plan\nRollback note\nAcceptance / closure criteria", + } + actions = self.bot.on_linear_ready_gate(issue) + self.assertTrue(any(a.kind == "set_label" and a.payload["value"] == "needs-type" for a in actions)) + + def test_ready_gate_missing_classification_block_fails_closed(self): + issue = { + "identifier": "BIT-45", + "status": "Ready", + "labels": ["Feature"], + "estimate": 3, "description": "Objective\nScope\nRequired outputs\nVerification plan\nRollback note\nAcceptance / closure criteria", } actions = self.bot.on_linear_ready_gate(issue) self.assertTrue(any(a.kind == "set_label" and a.payload["value"] == "needs-type" for a in actions)) + self.assertTrue(any(a.kind == "comment" and "Linear Classification" in a.payload["body"] for a in actions)) + + def test_ready_gate_type_mismatch_fails_closed(self): + issue = { + "identifier": "BIT-45", + "status": "Ready", + "labels": ["Chore"], + "estimate": 3, + "description": "Linear Classification\n- Output: code\n- Behavior change: yes\n- Broken existing behavior: no\n- Evidence: acceptance criteria\n- Children expected: no\n- PM-testable: yes\n\nObjective\nScope\nRequired outputs\nVerification plan\nRollback note\nAcceptance / closure criteria", + } + actions = self.bot.on_linear_ready_gate(issue) + self.assertTrue(any(a.kind == "set_label" and a.payload["value"] == "needs-type" for a in actions)) + self.assertTrue(any(a.kind == "comment" and "classifier predicts `Feature`" in a.payload["body"] for a in actions)) + + def test_ready_gate_invalid_classification_values_fail_closed(self): + issue = { + "identifier": "BIT-45", + "status": "Ready", + "labels": ["Chore"], + "estimate": 3, + "description": "Linear Classification\n- Output: ???\n- Behavior change: maybe\n- Broken existing behavior: TBD\n- Evidence: acceptance criteria\n- Children expected: no\n- PM-testable: no\n\nObjective\nScope\nRequired outputs\nVerification plan\nRollback note\nAcceptance / closure criteria", + } + actions = self.bot.on_linear_ready_gate(issue) + self.assertTrue(any(a.kind == "set_label" and a.payload["value"] == "needs-type" for a in actions)) + self.assertTrue(any(a.kind == "comment" and "Invalid `Linear Classification` values" in a.payload["body"] for a in actions)) + + def test_ready_gate_ignores_existing_in_progress_for_migration(self): + issue = { + "identifier": "BIT-45", + "status": "In Progress", + "labels": ["Feature"], + "estimate": 3, + "description": "Objective\nScope\nRequired outputs\nVerification plan\nRollback note\nAcceptance / closure criteria", + } + self.assertEqual(self.bot.on_linear_ready_gate(issue), []) + + def test_classifier_contract_parity(self): + self.assertEqual(CANONICAL_TYPES, set(CLASSIFIER_CONTRACT["canonical_types"])) + self.assertEqual(CLASSIFICATION_FIELDS, set(CLASSIFIER_CONTRACT["required_intake_fields"])) + self.assertEqual(VALID_OUTPUTS, set(CLASSIFIER_CONTRACT["output_values"])) + self.assertEqual(STRICT_BOOLEAN_FIELDS, set(CLASSIFIER_CONTRACT["strict_boolean_fields"])) + self.assertEqual(BOOLEAN_VALUES, set(CLASSIFIER_CONTRACT["boolean_values"])) + self.assertEqual( + [rule["type"] for rule in CLASSIFIER_CONTRACT["type_decision_order"]], + ["Bug", "Design", "Release", "Plan", "Feature", "Chore"], + ) + + def test_classifier_routes_technical_chore_pm_skip_allowed(self): + intake = { + "Output": "docs/process", + "Behavior change": "no", + "Broken existing behavior": "no", + "Evidence": "process maintenance", + "Children expected": "no", + "PM-testable": "no", + } + issue_type, _reason = self.bot.classify_issue_type(intake) + self.assertEqual(issue_type, "Chore") + self.assertEqual(self.bot.classify_route(issue_type, intake)[:2], ("required", "skip_allowed")) def test_linear_comment_failed(self): actions = self.bot.on_linear_comment(