diff --git a/.abcd/development/brief/05-internals/06-lint.md b/.abcd/development/brief/05-internals/06-lint.md index d4c92c06..4d26260d 100644 --- a/.abcd/development/brief/05-internals/06-lint.md +++ b/.abcd/development/brief/05-internals/06-lint.md @@ -112,7 +112,7 @@ Lint codes are 2-letter category prefix + 3-digit numeric suffix: `AAnnn`. Categ - `RC007` DONE spec references an itd-N with NO intent file in any bucket — reference-to-nothing. Same DONE-spec walk + extraction priority as `RC006`. Severity: info. **Delivered** in spc-48 .2. The gate keeps its REPORT-not-block exit grammar (clean/info/warn → exit 0; nonzero only for a config-promoted blocker or a malformed invocation) — `RC006`/`RC007` do not change it. - `RD001` review directory malformed — a directory under `.abcd/work/reviews/` is not named `-` or lacks the required `00-summary.md` consolidated report. The charter's `reviews/README.md` sits at the root and is exempt; so are the 40-hex sha-keyed semantic-gate receipt directories (`.abcd/work/reviews/<40-hex>/.json`, iss-35), a distinct artefact class with their own `receipt_gate` integrity check. Severity: blocker. Enforced now by the standalone pre-push/CI gate `scripts/check-reviews.sh`; ports into `internal/core/lint`. -- `RD002` review file edited after creation — a file inside a dated review directory carries a post-creation modify/rename in committed history, violating the append-only rule (a review is immutable; reality is never edited to match a review, nor a review to match reality). Deletion detection over history is deferred to the ported Go implementation. Severity: blocker. Enforced now by `scripts/check-reviews.sh`, which needs full git history: on a shallow checkout the script refuses (exit 2) rather than pass vacuously, since past the graft boundary every file reads as newly added and the append-only check covers nothing. The RS-family gate (`scripts/check-issue-resolution.sh`) shares the refusal for the same reason — an unfetched commit is indistinguishable from an absent one. The CI `record-lint` job checks out with full depth. +- `RD002` review file changed after creation — a file inside a dated review directory carries a post-creation modify, rename or delete in committed history, violating the append-only rule (a review is immutable; reality is never edited to match a review, nor a review to match reality). All three shapes refuse: the gate makes ONE history pass over the reviews root (`git log --name-status --diff-filter=DMRT`) and flags every status line whose path falls in the dated-directory population, rather than probing each working-tree file — a pathspec-scoped log never reports a rename, and a deleted file leaves no tree path to probe from. Severity: blocker. Enforced now by `scripts/check-reviews.sh`, which needs full git history: on a shallow checkout the script refuses (exit 2) rather than pass vacuously, since past the graft boundary every file reads as newly added and the append-only check covers nothing. Every other git failure refuses the same way (exit 2) instead of reading as a clean history; the one exception is an unborn HEAD — a freshly scaffolded repo — which passes while saying loudly that it covered nothing. `scripts/check-reviews-cases.sh` stages each shape in a scratch repository and asserts both polarities, so the refusal is a watched fact rather than a claim. The RS-family gate (`scripts/check-issue-resolution.sh`) shares the refusal for the same reason — an unfetched commit is indistinguishable from an absent one. The CI `record-lint` job checks out with full depth. - `RD003` review path leak — a review document contains an absolute personal path (`/Users/…`, `/home//…`, `C:\Users\…`) rather than a repo-relative one. Severity: blocker. Enforced now by `scripts/check-reviews.sh`; ports into `internal/core/lint`. The privacy scan is narrower here than the launch PII gate — it targets only the personal-path leak the charter forbids. The charter's two **semantic** rules — the provenance discriminator ("no abcd verb invocation produced it → it belongs here") and "reviews are not a shadow backlog" (findings graduate to intents/issues/ADRs) — are not machine-decidable and stay conventions, enforced by review, not lint. diff --git a/.abcd/development/decisions/adrs/0037-changelog-driven-releases.md b/.abcd/development/decisions/adrs/0037-changelog-driven-releases.md index f99f27d5..d70afa91 100644 --- a/.abcd/development/decisions/adrs/0037-changelog-driven-releases.md +++ b/.abcd/development/decisions/adrs/0037-changelog-driven-releases.md @@ -59,6 +59,13 @@ release automation: here is the *recording and cutting* instrument either way, not a second source of the number. + > **Amendment (2026-08-26).** itd-73 has landed: the ingest derives the + > number and renders the six closed Keep a Changelog sections, so a + > pre-1.0 break is signalled by the minor bump and the section its record + > belongs to, not by a **Breaking** heading — the derived renderer has no + > such section. The "called out under **Breaking**" clause above described + > the interim hand-rolled regime, whose last cut was v0.6.0. + ## Alternatives Considered - **Manual tag push as the decision.** The status quo. Rejected as policy: diff --git a/.abcd/development/intents/disciplines/itd-1-acceptance-gates.md b/.abcd/development/intents/disciplines/itd-1-acceptance-gates.md index 2a20f538..56bf0ecd 100644 --- a/.abcd/development/intents/disciplines/itd-1-acceptance-gates.md +++ b/.abcd/development/intents/disciplines/itd-1-acceptance-gates.md @@ -2,7 +2,7 @@ id: itd-1 slug: acceptance-gates kind: discipline -kind_notes: "Cross-cutting acceptance-criteria gate; applied via the record lint (internal/core/lint) at /abcd:intent plan time and verified by intent-fidelity-reviewer (single-document role) on every shipped intent." +kind_notes: "Cross-cutting acceptance-criteria gate; applied via the record lint (internal/core/lint) at /abcd:intent plan time and verified by intent-auditor (single-document role) on every shipped intent." suggested_kind: null spec_id: null reclassification_history: @@ -14,11 +14,11 @@ severity: critical ## Rule -Every standalone or bundle-member intent in `drafts/` and `planned/` carries a `## Acceptance Criteria` section with at least one well-formed Given-When-Then bullet. Every discipline intent in `disciplines/` carries the same. `/abcd:intent plan` refuses to promote an intent without it (hard block via the record lint, `internal/core/lint`). Every shipped intent's `## Audit Notes` section contains per-criterion verdicts (`MET` / `MET_WITH_CONCERNS` / `NOT_MET` / `INCONCLUSIVE`) emitted by the `intent-fidelity-reviewer` agent's single-document role. +Every standalone or bundle-member intent in `drafts/` and `planned/` carries a `## Acceptance Criteria` section with at least one well-formed Given-When-Then bullet. Every discipline intent in `disciplines/` carries the same. `/abcd:intent plan` refuses to promote an intent without it (hard block via the record lint, `internal/core/lint`). Every shipped intent's `## Audit Notes` section contains per-criterion verdicts (`MET` / `MET_WITH_CONCERNS` / `NOT_MET` / `INCONCLUSIVE`) emitted by the `intent-auditor` agent's single-document role. ## Why -abcd's intent format captures direction (press release or `## Rule`) and scope (in/out lists). Without acceptance criteria it cannot capture **the verifiable bar for "shipped"**. The `intent-fidelity-reviewer` agent is otherwise reduced to interpreting prose, which means drift detection depends on the reviewer's judgement rather than on a pre-committed standard. +abcd's intent format captures direction (press release or `## Rule`) and scope (in/out lists). Without acceptance criteria it cannot capture **the verifiable bar for "shipped"**. The `intent-auditor` agent is otherwise reduced to interpreting prose, which means drift detection depends on the reviewer's judgement rather than on a pre-committed standard. Prior art ([PAUL][paul]) treats acceptance criteria as a hard gate: defined before tasks, verified by an Execute/Qualify loop, with multi-state escalation outcomes (`DONE` / `DONE_WITH_CONCERNS` / `NEEDS_CONTEXT` / `BLOCKED`). The full PAUL framework is more than abcd currently needs; the **acceptance-criteria pattern** ([Given-When-Then][bdd-given-when-then]) is the load-bearing piece. @@ -28,7 +28,7 @@ This is a small schema bump with a large quality return. Every intent gets a mea - **`## Acceptance Criteria` section** required in every intent template (standalone, bundle-member, *and* discipline). At least one Given-When-Then bullet. The section header is fixed (parser depends on it). - **Hard-block validation in `/abcd:intent plan`** — intent cannot transition `drafts/` → `planned/` (or `drafts/` → `disciplines/`) without at least one well-formed acceptance criterion. Lint code: `IL002` (delivered by spc-8; see `05-internals/06-lint.md`). -- **`intent-fidelity-reviewer` single-document role** — when auditing a shipped intent, the agent emits a per-criterion verdict block into the intent's own `## Audit Notes` section. The writer maintains a single delimited `### itd-1 review ` block (machine-fenced so a repeat review *replaces* it in place — the section never accumulates stale blocks; git history is the prior-review trail): +- **`intent-auditor` single-document role** — when auditing a shipped intent, the agent emits a per-criterion verdict block into the intent's own `## Audit Notes` section. The writer maintains a single delimited `### itd-1 review ` block (machine-fenced so a repeat review *replaces* it in place — the section never accumulates stale blocks; git history is the prior-review trail): ``` ## Audit Notes @@ -42,11 +42,11 @@ This is a small schema bump with a large quality return. Every intent gets a mea Overall: MET / MET_WITH_CONCERNS / NOT_MET / INCONCLUSIVE ``` - This `## Audit Notes` write is the **verdict of record**; each run also writes a per-run forensic copy at `.abcd/logbook/audit/review-/report.{json,md}`. **spc-12 ships the manual reviewer** (`/abcd:intent review `) plus the `## Audit Notes` / `review-` writers; **automatic invocation on the `planned → shipped` transition is deferred to the lifecycle-owning spec** (`spc-6`). Until that lands, a shipped intent's `## Audit Notes` is populated only when `/abcd:intent review` is run by hand. + This `## Audit Notes` write is the **verdict of record**; each run also writes a per-run forensic copy at `.abcd/logbook/audit/review-/report.{json,md}`. **spc-12 ships the manual reviewer** (`/abcd:intent audit `) plus the `## Audit Notes` / `review-` writers; **automatic invocation on the `planned → shipped` transition is deferred to the lifecycle-owning spec** (`spc-6`). Until that lands, a shipped intent's `## Audit Notes` is populated only when `/abcd:intent audit` is run by hand. - **Escalation states** — four states, lifted from PAUL. Binary pass/fail loses information; four states preserve nuance without exploding. -- **Verdict family disjointness** (cross-referenced from [`05-internals/01-agents.md § Verdict-tag protocol`](../../brief/05-internals/01-agents.md#verdict-tag-protocol)). The four criterion verdicts above (`MET` / `MET_WITH_CONCERNS` / `NOT_MET` / `INCONCLUSIVE`) score *promise vs reality on a shipped intent* — they belong to `intent-fidelity-reviewer`'s Role 1 output. They are **deliberately disjoint from review verdicts** (`SHIP` / `NEEDS_WORK` / `MAJOR_RETHINK`) which score *changes/runs* (oracle reviews of plans, implementations, completions; consumed by the native receipt schema validator). The two enums never mix — review verdicts emit on a *change*, criterion verdicts emit on a *promise*. This disjointness was reinforced 2026-05-08 when idea-4's pre-review draft conflated the two families ("NOT_MET on an agent run" — wrong; criterion verdicts apply to intents not agents). Closing-the-loop signals on agents (per Frontier Awareness, idea-4) MUST use canary/golden-test/operator-tagged failure signals, NOT spec-level criterion verdicts. +- **Verdict family disjointness** (cross-referenced from [`05-internals/01-agents.md § Verdict-tag protocol`](../../brief/05-internals/01-agents.md#verdict-tag-protocol)). The four criterion verdicts above (`MET` / `MET_WITH_CONCERNS` / `NOT_MET` / `INCONCLUSIVE`) score *promise vs reality on a shipped intent* — they belong to `intent-auditor`'s Role 1 output. They are **deliberately disjoint from review verdicts** (`SHIP` / `NEEDS_WORK` / `MAJOR_RETHINK`) which score *changes/runs* (oracle reviews of plans, implementations, completions; consumed by the native receipt schema validator). The two enums never mix — review verdicts emit on a *change*, criterion verdicts emit on a *promise*. This disjointness was reinforced 2026-05-08 when idea-4's pre-review draft conflated the two families ("NOT_MET on an agent run" — wrong; criterion verdicts apply to intents not agents). Closing-the-loop signals on agents (per Frontier Awareness, idea-4) MUST use canary/golden-test/operator-tagged failure signals, NOT spec-level criterion verdicts. - **Intent template update** — the intent template the binary scaffolds (`internal/core`) includes the `## Acceptance Criteria` section with one example criterion. The discipline template (separate file) includes the same section. -- **Inheritance into every other spec** — every native spec plan-reviewed under abcd inherits the discipline's gate: the spec must reference the parent intent's acceptance criteria as the verification bar, and `intent-fidelity-reviewer` checks delivered reality against them on shipping. +- **Inheritance into every other spec** — every native spec plan-reviewed under abcd inherits the discipline's gate: the spec must reference the parent intent's acceptance criteria as the verification bar, and `intent-auditor` checks delivered reality against them on shipping. ## What's Out of Scope @@ -58,12 +58,12 @@ This is a small schema bump with a large quality return. Every intent gets a mea ## Acceptance Criteria -> _Yes, this discipline eats its own dog food. The criteria below describe how the discipline itself is checked — by the record lint (`internal/core/lint`) at promotion time and by `intent-fidelity-reviewer`'s single-document role on every shipped intent._ +> _Yes, this discipline eats its own dog food. The criteria below describe how the discipline itself is checked — by the record lint (`internal/core/lint`) at promotion time and by `intent-auditor`'s single-document role on every shipped intent._ - **Given** a draft intent without an `## Acceptance Criteria` section, **when** the user runs `/abcd:intent plan itd-N`, **then** the command refuses to promote the intent and lists the missing section as the reason. - **Given** a draft intent with a malformed acceptance section (e.g. no Given-When-Then bullets, or a header but empty body), **when** `/abcd:intent plan` runs, **then** the lint emits a specific error pointing at the malformed line. - **Given** a discipline-kind intent in `drafts/` without a `## Acceptance Criteria` section, **when** the user attempts to promote it via `/abcd:intent plan --kind discipline`, **then** the same hard-block applies — disciplines are not exempt from their own rule. -- **Given** a shipped intent with three acceptance criteria, **when** `intent-fidelity-reviewer` runs, **then** the resulting Audit Notes contain exactly three per-criterion verdict lines plus an overall status. *(Audit note 2026-05-07: this criterion is verifiable only after the reviewer's implementation lands in a separate spec; until then it is a paper-only gate. The reviewer itself will retroactively run against this discipline when implemented.)* +- **Given** a shipped intent with three acceptance criteria, **when** `intent-auditor` runs, **then** the resulting Audit Notes contain exactly three per-criterion verdict lines plus an overall status. *(Audit note 2026-05-07: this criterion is verifiable only after the reviewer's implementation lands in a separate spec; until then it is a paper-only gate. The reviewer itself will retroactively run against this discipline when implemented.)* - **Given** a reviewer verdict of `NOT_MET` on any criterion, **when** the reviewer writes its report, **then** the report includes a "Divergence" sub-section explaining what was delivered vs. what was promised — not just a label. - **Given** the brief is updated, **when** a contributor reads `04-surfaces/05-intent.md` (intent system), **then** they find the acceptance-pattern requirement and one worked example with all four verdict types. - **Given** a native spec that traces back to a parent intent, **when** the spec's plan-review runs, **then** the review verifies the spec's acceptance section references (or is structurally compatible with) the parent intent's `## Acceptance Criteria` bullets — drift between the two surfaces is flagged. @@ -79,7 +79,7 @@ This is a small schema bump with a large quality return. Every intent gets a mea ## Audit Notes -_Empty. Populated by intent-fidelity-reviewer's single-document role when this discipline is first audited. Note: disciplines never "ship" in the standalone-intent sense — they are audited continuously via the rule-applies-to-every-spec semantics rather than via a planned→shipped transition. The reviewer's findings here record any spec that violated this discipline (e.g., merged without an AC section)._ +_Empty. Populated by intent-auditor's single-document role when this discipline is first audited. Note: disciplines never "ship" in the standalone-intent sense — they are audited continuously via the rule-applies-to-every-spec semantics rather than via a planned→shipped transition. The reviewer's findings here record any spec that violated this discipline (e.g., merged without an AC section)._ ## References diff --git a/.abcd/development/intents/disciplines/itd-37-modification-grammar.md b/.abcd/development/intents/disciplines/itd-37-modification-grammar.md index 7aea8f6b..d5c35982 100644 --- a/.abcd/development/intents/disciplines/itd-37-modification-grammar.md +++ b/.abcd/development/intents/disciplines/itd-37-modification-grammar.md @@ -2,7 +2,7 @@ id: itd-37 slug: modification-grammar kind: discipline -kind_notes: "Cross-cutting modification-grammar gate; applied at every spec plan-review and ship time via concreteness lint (MG001-MG003) + intent-fidelity-reviewer Role 1 boilerplate detection (MG004). Every spec inherits this rule. Closes Naur's Modification axis — the genuinely new gap in abcd's theory transmission (Mapping + Justification already partially captured by press release + audit notes)." +kind_notes: "Cross-cutting modification-grammar gate; applied at every spec plan-review and ship time via concreteness lint (MG001-MG003) + intent-auditor Role 1 boilerplate detection (MG004). Every spec inherits this rule. Closes Naur's Modification axis — the genuinely new gap in abcd's theory transmission (Mapping + Justification already partially captured by press release + audit notes)." suggested_kind: null spec_id: null reclassification_history: [] @@ -38,7 +38,7 @@ The discipline is named for what's actually new — **modification grammar**, no **Why two-layer enforcement.** Boilerplate-rot is the discipline's principal failure mode. A `## Modification Grammar` section that says *"extending requires care; modifying requires understanding the design"* is non-empty, parseable, content-free — and **worse than no section at all** because it occupies cross-cutting query surface with noise. A regex-level "non-empty" lint catches the laziest failure but not plausible-sounding boilerplate. Two layers: 1. **Concreteness lint (mechanical)** rejects sections without a concrete reference (code block / file path / line ref). Lint codes `MG001`-`MG003`. -2. **Semantic boilerplate detection (reviewer judgement)** runs `intent-fidelity-reviewer` Role 1 (the discipline role per itd-1) at spec plan-review and ship time. Prompt-encoded test: ***"Strip the spec name. Could this `## Modification Grammar` text describe a different spec? If yes, reject."*** Lint code `MG004`. +2. **Semantic boilerplate detection (reviewer judgement)** runs `intent-auditor` Role 1 (the discipline role per itd-1) at spec plan-review and ship time. Prompt-encoded test: ***"Strip the spec name. Could this `## Modification Grammar` text describe a different spec? If yes, reject."*** Lint code `MG004`. The semantic enforcement is genuine LLM-judgement work. The discipline owns the requirement explicitly — regex cannot catch boilerplate. @@ -57,7 +57,7 @@ The semantic enforcement is genuine LLM-judgement work. The discipline owns the - `MG001` — section missing. - `MG002` — sub-heading missing or empty. - `MG003` — sub-heading present but contains no concrete reference (code block / file path / line ref). -- **Semantic boilerplate detection** via `intent-fidelity-reviewer` Role 1's discipline-checking pass. New prompt-encoded test ("could this describe a different spec?"). Lint code `MG004` — Role 1 boilerplate verdict. +- **Semantic boilerplate detection** via `intent-auditor` Role 1's discipline-checking pass. New prompt-encoded test ("could this describe a different spec?"). Lint code `MG004` — Role 1 boilerplate verdict. - **Specific rejection criteria** the prompt encodes: (a) `Extends cleanly` rejected if it doesn't name a concrete extension point with a constraint; (b) `Breaks the design` rejected if it doesn't name a specific failure mode; (c) `Why` rejected if it's a list of cases without an underlying rule, OR if the rule could equally describe a different spec. - **`principle-distiller` extraction trigger** — at spec completion, the curator (per itd-36's role extension): - Writes append-only `spec_modification_grammar_.md` to `.abcd/memory/` with `source.class: spec_modification_grammar`. @@ -79,12 +79,12 @@ The semantic enforcement is genuine LLM-judgement work. The discipline owns the ## Acceptance Criteria -> _BDD format, per [itd-1 acceptance gates](itd-1-acceptance-gates.md). These gates are checked by `intent-fidelity-reviewer` Role 1 against every spec plan-reviewed under abcd._ +> _BDD format, per [itd-1 acceptance gates](itd-1-acceptance-gates.md). These gates are checked by `intent-auditor` Role 1 against every spec plan-reviewed under abcd._ - **Given** a spec without a `## Modification Grammar` section, **when** plan-review runs, **then** the intent lint emits `MG001` and blocks promotion. - **Given** a spec with `## Modification Grammar` but missing one of the three required sub-headings (`Extends cleanly` / `Breaks the design` / `Why`), **when** plan-review runs, **then** the lint emits `MG002` naming the missing sub-heading. - **Given** a spec where any of the three sub-headings is present but contains no concrete reference (no code block, no file path, no line ref), **when** plan-review runs, **then** the lint emits `MG003` and points at the offending sub-heading. -- **Given** a spec where `## Modification Grammar` content could equally describe a different spec (boilerplate failure), **when** `intent-fidelity-reviewer` Role 1 runs the discipline check, **then** the reviewer emits `MG004` with the rejection reason ("strip-the-name test fails: this prose describes [generic concern] not [this spec's specifics]"). +- **Given** a spec where `## Modification Grammar` content could equally describe a different spec (boilerplate failure), **when** `intent-auditor` Role 1 runs the discipline check, **then** the reviewer emits `MG004` with the rejection reason ("strip-the-name test fails: this prose describes [generic concern] not [this spec's specifics]"). - **Given** a spec with `### Ripple > Vocabulary delta` introducing a new term not registered in either registry named by `02-constraints/04-naming.md`, **when** plan-review runs, **then** the intent lint is designed to emit `VR001` and block promotion until the term is registered (reserved; not yet implemented — today this is a review-time check, not a lint gate). - **Given** a spec transitions to shipped, **when** `principle-distiller` runs the extraction pass, **then** an append-only memory page `spec_modification_grammar_.md` is written to `.abcd/memory/` with `source.class: spec_modification_grammar` AND a curator-merged page `modification_grammar_.md` is updated with `source.class: modification_grammar`. - **Given** the cost-discipline boundary (itd-37 is first expensive discipline; ~15-30 min capture per spec), **when** any spec proposes adding "modification grammar exemption for trivial specs", **then** the proposal is rejected — trivial-self-exemption invites loophole-driven bypass; trivial specs produce short Modification Grammar sections, not absent ones. @@ -101,7 +101,7 @@ The semantic enforcement is genuine LLM-judgement work. The discipline owns the ## Audit Notes -_Empty. Populated by `intent-fidelity-reviewer` Role 1 (single-document fidelity per itd-1) when this discipline is first audited. Like itd-1, this discipline is audited continuously via the rule-applies-to-every-spec semantics rather than via a planned→shipped transition. The reviewer's findings here record any spec that violated the discipline (e.g., shipped without `## Modification Grammar`, or with boilerplate `MG004` not caught at plan-review)._ +_Empty. Populated by `intent-auditor` Role 1 (single-document fidelity per itd-1) when this discipline is first audited. Like itd-1, this discipline is audited continuously via the rule-applies-to-every-spec semantics rather than via a planned→shipped transition. The reviewer's findings here record any spec that violated the discipline (e.g., shipped without `## Modification Grammar`, or with boilerplate `MG004` not caught at plan-review)._ ## References diff --git a/.abcd/development/intents/disciplines/itd-5-prompt-quality-additions.md b/.abcd/development/intents/disciplines/itd-5-prompt-quality-additions.md index 754bcc66..d313a891 100644 --- a/.abcd/development/intents/disciplines/itd-5-prompt-quality-additions.md +++ b/.abcd/development/intents/disciplines/itd-5-prompt-quality-additions.md @@ -77,7 +77,7 @@ The discipline is project-agnostic: any project shipping LLM-driven agents under designed_for: "" ``` -- `task_classes` is a closed-enum list of tokens drawn from the controlled vocabulary in [`02-constraints/04-naming.md`](../../brief/02-constraints/04-naming.md) (`Reserved vocabulary § task_classes`). The machine-readable source of truth is the `task_classes` enum in `internal/core/lint`. Initial set (~10 tokens, PR-to-extend): `oracle_review`, `intent_review`, `spec_planning`, `code_rescue`, `principle_distillation`, `lifeboat_packing`, `audit`, `lint`, `surface_render`, `cross_document_audit`. +- `task_classes` is a closed-enum list of tokens drawn from the controlled vocabulary in [`02-constraints/04-naming.md`](../../brief/02-constraints/04-naming.md) (`Reserved vocabulary § task_classes`), which is the source of truth today — the binary carries no `task_classes` schema and no check reads the field (iss-265); a machine-readable enum in the binary is a design target, not a shipped artefact. Initial set (~10 tokens, PR-to-extend): `oracle_review`, `intent_audit`, `spec_planning`, `code_rescue`, `principle_distillation`, `lifeboat_packing`, `audit`, `lint`, `surface_render`, `cross_document_audit`. - `designed_for` is a free-text 1-line description of the agent's intended task class (for human readers — does not participate in lint, and is NEVER read to infer scope). - **Validation in `lint_prompts`** — strictly set-membership, NEVER inference: - (i) `capability_scope` field is present and parses; `task_classes` is a non-empty inline list; `designed_for` is a string. @@ -99,7 +99,7 @@ Every native spec that ships an agent inherits all four rules above as acceptanc - If the agent reads untrusted input (per the in-scope list), the spec's task list includes a canary-fixture task. - The agent's `agents/.md` will carry `capability_scope` with valid `task_classes` tokens at close. -`intent-fidelity-reviewer`'s single-document role (per the [itd-1 discipline](itd-1-acceptance-gates.md)) checks delivered reality against this discipline's acceptance criteria when each agent spec ships. +`intent-auditor`'s single-document role (per the [itd-1 discipline](itd-1-acceptance-gates.md)) checks delivered reality against this discipline's acceptance criteria when each agent spec ships. ## What's Out of Scope @@ -112,7 +112,7 @@ Every native spec that ships an agent inherits all four rules above as acceptanc ## Acceptance Criteria -> _BDD format, per the [itd-1 discipline](itd-1-acceptance-gates.md). The criteria below describe how this discipline is checked — by `lint_prompts` continuously, and by `intent-fidelity-reviewer`'s single-document role on every agent spec._ +> _BDD format, per the [itd-1 discipline](itd-1-acceptance-gates.md). The criteria below describe how this discipline is checked — by `lint_prompts` continuously, and by `intent-auditor`'s single-document role on every agent spec._ - **Given** a fresh checkout, **when** the prompt linter runs, **then** it passes only if every `agents/*.md` has `prompt_version: ` and a corresponding `CHANGELOG.md` entry exists for `1.0.0`. - **Given** any agent at v1.0.0 lock-time, **when** the agent's spec closes, **then** `agents/CHANGELOG.md` contains an entry with the self-improvement pre-flight outcome (oracle variant accepted | candidate retained + reason). @@ -138,7 +138,7 @@ Every native spec that ships an agent inherits all four rules above as acceptanc ## Audit Notes -_Empty. Populated by intent-fidelity-reviewer's single-document role when this discipline is first audited. Note: like itd-1, this discipline is audited continuously via the rule-applies-to-every-agent-spec semantics rather than via a planned→shipped transition. The reviewer's findings here record any agent spec that violated this discipline (e.g., shipped without `prompt_version`, or without canary fixtures despite reading untrusted input)._ +_Empty. Populated by intent-auditor's single-document role when this discipline is first audited. Note: like itd-1, this discipline is audited continuously via the rule-applies-to-every-agent-spec semantics rather than via a planned→shipped transition. The reviewer's findings here record any agent spec that violated this discipline (e.g., shipped without `prompt_version`, or without canary fixtures despite reading untrusted input)._ ## References diff --git a/.abcd/development/intents/disciplines/itd-81-judge-calibration.md b/.abcd/development/intents/disciplines/itd-81-judge-calibration.md index 35aede7c..1fce0948 100644 --- a/.abcd/development/intents/disciplines/itd-81-judge-calibration.md +++ b/.abcd/development/intents/disciplines/itd-81-judge-calibration.md @@ -15,7 +15,7 @@ severity: major ## Rule Every agent abcd ships that renders a **verdict** on code or artefacts — today -`intent-fidelity-reviewer`, and every reviewer, auditor, linter-with-judgement, +`intent-auditor`, and every reviewer, auditor, linter-with-judgement, and oracle that follows — is calibrated against a labelled corpus before its prompt is locked, and re-scored whenever the prompt changes. @@ -156,7 +156,7 @@ length tiebreak is struck; the pre-flight gate is the corpus score.** ## Open Questions - **Where the TNR floor starts.** A floor set before we have a baseline is a - guess. Proposal: run the current `intent-fidelity-reviewer` against a first + guess. Proposal: run the current `intent-auditor` against a first corpus, take the measured TNR as the floor, and ratchet — never regress. Resolve at the first judge spec's T1. - **Corpus size before the number means anything.** 30–50 cases is the working @@ -190,7 +190,7 @@ length tiebreak is struck; the pre-flight gate is the corpus score.** ## Audit Notes -_Empty. Populated by `intent-fidelity-reviewer`'s single-document role when this +_Empty. Populated by `intent-auditor`'s single-document role when this discipline is first audited. Like itd-1 and itd-5, audited continuously via rule-applies-to-every-judge-agent-spec semantics rather than a planned→shipped transition._ diff --git a/.abcd/development/intents/disciplines/itd-84-intent-decomposition.md b/.abcd/development/intents/disciplines/itd-84-intent-decomposition.md index aec2bc5f..5afdacae 100644 --- a/.abcd/development/intents/disciplines/itd-84-intent-decomposition.md +++ b/.abcd/development/intents/disciplines/itd-84-intent-decomposition.md @@ -182,7 +182,7 @@ optional layer, never the headline. ## Audit Notes -_Empty. Populated by `intent-fidelity-reviewer`'s single-document role when this +_Empty. Populated by `intent-auditor`'s single-document role when this discipline is first audited. Like itd-1, itd-5, and itd-81, audited continuously via rule-applies-to-every-capture semantics rather than a planned→shipped transition._ diff --git a/.abcd/development/intents/planned/itd-24-reflect-command.md b/.abcd/development/intents/planned/itd-24-reflect-command.md index 9a5fca1a..24a4b4a0 100644 --- a/.abcd/development/intents/planned/itd-24-reflect-command.md +++ b/.abcd/development/intents/planned/itd-24-reflect-command.md @@ -6,7 +6,7 @@ kind: bundle-member bundle: spc-83-operator-surfaces suggested_kind: null reclassification_history: [] -glossary_terms_used: [core/phase, core/intent, core/voyage, core/persona, core/brief, core/epic, core/lifeboat, core/oracle, core/spec, interview/embark, distribution/release] +glossary_terms_used: [core/phase, core/intent, core/voyage, core/persona, core/brief, core/lifeboat, core/oracle, core/spec, interview/embark, distribution/release] grill_session_id: e6a24d86-e133-495f-8dec-94dec21449ea grilled_at: 2026-05-16T15:37:17Z grilled_intent_hash: 8412a59b575df882fc4a370ab01404796cad4dd9e120d0519e9918d3ea891c61 diff --git a/.abcd/development/intents/planned/itd-43-epic-to-spec-terminology.md b/.abcd/development/intents/planned/itd-43-epic-to-spec-terminology.md index f517bb46..c8cefead 100644 --- a/.abcd/development/intents/planned/itd-43-epic-to-spec-terminology.md +++ b/.abcd/development/intents/planned/itd-43-epic-to-spec-terminology.md @@ -14,9 +14,9 @@ severity: minor ## Press Release -> **abcd speaks one word for a specced block of work — `spec` — everywhere a product thinker or contributor reads: no `epic` left behind in a heading, a review type, a schema field, or the glossary.** The native spec store is `spec` throughout, and abcd's `spec_id` intent-frontmatter field already carries the concept. Where abcd's *surfaces* still say `"epic"` — the reviews subsystem (`epic-review` type, `## Epic:` headings, `epic_id` review-directory identifiers), and prose across the brief, docs, command help, and the canonical `terminology/core/epic.md` term file — this intent makes the vocabulary single. Every one of those surfaces is abcd-owned; there is no vendored external epic/spec boundary to preserve, so the rename runs clean through the reviews subsystem, the schemas, and the glossary. +> **abcd speaks one word for a specced block of work — `spec` — everywhere a product thinker or contributor reads: no `epic` left behind in a heading, a review type, a schema field, or the glossary.** The native spec store is `spec` throughout, and abcd's `spec_id` intent-frontmatter field already carries the concept. Where abcd's *surfaces* still say `"epic"` — the reviews subsystem (`epic-review` type, `## Epic:` headings, `epic_id` review-directory identifiers), and prose across the brief, docs, and command help — this intent makes the vocabulary single. Every one of those surfaces is abcd-owned; there is no vendored external epic/spec boundary to preserve, so the rename runs clean through the reviews subsystem, the schemas, and the glossary. > -> "I'd renamed the field and thought I was done — then a contributor opened `terminology/core/epic.md` and asked which word was real," said Kira, framework author. "abcd's whole pitch is that each concept has one canonical term. Having `spec` in the schema and `epic` in the glossary was exactly the drift the glossary exists to prevent. One sweep, one word, and the term file is the source of truth again." +> "I'd renamed the field and thought I was done — then a contributor opened the glossary's `epic` term file and asked which word was real," said Kira, framework author. "abcd's whole pitch is that each concept has one canonical term. Having `spec` in the schema and `epic` in the glossary was exactly the drift the glossary exists to prevent. One sweep, one word, and the term file is the source of truth again." ## Status @@ -28,20 +28,20 @@ Scope* below is what this intent carries. ## Why This Matters -abcd's [terminology discipline](../../brief/glossary/) exists to kill exactly one failure: the same concept named two ways, drifting until two readers mean different things. Right now abcd commits that failure about its own core noun. The schema and all 41 intent files say `spec_id`; `terminology/core/epic.md` still defines the concept as `"epic"`; the reviews subsystem still classifies `epic-review`. A framework that enforces ubiquitous language cannot itself be bilingual about its central term. +abcd's [terminology discipline](../../brief/glossary/) exists to kill exactly one failure: the same concept named two ways, drifting until two readers mean different things. Right now abcd commits that failure about its own core noun. The schema and the intent corpus say `spec_id`; the glossary half is delivered — `brief/glossary/core/spec.md` carries `term: spec` with `epic` in `forbidden_synonyms` (spc-8) — but the reviews subsystem still classifies `epic-review`. A framework that enforces ubiquitous language cannot itself be bilingual about its central term. The `epic_id`→`spec_id` field rename was done separately and first, on purpose — it had to be atomic (schema + data + code, or intent-lint validation fails). What remains does **not** break anything: it is inconsistency, not breakage, which is why it is its own intent rather than an emergency fix. But unaddressed it erodes the glossary's authority and confuses every new contributor. -The reviews subsystem, the schemas, and the spec store are all abcd-owned — there is no vendored external plugin whose `epic`/`spec` aliases must be preserved, so the rename is a coherent internal sweep rather than a negotiation across a boundary. The single-source-of-truth rule decides the order: `terminology/core/spec.md` is canonical for the concept, so it is renamed first and everything else conforms to it. +The reviews subsystem, the schemas, and the spec store are all abcd-owned — there is no vendored external plugin whose `epic`/`spec` aliases must be preserved, so the rename is a coherent internal sweep rather than a negotiation across a boundary. The single-source-of-truth rule decides the order: `brief/glossary/core/spec.md` is canonical for the concept, so it was renamed first and everything else conforms to it. ## What's In Scope -- **Rename the canonical term file** `terminology/core/epic.md` → `terminology/core/spec.md`, with `term: spec` and `epic` recorded as a `forbidden_synonyms` entry so the lint catches regressions. +- **Rename the canonical term file** — delivered with spc-8: the term file lives at `brief/glossary/core/spec.md` with `term: spec` and `epic` in `forbidden_synonyms`, and the `GL002` lint catches regressions. - **Reviews subsystem rename** — the review-index, review-postprocess, and review-verify surfaces: `epic_id` parameters → `spec_id`, the `--epic` CLI flag, the `## Epic:` rendered heading, the `epic_id` JSON field, and the `epic-review`/`epic` review-type tokens. All of it is abcd-owned, so the review-type token becomes `spec-review` throughout with no external token to accommodate. -- **`issue.schema.json`** — `related_epics` → `related_specs`, nested `epic` key → `spec`, descriptions updated. -- **`grill-report.schema.json`** — prose mentions of `epic`/task ID updated. +- **`issue.schema.json`** — moot in the Go rebuild (spc-8): no `*.schema.json` exists in the tree, and the native validator already uses `related_specs` exclusively. +- **`grill-report.schema.json`** — moot in the Go rebuild (spc-8): the file does not exist. - **Prose sweep** — `intents/README.md`, the brief (`04-surfaces/`, `02-constraints/`, etc.), `docs/reference/{commands,facilitator,review-schema}.md`, `commands/intent.md`, the grill `SKILL.md` boundary message, project READMEs: `epic` as a noun → `spec`. -- **The native spec store's README** — the directory-purpose prose still says `epics`; align to `specs`. +- **The native spec store's README** — moot in the Go rebuild (spc-8): `.abcd/development/specs/` carries no README. ## What's Out of Scope @@ -53,16 +53,15 @@ The reviews subsystem, the schemas, and the spec store are all abcd-owned — th > _BDD format, per the itd-1 discipline._ - **Given** the rename is complete, **when** a contributor greps abcd-owned files for `"epic"` as a standalone noun, **then** no live reference remains — only historical git-tracked records. -- **Given** the terminology directory, **when** a contributor looks up the concept, **then** it resolves to `terminology/core/spec.md` with `term: spec`, and `epic` appears there only as a `forbidden_synonyms` entry. +- **Given** the glossary, **when** a contributor looks up the concept, **then** it resolves to `brief/glossary/core/spec.md` with `term: spec`, and `epic` appears there only as a `forbidden_synonyms` entry. - **Given** the reviews subsystem is renamed, **when** a review is classified, **then** classification succeeds against the `spec-review` type the native reviews surface emits — one internal token, no desync. -- **Given** `issue.schema.json` is updated, **when** an issue links to a spec, **then** it uses `related_specs`, and an issue file using the old `related_epics` key fails schema validation. +- **Given** the issue ledger, **when** an issue links to a spec, **then** it uses `related_specs` — met by the Go rebuild's validator (spc-8); no `*.schema.json` remains to update. - **Given** the prose sweep is complete, **when** `internal/core/lint` runs, **then** no forbidden-synonym (`GL002`) violation for `epic` is raised by any abcd-owned intent or doc. ## Open Questions - Sequencing against the `intents/README.md` v1/v2/v3 → phase migration (logged separately in a working-log entry): both rewrite `intents/README.md`. Run the README migration first and this sweep second, or merge them into one README pass? -- Should `terminology/core/epic.md` be renamed (git mv → `spec.md`) or kept as a stub redirecting to `spec.md`? A stub preserves inbound links but adds a file the glossary must explain. -- Does `epic.md`'s definition body need rewriting, or only its `term` field and filename? The concept is unchanged; only the word changes. +- ~~Should the term file be renamed or kept as a stub?~~ Answered by the tree (spc-8): the rename happened with no stub, and the definition body carries `term: spec`. ## Audit Notes diff --git a/.abcd/development/principles/the-record-lands-with-the-act.md b/.abcd/development/principles/the-record-lands-with-the-act.md index 3270c796..bb2d0a2f 100644 --- a/.abcd/development/principles/the-record-lands-with-the-act.md +++ b/.abcd/development/principles/the-record-lands-with-the-act.md @@ -48,8 +48,9 @@ opportunity produces nothing. **Live instance.** iss-2608241347321757 dissolves the dependency. `make lint-issues` (RS001) requires a commit carrying a `Resolves: iss-N` trailer to -move that record out of `open/` in the same diff, so resolution lands inside the -fixing commit and no post-merge step exists to forget. `resolved_by.commit` +land that record in `resolved/` or `wontfix/` in the same diff — a bare delete +of the open record satisfies nothing — so resolution lands inside the fixing +commit and no post-merge step exists to forget. `resolved_by.commit` becomes optional-but-verified: RS002 checks that a stamp added in a range names a reachable commit, and RS003 checks that every stamp already in the ledger stays reachable under squash and rebase merges. diff --git a/.abcd/development/specs/open/spc-28-the-intent-audit-says-audit-abcd-intent-review-becomes-abcd.md b/.abcd/development/specs/open/spc-28-the-intent-audit-says-audit-abcd-intent-review-becomes-abcd.md index 19b59d55..698ec172 100644 --- a/.abcd/development/specs/open/spc-28-the-intent-audit-says-audit-abcd-intent-review-becomes-abcd.md +++ b/.abcd/development/specs/open/spc-28-the-intent-audit-says-audit-abcd-intent-review-becomes-abcd.md @@ -47,7 +47,13 @@ Record (current-state surfaces only): research notes, dated plans, resolved/wontfix issues, shipped/superseded intents, `DECISIONS.md`, review-store artefacts, git history. A test or grep in the PR description demonstrates remaining occurrences are all in these -classes. +classes. Amendment (2026-08-26): `intents/disciplines/` appeared in neither +the swept inventory nor this list and was swept late (iss-2608261437043634) — +it is live state with no reachable reconciliation event, since `intent audit` +refuses a non-shipped intent; `drafts/` and `planned/` stay deliberately +unswept under the iss-94 convention (each reconciles when next planned). +Stored artefact formats (`abcd-review:` markers, `review-` receipt +directories) keep their frozen spellings. ## Approach diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 74a5fea6..800c98bd 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2109,3 +2109,22 @@ parallel-agent merge contention bites. pronouns in two planned intents. Recorded open: the memory store-lock S_IFMT mask (rides iss-129), the quoted-enum impact split, and a deferral-currency detector seed. +- 2026-08-26 — Bug-hunt round 9 (bughunt-b): baseline green on the unshallowed + clone. Five parallel hunters, per-finding adversarial refutation: 12 + substantive and 5 nitpick findings confirmed and fixed, 4 refuted or prior + art — the renderReviewMD provenance gap (open GitHub #325), the receipt + sha-width split (two different jobs, machine-derived 40-hex everywhere), + the principles-README universal claim (iss-390's scope), and the DECISIONS + ordering inversions (order-independent by declared union-merge design). + Fixed with provenance: capture --blocked-by existence probe, intent verbs + resolving spec_id by number, ScanSpecLinks failing closed, cleanLessonProse + delegating to CleanProse, firstRootSHA bounded, RD002's one-pass DMRT + history scan with a cases harness, record_schema's required-frontmatter + invariant with the issueschema leaf plus the repaired live record, the + RS001 terminal-folder wording at three sites, the plugin-command count, + the derived-changelog Breaking clause with adr-37 amended, the data-theme + claim, CI-account rosters, the disciplines intent-auditor sweep with + spc-28's late-sweep amendment, itd-5's task_classes source of truth, + itd-43's glossary reconcile, itd-24's core/epic entry, and the + install-template eol pin. Recorded open: the unknown-property half of the + ledger-visibility gap and itd-5's struck-tiebreak residue. diff --git a/.abcd/work/issues/open/iss-2608231237300997-inspirations-lead-removal-blocked-by-name-guard.md b/.abcd/work/issues/open/iss-2608231237300997-inspirations-lead-removal-blocked-by-name-guard.md index f5b5221a..449234cb 100644 --- a/.abcd/work/issues/open/iss-2608231237300997-inspirations-lead-removal-blocked-by-name-guard.md +++ b/.abcd/work/issues/open/iss-2608231237300997-inspirations-lead-removal-blocked-by-name-guard.md @@ -1,4 +1,5 @@ --- +schema_version: 1 id: iss-2608231237300997 slug: no-way-to-edit-a-file-the-private-name-guard-blocks severity: minor @@ -6,7 +7,6 @@ category: documentation found_at: ACKNOWLEDGEMENTS.md found_during: user-observation source: user-observation -status: open --- # There is no way to change text in a file the private name-guard blocks diff --git a/.abcd/work/issues/open/iss-2608261437042674-itd-5-scope-step-still-carries-the-tiebreak-its-own-amendmen.md b/.abcd/work/issues/open/iss-2608261437042674-itd-5-scope-step-still-carries-the-tiebreak-its-own-amendmen.md new file mode 100644 index 00000000..01781ba4 --- /dev/null +++ b/.abcd/work/issues/open/iss-2608261437042674-itd-5-scope-step-still-carries-the-tiebreak-its-own-amendmen.md @@ -0,0 +1,12 @@ +--- +schema_version: 1 +id: "iss-2608261437042674" +slug: "itd-5-scope-step-still-carries-the-tiebreak-its-own-amendmen" +severity: "nitpick" +category: "observation" +source: "agent-observation" +found_during: "bughunt-b-round-9" +found_at: ".abcd/development/intents/disciplines/itd-5-prompt-quality-additions.md" +--- + +itd-5 scope step still carries the tiebreak its own amendment struck \ No newline at end of file diff --git a/.abcd/work/issues/open/iss-2608261447039180-an-unknown-frontmatter-property-hides-a-ledger-record-the-sa.md b/.abcd/work/issues/open/iss-2608261447039180-an-unknown-frontmatter-property-hides-a-ledger-record-the-sa.md new file mode 100644 index 00000000..3fae237a --- /dev/null +++ b/.abcd/work/issues/open/iss-2608261447039180-an-unknown-frontmatter-property-hides-a-ledger-record-the-sa.md @@ -0,0 +1,12 @@ +--- +schema_version: 1 +id: "iss-2608261447039180" +slug: "an-unknown-frontmatter-property-hides-a-ledger-record-the-sa" +severity: "minor" +category: "bug" +source: "agent-observation" +found_during: "bughunt-b-round-9" +found_at: "internal/core/lint/schema.go" +--- + +an unknown frontmatter property hides a ledger record the same way a missing one did \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-2608261437040448-the-served-install-script-template-misses-the-eol-lf-pin-its.md b/.abcd/work/issues/resolved/iss-2608261437040448-the-served-install-script-template-misses-the-eol-lf-pin-its.md new file mode 100644 index 00000000..20b639d5 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2608261437040448-the-served-install-script-template-misses-the-eol-lf-pin-its.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2608261437040448" +slug: "the-served-install-script-template-misses-the-eol-lf-pin-its" +severity: "nitpick" +category: "observation" +source: "agent-observation" +found_during: "bughunt-b-round-9" +found_at: ".gitattributes" +resolution: "install.sh.tmpl pinned text eol=lf with the pipe-to-sh rationale" +impact: fix +resolved_by: + commit: "66c06e15" +--- + +the served install script template misses the eol=lf pin its shell siblings carry \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-2608261437040578-cleanlessonprose-kept-the-pre-migration-body-the-cleanprose.md b/.abcd/work/issues/resolved/iss-2608261437040578-cleanlessonprose-kept-the-pre-migration-body-the-cleanprose.md new file mode 100644 index 00000000..e960f347 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2608261437040578-cleanlessonprose-kept-the-pre-migration-body-the-cleanprose.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2608261437040578" +slug: "cleanlessonprose-kept-the-pre-migration-body-the-cleanprose" +severity: "major" +category: "bug" +source: "agent-observation" +found_during: "bughunt-b-round-9" +found_at: "internal/core/lifeboat/graveyard_lessons.go" +resolution: "cleanLessonProse delegates to termsafe.CleanProse with the unchanged cap" +impact: fix +resolved_by: + commit: "2d00855e" +--- + +cleanLessonProse kept the pre-migration body the CleanProse consolidation claims routed \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-2608261437041050-a-ledger-record-missing-schema-version-vanishes-from-capture.md b/.abcd/work/issues/resolved/iss-2608261437041050-a-ledger-record-missing-schema-version-vanishes-from-capture.md new file mode 100644 index 00000000..3a62d27f --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2608261437041050-a-ledger-record-missing-schema-version-vanishes-from-capture.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2608261437041050" +slug: "a-ledger-record-missing-schema-version-vanishes-from-capture" +severity: "major" +category: "bug" +source: "agent-observation" +found_during: "bughunt-b-round-9" +found_at: "internal/core/lint/schema.go" +resolution: "record_schema enforces the issue store's required frontmatter from the shared issueschema leaf; status board renders skips; live record repaired" +impact: fix +resolved_by: + commit: "9a24c32d" +--- + +a ledger record missing schema_version vanishes from capture surfaces while every gate stays green \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-2608261437041111-readme-and-agents-quote-the-superseded-rs001-leave-open-rule.md b/.abcd/work/issues/resolved/iss-2608261437041111-readme-and-agents-quote-the-superseded-rs001-leave-open-rule.md new file mode 100644 index 00000000..e9b83bfb --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2608261437041111-readme-and-agents-quote-the-superseded-rs001-leave-open-rule.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2608261437041111" +slug: "readme-and-agents-quote-the-superseded-rs001-leave-open-rule" +severity: "minor" +category: "observation" +source: "agent-observation" +found_during: "bughunt-b-round-9" +found_at: "README.md" +resolution: "README example, AGENTS.md and the principle text state the enters-terminal-folder rule" +impact: fix +resolved_by: + commit: "15e7d912" +--- + +README and AGENTS quote the superseded RS001 leave-open rule the round-8 fix retired \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-2608261437042550-readme-counts-one-plugin-only-command-where-the-plugin-surfa.md b/.abcd/work/issues/resolved/iss-2608261437042550-readme-counts-one-plugin-only-command-where-the-plugin-surfa.md new file mode 100644 index 00000000..1091fd8e --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2608261437042550-readme-counts-one-plugin-only-command-where-the-plugin-surfa.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2608261437042550" +slug: "readme-counts-one-plugin-only-command-where-the-plugin-surfa" +severity: "minor" +category: "observation" +source: "agent-observation" +found_during: "bughunt-b-round-9" +found_at: "README.md" +resolution: "README counts the three host-delegated plugin commands" +impact: fix +resolved_by: + commit: "6428a251" +--- + +README counts one plugin-only command where the plugin surface carries three \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-2608261437043634-the-live-disciplines-bucket-was-never-swept-for-the-spc-28-i.md b/.abcd/work/issues/resolved/iss-2608261437043634-the-live-disciplines-bucket-was-never-swept-for-the-spc-28-i.md new file mode 100644 index 00000000..5a3a3ffb --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2608261437043634-the-live-disciplines-bucket-was-never-swept-for-the-spc-28-i.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2608261437043634" +slug: "the-live-disciplines-bucket-was-never-swept-for-the-spc-28-i" +severity: "minor" +category: "observation" +source: "agent-observation" +found_during: "bughunt-b-round-9" +found_at: ".abcd/development/intents/disciplines" +resolution: "disciplines swept onto intent-auditor and intent audit; spc-28 records the late sweep" +impact: fix +resolved_by: + commit: "eff3168c" +--- + +the live disciplines bucket was never swept for the spc-28 intent-auditor rename \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-2608261437043962-itd-5-names-a-phantom-task-classes-enum-as-source-of-truth-a.md b/.abcd/work/issues/resolved/iss-2608261437043962-itd-5-names-a-phantom-task-classes-enum-as-source-of-truth-a.md new file mode 100644 index 00000000..48f1c1c6 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2608261437043962-itd-5-names-a-phantom-task-classes-enum-as-source-of-truth-a.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2608261437043962" +slug: "itd-5-names-a-phantom-task-classes-enum-as-source-of-truth-a" +severity: "minor" +category: "observation" +source: "agent-observation" +found_during: "bughunt-b-round-9" +found_at: ".abcd/development/intents/disciplines/itd-5-prompt-quality-additions.md" +resolution: "itd-5 points task_classes at the naming registry with intent_audit, citing iss-265" +impact: fix +resolved_by: + commit: "c961ce60" +--- + +itd-5 names a phantom task_classes enum as source of truth and carries the retired intent_review token \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-2608261437044340-itd-43-specifies-work-against-the-retired-terminology-tree-i.md b/.abcd/work/issues/resolved/iss-2608261437044340-itd-43-specifies-work-against-the-retired-terminology-tree-i.md new file mode 100644 index 00000000..4e1ff715 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2608261437044340-itd-43-specifies-work-against-the-retired-terminology-tree-i.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2608261437044340" +slug: "itd-43-specifies-work-against-the-retired-terminology-tree-i" +severity: "minor" +category: "observation" +source: "agent-observation" +found_during: "bughunt-b-round-9" +found_at: ".abcd/development/intents/planned/itd-43-epic-to-spec-terminology.md" +resolution: "itd-43 stale sites reconciled onto the delivered glossary rename; live remainder untouched" +impact: fix +resolved_by: + commit: "cefa2770" +--- + +itd-43 specifies work against the retired terminology tree its own spec records as delivered \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-2608261437044382-rd002-s-rename-leg-is-dead-under-a-pathspec-and-a-git-failur.md b/.abcd/work/issues/resolved/iss-2608261437044382-rd002-s-rename-leg-is-dead-under-a-pathspec-and-a-git-failur.md new file mode 100644 index 00000000..0f7b1c10 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2608261437044382-rd002-s-rename-leg-is-dead-under-a-pathspec-and-a-git-failur.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2608261437044382" +slug: "rd002-s-rename-leg-is-dead-under-a-pathspec-and-a-git-failur" +severity: "major" +category: "bug" +source: "agent-observation" +found_during: "bughunt-b-round-9" +found_at: "scripts/check-reviews.sh" +resolution: "RD002 runs one DMRT history pass, fails closed on git errors, cases harness wired into gate and CI" +impact: fix +resolved_by: + commit: "15b849d6" +--- + +RD002's rename leg is dead under a pathspec and a git failure reads as clean \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-2608261437046261-changelog-header-promises-a-breaking-section-the-derived-ing.md b/.abcd/work/issues/resolved/iss-2608261437046261-changelog-header-promises-a-breaking-section-the-derived-ing.md new file mode 100644 index 00000000..332f63c2 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2608261437046261-changelog-header-promises-a-breaking-section-the-derived-ing.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2608261437046261" +slug: "changelog-header-promises-a-breaking-section-the-derived-ing" +severity: "minor" +category: "observation" +source: "agent-observation" +found_during: "bughunt-b-round-9" +found_at: "CHANGELOG.md" +resolution: "CHANGELOG header states the derived truth; adr-37 amended, version comment repointed" +impact: fix +resolved_by: + commit: "a7e602f6" +--- + +CHANGELOG header promises a Breaking section the derived ingest cannot emit \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-2608261437046287-capture-blocked-by-writes-an-unverified-cross-reference-its.md b/.abcd/work/issues/resolved/iss-2608261437046287-capture-blocked-by-writes-an-unverified-cross-reference-its.md new file mode 100644 index 00000000..4bc3ad71 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2608261437046287-capture-blocked-by-writes-an-unverified-cross-reference-its.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2608261437046287" +slug: "capture-blocked-by-writes-an-unverified-cross-reference-its" +severity: "major" +category: "bug" +source: "agent-observation" +found_during: "bughunt-b-round-9" +found_at: "internal/core/capture/workflow.go" +resolution: "capture probes blocked-by targets before writing; dangling target refuses with nothing written" +impact: fix +resolved_by: + commit: "a2aa7780" +--- + +capture --blocked-by writes an unverified cross-reference its own blocker then refuses \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-2608261437046944-itd-24-glossary-terms-used-still-lists-the-retired-core-epic.md b/.abcd/work/issues/resolved/iss-2608261437046944-itd-24-glossary-terms-used-still-lists-the-retired-core-epic.md new file mode 100644 index 00000000..8fdcf28d --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2608261437046944-itd-24-glossary-terms-used-still-lists-the-retired-core-epic.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2608261437046944" +slug: "itd-24-glossary-terms-used-still-lists-the-retired-core-epic" +severity: "nitpick" +category: "observation" +source: "agent-observation" +found_during: "bughunt-b-round-9" +found_at: ".abcd/development/intents/planned/itd-24-reflect-command.md" +resolution: "itd-24 drops the dangling core/epic entry; core/spec already present" +impact: fix +resolved_by: + commit: "30b965b6" +--- + +itd-24 glossary_terms_used still lists the retired core/epic term \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-2608261437047643-intent-verbs-compare-spec-id-raw-while-lint-canonicalises-so.md b/.abcd/work/issues/resolved/iss-2608261437047643-intent-verbs-compare-spec-id-raw-while-lint-canonicalises-so.md new file mode 100644 index 00000000..308b1ed1 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2608261437047643-intent-verbs-compare-spec-id-raw-while-lint-canonicalises-so.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2608261437047643" +slug: "intent-verbs-compare-spec-id-raw-while-lint-canonicalises-so" +severity: "minor" +category: "bug" +source: "agent-observation" +found_during: "bughunt-b-round-9" +found_at: "internal/core/intent/lifecycle.go" +resolution: "intent verbs resolve spec_id by number via shared SameNum/HasNum; lint-green spellings verb-green" +impact: fix +resolved_by: + commit: "c3a46c59" +--- + +intent verbs compare spec_id raw while lint canonicalises so a lint-green slug or zero-padded spelling bricks reconcile and ready \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-2608261437047965-site-src-readme-claims-the-record-explorer-ships-the-data-th.md b/.abcd/work/issues/resolved/iss-2608261437047965-site-src-readme-claims-the-record-explorer-ships-the-data-th.md new file mode 100644 index 00000000..c4ed31f9 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2608261437047965-site-src-readme-claims-the-record-explorer-ships-the-data-th.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2608261437047965" +slug: "site-src-readme-claims-the-record-explorer-ships-the-data-th" +severity: "minor" +category: "observation" +source: "agent-observation" +found_during: "bughunt-b-round-9" +found_at: "site-src/README.md" +resolution: "site-src README says no page sets data-theme yet" +impact: fix +resolved_by: + commit: "371c1f4c" +--- + +site-src README claims the record explorer ships the data-theme control nothing sets \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-2608261437047992-contributing-agents-and-the-ci-yml-header-understate-the-lin.md b/.abcd/work/issues/resolved/iss-2608261437047992-contributing-agents-and-the-ci-yml-header-understate-the-lin.md new file mode 100644 index 00000000..df459033 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2608261437047992-contributing-agents-and-the-ci-yml-header-understate-the-lin.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2608261437047992" +slug: "contributing-agents-and-the-ci-yml-header-understate-the-lin" +severity: "nitpick" +category: "observation" +source: "agent-observation" +found_during: "bughunt-b-round-9" +found_at: "CONTRIBUTING.md" +resolution: "CONTRIBUTING, AGENTS and the ci.yml header name the site-render and RS gates" +impact: fix +resolved_by: + commit: "2e22ac0c" +--- + +CONTRIBUTING AGENTS and the ci.yml header understate the Linux leg and the record-lint job \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-2608261437048689-firstrootsha-is-the-one-unbounded-git-read-on-the-lifeboat-p.md b/.abcd/work/issues/resolved/iss-2608261437048689-firstrootsha-is-the-one-unbounded-git-read-on-the-lifeboat-p.md new file mode 100644 index 00000000..ad34b62a --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2608261437048689-firstrootsha-is-the-one-unbounded-git-read-on-the-lifeboat-p.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2608261437048689" +slug: "firstrootsha-is-the-one-unbounded-git-read-on-the-lifeboat-p" +severity: "nitpick" +category: "bug" +source: "agent-observation" +found_during: "bughunt-b-round-9" +found_at: "internal/core/lifeboat/probe.go" +resolution: "firstRootSHA reads through RunLimited with rev-list -n 1; source guard pins the package" +impact: fix +resolved_by: + commit: "4fe662d4" +--- + +firstRootSHA is the one unbounded git read on the lifeboat probe path \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-2608261437049307-scanspeclinks-swallows-intent-tree-read-errors-the-spec-half.md b/.abcd/work/issues/resolved/iss-2608261437049307-scanspeclinks-swallows-intent-tree-read-errors-the-spec-half.md new file mode 100644 index 00000000..8ae7bbd0 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2608261437049307-scanspeclinks-swallows-intent-tree-read-errors-the-spec-half.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2608261437049307" +slug: "scanspeclinks-swallows-intent-tree-read-errors-the-spec-half" +severity: "nitpick" +category: "bug" +source: "agent-observation" +found_during: "bughunt-b-round-9" +found_at: "internal/core/lint/speclinks.go" +resolution: "ScanSpecLinks propagates intent-tree walk and read errors; missing [redacted-user] stays soft" +impact: fix +resolved_by: + commit: "5d964a9e" +--- + +ScanSpecLinks swallows intent-tree read errors the spec half propagates \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index c28c0719..a7ef930c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -26,3 +26,9 @@ hooks/*.sh text eol=lf # via `bash scripts/...`); a CRLF checkout breaks both — a \r is a syntax error # to bash — so the pinned pre-push hook would then die one hop later here. scripts/*.sh text eol=lf +# site-src/install.sh.tmpl is the committed source of the /install.sh the site +# serves: `abcd site build` copies its bytes out (plus one stamp comment) and +# users pipe the result straight into `sh`, where a trailing \r on every line +# is a syntax error — no shebang required to break it. A render from a CRLF +# checkout would publish an installer that cannot run. +site-src/install.sh.tmpl text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c75274eb..26a30f0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,10 +4,10 @@ name: ci # work is covered by the pull_request trigger alone, so a PR push runs the matrix # once; the default-branch push keeps the merged tip checked. The check job # builds, vets and tests on macOS and Linux, with race-enabled internal tests on -# both and the `gofmt -l .` format gate, record-lint and docs-lint on the ubuntu -# leg alone. Separate jobs run full-history secret scanning, a workflow audit, -# the reviews-charter discipline, dependency review, govulncheck, and the smoke -# harness. +# both and the `gofmt -l .` format gate, record-lint, docs-lint and the +# site-render gate on the ubuntu leg alone. Separate jobs run full-history +# secret scanning, a workflow audit, the reviews-charter and issue-resolution +# gates (RS001-RS003), dependency review, govulncheck, and the smoke harness. # # The `changes` job classifies the diff, and the duplicated lanes stand down on a # change confined to the record and the user-facing docs. Two properties make @@ -349,6 +349,9 @@ jobs: with: fetch-depth: 0 persist-credentials: false + - name: Reviews-charter gate can fail (cases) + run: bash scripts/check-reviews-cases.sh + - name: Reviews-charter discipline (RD001-RD003) run: bash scripts/check-reviews.sh diff --git a/AGENTS.md b/AGENTS.md index 4aa7de94..90013c2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,10 +75,12 @@ go test -run TestStatus ./internal/core/ # a single test CI (`.github/workflows/ci.yml`) runs its `check` job on macOS + Linux — build, vet, test and the race-enabled internal tests on both, with the `gofmt -l .` -format gate and the record-lint and docs-lint steps on the Linux leg alone. -Separate jobs run the reviews-charter check (`scripts/check-reviews.sh`), -full-history secret scanning (`gitleaks`), a workflow audit (`zizmor`), -dependency review, `govulncheck`, and the smoke harness (`make smoke`). A +format gate, the record-lint and docs-lint steps and the site-render gate on +the Linux leg alone. Separate jobs run the reviews-charter check +(`scripts/check-reviews.sh`) together with the issue-resolution gates +(RS001–RS003), full-history secret scanning (`gitleaks`), a workflow audit +(`zizmor`), dependency review, `govulncheck`, and the smoke harness +(`make smoke`). A fail-closed classifier stands the macOS leg, the race lane and the `zizmor`, `govulncheck` and smoke jobs down on a pull request confined to `docs/`, `.abcd/development/`, `.abcd/work/` and the root prose files; the Linux unit @@ -176,8 +178,9 @@ irreversible; guessing downward costs nothing.** blocks the next release. - **A change that fixes a captured issue resolves it in the same change**, and says so with a `Resolves: iss-N` trailer. `lint-issues` (RS001) refuses a - trailer whose record does not leave `.abcd/work/issues/open/` in the same - diff. Resolution is deliberately not a post-merge step: a step that happens + trailer whose record does not enter `.abcd/work/issues/resolved/` or + `.abcd/work/issues/wontfix/` in the same diff — a bare delete of the open + record satisfies nothing. Resolution is deliberately not a post-merge step: a step that happens after the merge is the one that gets forgotten, and a fixed-but-open issue leaves no marker to find it by. Resolving without a trailer stays legal — a stale issue closed on its own merits has no fixing commit to name. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8442501f..ba0fe573 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,11 @@ All notable changes to abcd are recorded here. The format follows uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a leading `v`. -Before v1.0.0, minor releases may make breaking changes; each one is -called out in a **Breaking** section. +Before v1.0.0, a minor release may make breaking changes: a record +declaring `impact: breaking` bumps the minor, and its entry appears under +the Keep a Changelog section it belongs to — usually **Changed** or +**Removed**. The version number is the signal; sections at v0.6.0 and +earlier were rolled by hand and some carry a **Breaking** heading. ## [Unreleased] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 95763720..a17b6c21 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,8 +20,9 @@ inbound = outbound statement is the whole of it. that is policy, not a judgement of the work. - **Branch + PR** for substantive changes; CI gates the merge. Its `check` job builds, vets and tests (plain and race-enabled) on macOS + Linux, and on the - Linux leg alone adds the `gofmt -l .` format gate and the record-lint and - docs-lint steps; separate jobs run the reviews-charter check, `gitleaks`, + Linux leg alone adds the `gofmt -l .` format gate, the record-lint and + docs-lint steps, and the site-render gate; separate jobs run the + reviews-charter and issue-resolution checks (RS001–RS003), `gitleaks`, `zizmor`, dependency review, `govulncheck` and the smoke harness. - **Merge queue.** Merging goes through the queue ("Merge when ready"): the required checks run against the actual merged result, so branches never need diff --git a/Makefile b/Makefile index 771391c9..7703e847 100644 --- a/Makefile +++ b/Makefile @@ -40,8 +40,11 @@ vet: # Deterministic gate for the .abcd/work/reviews/ charter (RD001-RD003) — a # stopgap until these codes land in internal/core/lint. Needs full git history # (RD002 is append-only over committed history): on a shallow checkout the -# script refuses (exit 2) rather than pass vacuously with nothing covered. +# script refuses (exit 2) rather than pass vacuously with nothing covered. The +# cases run first, as in lint-issues: a gate nobody has watched fail is an +# enforcement claim with no evidence behind it. lint-reviews: + @bash scripts/check-reviews-cases.sh @bash scripts/check-reviews.sh # AI-attribution gate (AGENTS.md § Attribution). Checks the commit trailers on diff --git a/README.md b/README.md index c7613d15..b1acb71d 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ stop it: ```text $ git push -check-issue-resolution: RS001 commit c1e2c4c3d915 declares 'Resolves: iss-317', but iss-317 does not leave .abcd/work/issues/open/ in origin/main..HEAD. Resolve it in this change (abcd capture resolve iss-317 ...) or drop the trailer. +check-issue-resolution: RS001 commit c1e2c4c3d915 declares 'Resolves: iss-317', but iss-317 does not enter .abcd/work/issues/resolved/ or .abcd/work/issues/wontfix/ in origin/main..HEAD. Resolve it in this change (abcd capture resolve iss-317 ...) or drop the trailer. check-issue-resolution: FAILED — 1 violation(s) ``` @@ -107,8 +107,9 @@ abcd — /path/to/your-repo work tiers: [development work work.local] ``` -The plugin surface adds one command the CLI does not carry: `/abcd:prepare-this-repo` gives a repository with no record yet the three-tier -`.abcd/` layout, an `AGENTS.md` router, and the commit gates. +The plugin surface adds three commands the CLI does not carry, all host-delegated markdown with no Go verb behind them. The one that matters +first: `/abcd:prepare-this-repo` gives a repository with no record yet the three-tier `.abcd/` layout, an `AGENTS.md` router, and the commit +gates. (`/abcd:consult` and `/abcd:ingest` drive a local sources corpus.) From there, three verbs cover most of a first session. In a plugin session, `/abcd:lint` checks the repository against the working conventions and names what is missing. `/abcd:capture "…"` files a half-formed observation to the issue ledger so it survives the session that noticed it. `/abcd:intent "…"` opens a user-facing change as a press-release intent, which is where a shipping change starts. diff --git a/internal/README.md b/internal/README.md index e1e4a8fc..41fb6b04 100644 --- a/internal/README.md +++ b/internal/README.md @@ -18,6 +18,13 @@ plugin surface, and a future MCP server share one engine. entered the terminal folders since the anchor tag. It owns the enum so the lints that GATE the judgement (`core/lint`), the ledger reader that VALIDATES it (`core/capture`), and the derivation that CONSUMES it cannot drift apart. +- **`core/issueschema/`** — the issue record's required frontmatter properties, + and nothing else. It is a leaf for the same reason `core/changelog` owns the + impact enum: the ledger reader (`core/capture`) and the lint that gates the + committed ledger (`core/lint`) must agree about what a well-formed record + carries, and a record they disagree about is one that sits in the ledger unread + by every surface. It is not inside `core/capture` because that package's own + tests import `core/lint`, so a lint importing capture back is an import cycle. - **`core/surface/`** — the compatibility surface as DATA: the snapshot of every command, flag, and manifest entry a consumer binds to, and the diff that names what a release narrowed. It shares a word with the `surface/` front-door tier diff --git a/internal/core/capture/validate.go b/internal/core/capture/validate.go index 1ea0f8b4..6c40d975 100644 --- a/internal/core/capture/validate.go +++ b/internal/core/capture/validate.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/intentdriven/abcd/internal/core/changelog" + "github.com/intentdriven/abcd/internal/core/issueschema" ) // knownFields is the additionalProperties:false allow-list from @@ -60,8 +61,11 @@ func validateStrict(fm map[string]any) error { } } - // Required strings. - for _, req := range []string{"id", "slug", "severity", "category", "source", "found_during"} { + // Required strings — the schema's own list (core/issueschema) minus + // schema_version, which the version check above already answered for. The + // record lint reads the SAME list, so the reader and the committed-ledger gate + // cannot disagree about what a well-formed record carries. + for _, req := range issueschema.RequiredStrings { v, present := fm[req] if !present { return fmt.Errorf("%w: missing required property %q", ErrMissingRequiredField, req) diff --git a/internal/core/capture/workflow.go b/internal/core/capture/workflow.go index 4a059bdd..503e102a 100644 --- a/internal/core/capture/workflow.go +++ b/internal/core/capture/workflow.go @@ -1,6 +1,7 @@ package capture import ( + "errors" "fmt" "os" "path/filepath" @@ -41,6 +42,9 @@ func Capture(req CaptureRequest) (CaptureResult, error) { if err := mutationPreamble(issuesRoot); err != nil { return CaptureResult{}, err } + if err := captureBlockers(issuesRoot, req.BlockedBy); err != nil { + return CaptureResult{}, err + } if strings.TrimSpace(req.FoundDuring) == "" { return CaptureResult{}, fmt.Errorf("found_during must be a non-empty string") @@ -88,6 +92,30 @@ func Capture(req CaptureRequest) (CaptureResult, error) { return result, nil } +// captureBlockers validates the blocked_by targets before any write, exactly as +// resolveProvenance validates the resolved_by members: the field is a +// cross-reference, and the record-lint blocker record_schema refuses one whose +// target is not in the corpus — so a capture that mints an unverified link hands +// back a record its own gate rejects, and the caller learns of it from the next +// preflight rather than from the command that wrote it. +// +// All three status directories count. Blocking on a resolved or wontfix target +// is legal: existence is what the cross-reference claims, and whether a blocker +// still holds anything up is the read-time priority projection's question +// (prioritise reads open/ alone). Shape stays validateStrict's and +// parseBlockedBy's job; this probe answers existence only. +func captureBlockers(issuesRoot string, blockedBy []string) error { + for _, dep := range blockedBy { + if _, _, err := findIssue(issuesRoot, dep); err != nil { + if errors.Is(err, ErrUnknownIssueID) { + return fmt.Errorf("capture: --blocked-by %s not found in the issue ledger; nothing written", dep) + } + return fmt.Errorf("capture: --blocked-by %s: %w; nothing written", dep, err) + } + } + return nil +} + func commitCapture(issuesRoot string, req CaptureRequest, issID, slug, placeholder string) (CaptureResult, error) { fields := []kv{ {"schema_version", 1}, diff --git a/internal/core/capture/workflow_test.go b/internal/core/capture/workflow_test.go index 293be753..5de64c50 100644 --- a/internal/core/capture/workflow_test.go +++ b/internal/core/capture/workflow_test.go @@ -585,6 +585,60 @@ func TestCaptureWritesBlockedByAndReadsBack(t *testing.T) { } } +// TestCaptureRefusesDanglingBlockedBy (iss-2608261437046287) proves a capture +// naming a blocked_by target that is in no status directory is refused before +// anything is written: blocked_by is a cross-reference, and record_schema — a +// blocker — refuses one whose target is not in the corpus, so minting the record +// anyway hands the caller a ledger entry the tool's own gate rejects. +func TestCaptureRefusesDanglingBlockedBy(t *testing.T) { + repo, ir := ledger(t) + _, err := Capture(CaptureRequest{ + RepoRoot: repo, IssuesRoot: ir, Text: "dependent", Severity: SeverityMinor, + Category: "bug", Source: "manual-test", Slug: "dep", FoundDuring: "t", + BlockedBy: []string{"iss-999999"}, + }) + if err == nil { + t.Fatal("capture with a dangling --blocked-by target must be refused") + } + if !strings.Contains(err.Error(), "iss-999999") || !strings.Contains(err.Error(), "nothing written") { + t.Errorf("error must name the id and say nothing was written, got %v", err) + } + entries, _ := os.ReadDir(filepath.Join(ir, "open")) + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".md") { + t.Fatalf("a refused capture wrote a record: %s", e.Name()) + } + } +} + +// TestCaptureAcceptsResolvedBlockedByTarget proves the existence probe counts all +// three status directories: blocking on an already-resolved issue is legal (the +// read-time priority projection is what decides a blocker is no longer holding +// anything up), so a resolved target must not refuse the capture. +func TestCaptureAcceptsResolvedBlockedByTarget(t *testing.T) { + repo, ir := ledger(t) + setSeqMinter(t) + root, err := Capture(CaptureRequest{ + RepoRoot: repo, IssuesRoot: ir, Text: "root cause", Severity: SeverityMinor, + Category: "bug", Source: "manual-test", Slug: "root", FoundDuring: "t", + }) + if err != nil { + t.Fatal(err) + } + if _, err := Resolve(ResolveRequest{ + RepoRoot: repo, IssuesRoot: ir, ID: root.ID, Resolution: "fixed", Impact: "fix", + }); err != nil { + t.Fatal(err) + } + if _, err := Capture(CaptureRequest{ + RepoRoot: repo, IssuesRoot: ir, Text: "dependent", Severity: SeverityMinor, + Category: "bug", Source: "manual-test", Slug: "dep", FoundDuring: "t", + BlockedBy: []string{root.ID}, + }); err != nil { + t.Fatalf("blocking on a resolved target is legal, got %v", err) + } +} + // TestDerivedPriorityUnblockedFirstThenSeverity proves the read-time projection: // List orders unblocked issues (highest severity first) ahead of blocked ones, // annotates each blocked row with its still-open blockers, and re-derives once a diff --git a/internal/core/changelog/version.go b/internal/core/changelog/version.go index 382b8078..f6e135a1 100644 --- a/internal/core/changelog/version.go +++ b/internal/core/changelog/version.go @@ -23,8 +23,9 @@ import "github.com/intentdriven/abcd/internal/core/launch" // fix -> patch++ // // The pre-1.0 row is load-bearing, not a shortcut. While abcd is at 0.x it has -// declared no stable surface, so a break bumps the minor (ADR-37: "pre-1.0, a -// minor may break, called out under Breaking"). The consequence is deliberate: +// declared no stable surface, so a break bumps the minor (ADR-37, as amended: +// pre-1.0, a minor may break — the bump itself is the signal, since the +// derived changelog has no Breaking section). The consequence is deliberate: // NO input can derive 1.0.0 from a 0.x base. The first 1.0.0 is a human's // explicit override, because declaring stability is a product decision no set of // records can make on the maintainer's behalf. diff --git a/internal/core/intent/audit.go b/internal/core/intent/audit.go index dd077ddd..a1de3543 100644 --- a/internal/core/intent/audit.go +++ b/internal/core/intent/audit.go @@ -11,6 +11,7 @@ import ( "strings" "syscall" + "github.com/intentdriven/abcd/internal/core/spec" "github.com/intentdriven/abcd/internal/fsutil" ) @@ -179,8 +180,11 @@ func emitAuditForIntent(repoRoot string, it Intent) (AuditEmitResult, error) { if !intentIDRe.MatchString(it.ID) { return AuditEmitResult{}, fmt.Errorf("intent: id %q must match ^itd-[0-9]+$", it.ID) } - if !specIDRe.MatchString(it.SpecID) { - return AuditEmitResult{}, fmt.Errorf("intent: spec id %q must match ^spc-[0-9]+$", it.SpecID) + // The stored spec_id is checked the tolerant way (a number must be readable + // from it), not against the strict argument grammar: record-lint accepts a + // slug-suffixed or zero-padded spec_id, and no path is built from this value. + if !spec.HasNum(it.SpecID) { + return AuditEmitResult{}, fmt.Errorf("intent: spec id %q must carry a spec number (spc-N)", it.SpecID) } abs := filepath.Join(repoRoot, it.Path) data, err := readRepoFile(abs, it.Path) @@ -253,7 +257,7 @@ func ReEmitAudit(repoRoot, intentID string) (AuditEmitResult, error) { if it.Bucket != BucketShipped { return AuditEmitResult{}, fmt.Errorf("intent: %s is in %s, not shipped; only a shipped intent owes a fidelity audit", intentID, it.Bucket) } - if !specIDRe.MatchString(it.SpecID) { + if !spec.HasNum(it.SpecID) { return AuditEmitResult{}, fmt.Errorf("intent: %s has no well-formed spec_id (%q); refusing to emit a review", intentID, it.SpecID) } return emitAuditForIntent(repoRoot, it) diff --git a/internal/core/intent/audit_test.go b/internal/core/intent/audit_test.go index 5dbc9ed0..c3a57398 100644 --- a/internal/core/intent/audit_test.go +++ b/internal/core/intent/audit_test.go @@ -504,3 +504,18 @@ func TestIngestVerdictRefusesSymlink(t *testing.T) { t.Errorf("unexpected error for symlinked verdict: %v", err) } } + +// TestReEmitAuditToleratesSpecIDSpelling proves the manual re-emit verb accepts +// the same lint-green spec_id spellings the ship move does. +func TestReEmitAuditToleratesSpecIDSpelling(t *testing.T) { + root := t.TempDir() + writeFile(t, root, shippedDir+"/itd-10-alpha.md", plannedLinked("itd-10", "alpha", "spc-1-alpha")) + + res, err := ReEmitAudit(root, "itd-10") + if err != nil { + t.Fatalf("ReEmitAudit must accept a slug-suffixed spec_id: %v", err) + } + if res.ReceiptID == "" || res.Status != "owed" { + t.Fatalf("ReEmitAudit result = %+v, want an owed receipt", res) + } +} diff --git a/internal/core/intent/intent_test.go b/internal/core/intent/intent_test.go index 146462fc..41dbca97 100644 --- a/internal/core/intent/intent_test.go +++ b/internal/core/intent/intent_test.go @@ -613,3 +613,50 @@ func TestSetFrontmatterFieldsToleratesDelimiterTrailingSpace(t *testing.T) { t.Fatalf("inserted keys must precede the closing delimiter, not enter the body\n---\n%s", out) } } + +// TestReconcileToleratesSpecIDSpelling proves the lifecycle verb yields to the +// record lint: record-lint compares a spec_id by NUMBER, so a slug suffix or a +// zero-padded id is lint-green — and a lint-green record must never be refused +// by the verb that ships it. +func TestReconcileToleratesSpecIDSpelling(t *testing.T) { + for _, specID := range []string{"spc-1-alpha", "spc-01"} { + t.Run(specID, func(t *testing.T) { + root := t.TempDir() + writeFile(t, root, plannedDir+"/itd-10-alpha.md", plannedLinked("itd-10", "alpha", specID)) + writeFile(t, root, specsOpen+"/spc-1-alpha.md", specNaming("spc-1", "alpha", "itd-10")) + + res, err := Reconcile(root, "spc-1") + if err != nil { + t.Fatalf("Reconcile must accept the lint-green spec_id %q: %v", specID, err) + } + if !res.IntentMoved || res.To != BucketShipped { + t.Fatalf("Reconcile result = %+v", res) + } + // The audit emit reads the same stored spec_id, so it must tolerate it too. + if res.AuditEmitError != "" { + t.Fatalf("audit emit must accept spec_id %q: %s", specID, res.AuditEmitError) + } + }) + } +} + +// TestLinkResolvesSpecByNumber proves the strict ^spc-[0-9]+$ ARGUMENT grammar +// still stands while the store lookup behind it compares by number, so a +// zero-padded spec record is linkable by its canonical id. +func TestLinkResolvesSpecByNumber(t *testing.T) { + root := t.TempDir() + writeFile(t, root, plannedDir+"/itd-10-alpha.md", + "---\nid: itd-10\nslug: alpha\nspec_id: null\nkind: standalone\n---\n# alpha\n") + writeFile(t, root, specsOpen+"/spc-01-alpha.md", specNaming("spc-01", "alpha", "itd-10")) + + res, err := Link(root, "itd-10", "spc-1") + if err != nil { + t.Fatalf("Link must resolve a zero-padded spec record: %v", err) + } + if res.Spec.ID != "spc-01" { + t.Fatalf("Link spec = %+v, want the spc-01 record", res.Spec) + } + if _, err := Link(root, "itd-10", "spc-1-alpha"); err == nil { + t.Fatal("Link must keep the strict ^spc-[0-9]+$ grammar for its argument") + } +} diff --git a/internal/core/intent/lifecycle.go b/internal/core/intent/lifecycle.go index 1a6d3bee..659a438b 100644 --- a/internal/core/intent/lifecycle.go +++ b/internal/core/intent/lifecycle.go @@ -303,7 +303,10 @@ func Reconcile(repoRoot, specID string) (ReconcileResult, error) { // Bidirectional agreement: the intent must point back at THIS spec. A null or // mismatched spec_id is drift (a one-sided link) — fail closed rather than ship // an intent that names a different, or no, spec. - if it.SpecID != specID { + // The comparison is canonical (spec.SameNum), not literal: record-lint matches + // a spec_id on its NUMBER, so a slug-suffixed or zero-padded value is + // lint-green and this verb must not refuse what the lint accepts. + if !spec.SameNum(it.SpecID, specID) { return ReconcileResult{}, fmt.Errorf("intent: %s spec_id is %q but spec %s claims it (bidirectional link disagrees); refusing to reconcile", intentID, it.SpecID, specID) } // Bucket guard runs BEFORE any move, so an unexpected bucket (drafts, diff --git a/internal/core/intent/ready_test.go b/internal/core/intent/ready_test.go index 43bbd19d..3f634ff5 100644 --- a/internal/core/intent/ready_test.go +++ b/internal/core/intent/ready_test.go @@ -264,3 +264,24 @@ func TestReadyFaults(t *testing.T) { t.Fatal("a symlinked intent record must be a fault") } } + +// TestReadySpecLinkToleratesSpecIDSpelling mirrors Reconcile's tolerance in the +// report: the spec_link check reads the same stored spec_id, so a lint-green +// slug-suffixed or zero-padded value must report the link as held, not missing. +func TestReadySpecLinkToleratesSpecIDSpelling(t *testing.T) { + for _, specID := range []string{"spc-1-alpha", "spc-01"} { + t.Run(specID, func(t *testing.T) { + root := t.TempDir() + writeFile(t, root, plannedDir+"/itd-10-alpha.md", plannedLinked("itd-10", "alpha", specID)) + writeFile(t, root, specsOpen+"/spc-1-alpha.md", specNaming("spc-1", "alpha", "itd-10")) + + res, err := Ready(root, "itd-10") + if err != nil { + t.Fatal(err) + } + if link := checkByName(t, res, "spec_link"); !link.OK { + t.Fatalf("spec_link = %+v, want OK for the lint-green spec_id %q", link, specID) + } + }) + } +} diff --git a/internal/core/issueschema/issueschema.go b/internal/core/issueschema/issueschema.go new file mode 100644 index 00000000..63abddd0 --- /dev/null +++ b/internal/core/issueschema/issueschema.go @@ -0,0 +1,27 @@ +// Package issueschema is the issue record's frontmatter schema as DATA: the one +// list of properties issue.schema.json marks required, held where every gate that +// asks the question can read it. +// +// It exists for the same reason core/changelog holds the impact enum: the writer +// and reader of the ledger (core/capture) and the lint that GATES the committed +// ledger (core/lint) must agree about what a well-formed record carries, and two +// hand-kept copies of a required-property list drift the moment one side gains a +// field. It is a leaf — no imports, no filesystem, no transport — because +// core/capture's own tests import core/lint, so a lint that imported capture back +// would be an import cycle in test. +package issueschema + +// Required is every property the issue schema marks required, in the order a +// record writes them. A record missing one is not a lax record: the ledger reader +// refuses it and skips it, so it goes silently invisible to every capture surface +// while still sitting in the ledger — which is what record_schema reports. +var Required = []string{ + "schema_version", "id", "slug", "severity", "category", "source", "found_during", +} + +// RequiredStrings is Required minus schema_version — the required properties +// whose value is a string. schema_version carries the integer version and is +// checked against the readers this repository ships, so it is validated on its +// own before the string-typed properties are walked. It is a slice OF Required +// rather than a second literal, so the two can never disagree about the set. +var RequiredStrings = Required[1:] diff --git a/internal/core/lifeboat/graveyard_lessons.go b/internal/core/lifeboat/graveyard_lessons.go index b9b70e99..72b10e24 100644 --- a/internal/core/lifeboat/graveyard_lessons.go +++ b/internal/core/lifeboat/graveyard_lessons.go @@ -13,6 +13,7 @@ import ( "syscall" "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/termsafe" ) // IngestLessons validates host-produced lesson JSON against a PACKED lifeboat and @@ -310,20 +311,12 @@ func filterEvidence(refs []string, ids map[string]bool) []string { return out } -// cleanLessonProse sanitises untrusted lesson prose: it collapses newlines and -// neutralises HTML-comment delimiters (oneLine-style, so the prose can neither -// break its line nor forge an abcd marker), strips control characters, then caps -// the length. An empty result signals the entry should be dropped. +// cleanLessonProse sanitises untrusted lesson prose: it collapses newlines, +// neutralises HTML openers (so the prose can neither break its line nor forge an +// abcd marker), strips control characters, then caps the length. An empty result +// signals the entry should be dropped. The cleaning itself is termsafe's — the +// canonical home this seam and cleanSynthProse both route through, rather than +// keeping divergent copies. func cleanLessonProse(s string) string { - s = strings.ReplaceAll(s, "\r", " ") - s = strings.ReplaceAll(s, "\n", " ") - s = strings.ReplaceAll(s, "", "-- >") - s = sanitize(s) // C0/DEL -> ?, tab -> space (the coverage helper) - s = strings.TrimSpace(s) - if len(s) > maxLessonProseBytes { - s = strings.ToValidUTF8(s[:maxLessonProseBytes], "") - s = strings.TrimSpace(s) - } - return s + return termsafe.CleanProse(s, maxLessonProseBytes) } diff --git a/internal/core/lifeboat/graveyard_lessons_test.go b/internal/core/lifeboat/graveyard_lessons_test.go index 7816a275..007c5ce0 100644 --- a/internal/core/lifeboat/graveyard_lessons_test.go +++ b/internal/core/lifeboat/graveyard_lessons_test.go @@ -354,6 +354,39 @@ func TestIngestLessonsSanitisesProse(t *testing.T) { } } +// TestIngestLessonsNeutralisesRawHTMLOpener pins the wider neutralisation the +// termsafe seam carries: a bare HTML opener (not just a comment marker) is broken +// with a space, so packed prose cannot smuggle a tag into a rendered lesson. +func TestIngestLessonsNeutralisesRawHTMLOpener(t *testing.T) { + dir := stdFixture(t) + raw := payload(t, Lesson{ID: "les-tag", Lesson: "before after", + Confidence: ConfidenceHigh, Evidence: []string{"adr-12"}}) + if _, err := IngestLessons(dir, raw); err != nil { + t.Fatalf("IngestLessons: %v", err) + } + got := readWrittenLessons(t, dir).Lessons[0].Lesson + if !strings.Contains(got, "< script") { + t.Errorf("raw HTML opener not neutralised: %q", got) + } +} + +// TestIngestLessonsNeutralisesAfterSanitise pins the ORDER of the two steps: +// Sanitize substitutes '?' for a masked control byte, so a '<' followed by an +// escape becomes '-.md"}, {prefix: "itd", noun: "intent", nodeType: "intent", buckets: intentBucketNames, fileNumRe: intentFileNumRe, filename: "itd--.md"}, {prefix: "spc", noun: "spec", nodeType: "spec", buckets: specBucketNames, fileNumRe: specFileNumRe, filename: "spc--.md"}, - {prefix: "iss", noun: "issue", nodeType: "issue", buckets: issueStatusDirs, fileNumRe: issueFileNumRe, filename: "iss--.md"}, + // The issue store's required properties come from the schema's ONE definition + // (core/issueschema), the same list the ledger reader validates against — a + // hand-copied list here would drift the moment the schema gains a field, and + // the drift would show up as a silently unread record, which is the defect + // this invariant exists to catch. + {prefix: "iss", noun: "issue", nodeType: "issue", buckets: issueStatusDirs, fileNumRe: issueFileNumRe, filename: "iss--.md", + requiredFields: issueschema.Required}, } // schemaRecord is one record file as the schema rule sees it: which store and @@ -197,6 +211,7 @@ func checkRecordSchema(repoRoot string, cfg RuleConfig) ([]Finding, error) { for _, r := range records { out = append(out, checkRecordFilename(r, cfg.Severity)...) + out = append(out, checkRecordRequiredFields(r, cfg.Severity)...) // Cross-references: a named record must be in the corpus, or declared // retired by the record that replaced it. @@ -303,6 +318,41 @@ func checkRecordFilename(r schemaRecord, severity string) []Finding { }} } +// checkRecordRequiredFields asserts that a record carries every frontmatter +// property its store declares required (iss-2608261437041050). Only a store that +// declares a list is judged; the rest are left to the rules that know their own +// schemas. +// +// The defect it closes is a SILENT one, which is why it belongs in the structural +// rule rather than in the reader alone: the issue ledger's reader validates each +// record and skips the ones that fail, so a committed record missing a required +// property disappears from `capture list`, `capture status` and every other +// surface — while sitting in the ledger, counted by nothing, reported by nothing. +// A record nobody can read is not a lax record, it is a lost one. +// +// A property present but empty (`schema_version:`) counts as missing for the same +// reason: the reader cannot make a value out of it either. +func checkRecordRequiredFields(r schemaRecord, severity string) []Finding { + var out []Finding + for _, field := range r.store.requiredFields { + f, present := r.fields[field] + if present && !isAbsentValue(f.value) { + continue + } + line := f.line + if line == 0 { + line = 1 + } + out = append(out, Finding{ + File: r.rel, Line: line, RuleID: ruleRecordSchema, Severity: severity, + Message: "frontmatter is missing required property '" + field + "'; the " + r.store.noun + + " reader validates before it reads, so a record without it is skipped — invisible to every " + + r.store.noun + " surface while it still sits in the store", + }) + } + return out +} + // noun renders the record kind for a message. func (r schemaRecord) noun() string { return r.store.noun } diff --git a/internal/core/lint/schema_test.go b/internal/core/lint/schema_test.go index 91dc46b8..eeaed8ef 100644 --- a/internal/core/lint/schema_test.go +++ b/internal/core/lint/schema_test.go @@ -571,6 +571,37 @@ func TestIntentLifecycleAcceptsAnADRSuccessor(t *testing.T) { } } +// TestRecordSchemaRequiresIssueFrontmatter (iss-2608261437041050) pins the +// required-property invariant for the issue store. The ledger reader validates +// every record it reads and SKIPS the ones that fail, so a committed record +// missing a required property is invisible to every capture surface while the +// record lint — which never asked the question — stays green. A store that +// declares no required properties (adr/itd/spc, whose schemas differ) is +// untouched, which the well-formed records in the same fixture prove. +func TestRecordSchemaRequiresIssueFrontmatter(t *testing.T) { + root := t.TempDir() + issues := "work/issues" + full := "---\nschema_version: 1\nid: iss-1\nslug: ok\nseverity: minor\ncategory: bug\nsource: user-observation\nfound_during: t\n---\n\nan issue\n" + writeFile(t, root, issues+"/open/iss-1-ok.md", full) + // The live shape of the defect: the record is complete but for schema_version, + // so every other gate reads it and only the reader drops it. + writeFile(t, root, issues+"/resolved/iss-2-stripped.md", + "---\nid: iss-2\nslug: stripped\nseverity: minor\ncategory: bug\nsource: user-observation\nfound_during: t\nresolution: done\n---\n\nan issue\n") + // A store with a different schema must not be judged against the issue's. + writeFile(t, root, "rec/decisions/adrs/0001-model.md", "---\nid: adr-1\n---\n# ADR-1\n") + + fs, err := Lint(schemaConfig(), root) + if err != nil { + t.Fatal(err) + } + if !findingWith(fs, filepath.Join(issues, "resolved", "iss-2-stripped.md"), ruleRecordSchema, "schema_version") { + t.Errorf("expected a missing-schema_version finding on the stripped record: %+v", fs) + } + if n := countRule(fs, ruleRecordSchema); n != 1 { + t.Fatalf("expected exactly 1 record_schema finding (the stripped record), got %d: %+v", n, fs) + } +} + // TestRecordSchemaGuardsTheRealRecord loads the committed record-lint config and // asserts the rule is armed as a blocker over the real stores. Deleting the rule, // dropping a store, or downgrading its severity fails here rather than silently diff --git a/internal/core/lint/speclinks.go b/internal/core/lint/speclinks.go index 93571be2..50729da2 100644 --- a/internal/core/lint/speclinks.go +++ b/internal/core/lint/speclinks.go @@ -94,6 +94,9 @@ func (x SpecLinkIndex) SpecBucket(specID string) (string, bool) { // ScanSpecLinks reads the intent buckets and the spec store once, both relative // to repoRoot. A missing tree contributes nothing and is not an error, mirroring // the rest of the record lint: an unpopulated repository is a state, not a fault. +// A tree that is present but cannot be read IS a fault and is returned: both +// halves fail closed, so no consumer ever reads an index that says "nothing +// here" about records it merely failed to open. // // top supplies the content exemptions. They are recorded per spec rather than // applied here, because they exempt a file from the lint's CONTENT checks — they @@ -103,13 +106,23 @@ func ScanSpecLinks(repoRoot, intentsDir, specsDir string, top Config) (SpecLinkI var idx SpecLinkIndex intentsRoot := filepath.Join(repoRoot, filepath.FromSlash(intentsDir)) - _ = filepath.WalkDir(intentsRoot, func(path string, d os.DirEntry, err error) error { - if err != nil || d.IsDir() || !intentFileRe.MatchString(d.Name()) { + if err := filepath.WalkDir(intentsRoot, func(path string, d os.DirEntry, err error) error { + if err != nil { + // A missing tree is the one soft case (and the guard that keeps the + // nil DirEntry WalkDir hands the failed root out of the checks below); + // every other walk error is a tree that could not be read, which is a + // fault, not an absence — fail closed exactly as the spec half does. + if path == intentsRoot && os.IsNotExist(err) { + return nil + } + return err + } + if d.IsDir() || !intentFileRe.MatchString(d.Name()) { return nil } content, rerr := os.ReadFile(path) if rerr != nil { - return nil + return rerr } fields := frontmatterFields(strings.Split(string(content), "\n")) id := fields["id"].value @@ -134,7 +147,9 @@ func ScanSpecLinks(repoRoot, intentsDir, specsDir string, top Config) (SpecLinkI SpecID: fields["spec_id"].value, }) return nil - }) + }); err != nil { + return SpecLinkIndex{}, err + } specsRoot := filepath.Join(repoRoot, filepath.FromSlash(specsDir)) for _, bucket := range specBucketNames { diff --git a/internal/core/lint/speclinks_test.go b/internal/core/lint/speclinks_test.go index cc02867c..09b89951 100644 --- a/internal/core/lint/speclinks_test.go +++ b/internal/core/lint/speclinks_test.go @@ -87,3 +87,18 @@ func TestScanSpecLinksMissingTreesAreSoft(t *testing.T) { t.Errorf("scanned %+v, want an empty index", idx) } } + +// TestScanSpecLinksUnreadableIntentIsHard proves the intent half fails closed +// exactly as the spec half does: a record that lists but cannot be read is a +// fault, not an absence. Swallowing it would hand the release cut's stale-intent +// refusal an index that says "no intents" about a tree it could not read. +func TestScanSpecLinksUnreadableIntentIsHard(t *testing.T) { + root := specLinkRepo(t) + planned := filepath.Join(root, "record", "intents", "planned") + if err := os.Symlink(filepath.Join(planned, "gone.md"), filepath.Join(planned, "itd-96-dangling.md")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if _, err := ScanSpecLinks(root, "record/intents", "record/specs", Config{}); err == nil { + t.Fatal("ScanSpecLinks must propagate an unreadable intent record") + } +} diff --git a/internal/core/spec/spec.go b/internal/core/spec/spec.go index 12a51c45..6c569941 100644 --- a/internal/core/spec/spec.go +++ b/internal/core/spec/spec.go @@ -78,13 +78,27 @@ type Store struct { Specs []Spec `json:"specs"` } -// Lookup returns the spec with the given id; ok is false when absent. +// Lookup returns the spec the given reference names; ok is false when absent. +// +// Matching is on the spec NUMBER, not the literal string, because that is the +// comparison record-lint makes: a spec_id is written bare (spc-9), with its slug +// (spc-9-widget), and zero-padded (spc-009) across the record, and all three are +// lint-green. A literal-only compare would let the lifecycle verbs refuse a +// record the lint accepts. An exact string match still wins when the store holds +// one, so a caller that names a record precisely gets that record; two specs +// sharing a number is a record defect the lint's spec_id_unique rule flags (the +// mint never produces one, since NextID allocates max+1 over the same numbers). func (s Store) Lookup(specID string) (Spec, bool) { for _, sp := range s.Specs { if sp.ID == specID { return sp, true } } + for _, sp := range s.Specs { + if SameNum(sp.ID, specID) { + return sp, true + } + } return Spec{}, false } @@ -114,9 +128,16 @@ func Validate(s Spec) error { // specNum extracts the numeric N from a spec id or spec_id value, or 0 if none. func specNum(id string) int { + n, _ := parseSpecNum(id) + return n +} + +// parseSpecNum extracts the numeric N from a spec id or spec_id value; ok is +// false when the value carries no usable number. +func parseSpecNum(id string) (int, bool) { m := specNumRe.FindStringSubmatch(id) if m == nil { - return 0 + return 0, false } n, err := strconv.Atoi(m[1]) if err != nil { @@ -124,9 +145,28 @@ func specNum(id string) int { // reservation: Atoi returns the clamped MaxInt64 alongside the error, and // keeping it would make NextID compute max+1 and wrap to a NEGATIVE id // (spc--9223…). Treat it as no number so the id space stays sane. - return 0 + return 0, false } - return n + return n, true +} + +// SameNum reports whether two spec references name the same spec number — the +// canonical comparison the record lint makes, so a verb using it can never +// refuse a spelling the lint accepts (spc-9, spc-9-widget, spc-009 are one +// spec). A value carrying no usable number (null, "spc-", "spc-abc", an +// over-int64 N) matches nothing, including another such value. +func SameNum(a, b string) bool { + an, aok := parseSpecNum(a) + bn, bok := parseSpecNum(b) + return aok && bok && an == bn +} + +// HasNum reports whether v is a spec reference carrying a usable number. It is +// the tolerant counterpart of the ^spc-[0-9]+$ argument grammar, for validating +// a STORED spec_id, which the record lint lets carry a slug or zero padding. +func HasNum(v string) bool { + _, ok := parseSpecNum(v) + return ok } // stubMarker is the opening of the author-guidance placeholder renderSpec mints. diff --git a/internal/core/spec/spec_test.go b/internal/core/spec/spec_test.go index 932fe0b7..d2e64b6e 100644 --- a/internal/core/spec/spec_test.go +++ b/internal/core/spec/spec_test.go @@ -88,3 +88,30 @@ func TestStoreLookupAndByIntent(t *testing.T) { t.Fatal("ByIntent(itd-77) unexpectedly found") } } + +// TestLookupResolvesBySpecNumber proves the store yields to the record lint: a +// spec_id is written bare, zero-padded, and with its slug across the corpus, and +// record-lint compares the NUMBER, so a literal-only Lookup would refuse a +// lint-green record. An exact string match still wins when the store holds one. +func TestLookupResolvesBySpecNumber(t *testing.T) { + store := Store{Specs: []Spec{ + {ID: "spc-9", Intent: "itd-1"}, + {ID: "spc-10", Intent: "itd-2"}, + }} + for _, ref := range []string{"spc-9", "spc-9-widget", "spc-009"} { + sp, ok := store.Lookup(ref) + if !ok || sp.ID != "spc-9" { + t.Errorf("Lookup(%q) = %+v, %v; want spc-9, true", ref, sp, ok) + } + } + for _, ref := range []string{"spc-11", "spc-", "null", ""} { + if sp, ok := store.Lookup(ref); ok { + t.Errorf("Lookup(%q) = %+v, true; want no match", ref, sp) + } + } + // A store carrying both spellings resolves to the record the caller named. + both := Store{Specs: []Spec{{ID: "spc-009", Intent: "itd-1"}, {ID: "spc-9", Intent: "itd-2"}}} + if sp, ok := both.Lookup("spc-9"); !ok || sp.ID != "spc-9" { + t.Errorf("Lookup(spc-9) = %+v, %v; want the exact-match record", sp, ok) + } +} diff --git a/internal/surface/cli/capture_surface_test.go b/internal/surface/cli/capture_surface_test.go index 8ed37ffc..458787aa 100644 --- a/internal/surface/cli/capture_surface_test.go +++ b/internal/surface/cli/capture_surface_test.go @@ -400,3 +400,29 @@ func TestCaptureEmptyTextNeverWrites(t *testing.T) { t.Fatalf("empty capture text filed %d issue(s); it must write nothing", n) } } + +// TestCaptureStatusBoardRendersSkipped (iss-2608261437041050): a ledger record +// the reader refuses is counted by none of the board's three totals, so a board +// that printed the totals alone would report a ledger smaller than the one on +// disk and say nothing about the record it dropped. `capture list` already +// renders the skipped roster; the bare status board must not undercount in +// silence. +func TestCaptureStatusBoardRendersSkipped(t *testing.T) { + repo := t.TempDir() + t.Chdir(repo) + + // One well-formed capture, so the ledger dirs exist and the board has a total. + runCLI(t, "capture", "a well formed observation", "--slug", "fine", "--json") + // A committed record missing a required property: the reader validates before + // it reads, so this one is skipped rather than counted. + stripped := filepath.Join(repo, ".abcd", "work", "issues", "open", "iss-900-stripped.md") + if err := os.WriteFile(stripped, []byte( + "---\nid: iss-900\nslug: stripped\nseverity: minor\ncategory: bug\nsource: user-observation\nfound_during: t\n---\n\nan issue\n"), 0o644); err != nil { + t.Fatal(err) + } + + board := string(runCLI(t, "capture")) + if !strings.Contains(board, "skipped") || !strings.Contains(board, "iss-900-stripped.md") { + t.Fatalf("the status board must render the skipped roster:\n%s", board) + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index eff36ef4..095906fb 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -2209,6 +2209,15 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { fmt.Fprintf(w, " %s %s %s%s\n", iss.ID, iss.Severity, iss.Slug, blockedNote(iss)) } } + // The skipped roster, exactly as `capture list` renders it + // (iss-2608261437041050): a record the reader refuses is counted + // by none of the three totals above, so a board that printed the + // totals alone would under-report the ledger and say nothing about + // the records it dropped. Path and Error echo the malformed file's + // own name and bytes, so both are sanitised before the terminal. + for _, sk := range st.Skipped { + fmt.Fprintf(w, " skipped %s: %s\n", termsafe.Sanitize(sk.Path), termsafe.Sanitize(sk.Error)) + } fmt.Fprint(w, ledgerDecisionRule) fmt.Fprint(w, ideateRoutingRule) }) diff --git a/scripts/check-reviews-cases.sh b/scripts/check-reviews-cases.sh new file mode 100755 index 00000000..c88a03cd --- /dev/null +++ b/scripts/check-reviews-cases.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# Proves check-reviews.sh can actually FAIL, and fails for the right reasons. +# +# RD002 is an append-only claim over committed history, and the claim is a fact +# about the history or it is nothing. Its first implementation probed each +# working-tree file with a pathspec-scoped `git log --diff-filter=MR`, which can +# never report an R — rename detection needs both sides in view — and swallowed +# git's own errors, so a rename and a broken git both read as clean. That is the +# worst shape a gate can take: green while it sees nothing. Every rule here is +# asserted in BOTH directions — a violating fixture must be refused, and a clean +# one must pass — because a check that only ever sees clean input proves nothing +# about refusal. +# +# Fixtures are built in a scratch repository, never against this one: the rule is +# about committed history, and history is cheap to stage in a throwaway repo and +# impossible to stage honestly in a tree someone is working in. +# +# Usage: check-reviews-cases.sh +# Exit 0 all cases behaved, 1 a case did not. +set -euo pipefail + +# Hermetic git (iss-28, iss-313): every scratch-repo command below is `git -C +# "$d" …`, but an inherited absolute GIT_DIR overrides -C and redirects these +# fixture commits onto the ambient repository — which then reports all-green while +# its real history is rewritten. An inherited GIT_CONFIG_GLOBAL / core.hooksPath +# also fires the developer's global hooks inside the scratch repos and breaks the +# run for a reason that has nothing to do with the reviews charter. Neutralise the +# ambient git environment before the first git call, matching +# check-issue-resolution-cases.sh and gitutil.IsolatedEnv. (commit.gpgsign is set +# per-repo below; this closes the rest.) +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_CONFIG GIT_CONFIG_COUNT GIT_CONFIG_PARAMETERS +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 GIT_TERMINAL_PROMPT=0 + +GATE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/check-reviews.sh" +[ -x "$GATE" ] || { + echo "cases: gate not executable: $GATE" >&2 + exit 2 +} + +failures=0 +tmproot="$(mktemp -d)" +trap 'rm -rf "$tmproot"' EXIT + +REV_DIR=".abcd/work/reviews" +SCOPE="$REV_DIR/2026-01-01-a-scope" + +# expect