From 8f58829fc4f73f27a27fe6a575ab1b0e4110d476 Mon Sep 17 00:00:00 2001 From: "melodic-automation-lanes[bot]" <337595936+melodic-automation-lanes[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:15:42 +0000 Subject: [PATCH 1/2] Resolve issue #10039 Lane-Activity: work-resolve-issue/resolve-issue --- ...10039-acceptance-criterion-ids-3f9c2a71.md | 12 +++ ...10039-acceptance-criterion-ids-3f9c2a71.md | 7 ++ .../planning/scripts/check-plan-outcome.sh | 76 ++++++++++++++++++- .../scripts/check-plan-outcome.test.sh | 44 +++++++++++ plugins/planning/skills/interview/SKILL.md | 12 +-- .../planning/skills/interview/context/loop.md | 16 ++-- .../skills/interview/context/surface.md | 2 +- plugins/planning/skills/plan/SKILL.md | 3 +- .../skills/plan/context/plan-template.md | 19 +++++ plugins/planning/skills/plan/evals/evals.json | 4 +- .../skills/plan/templates/plan-md-anatomy.md | 6 +- plugins/work-items/skills/decompose/SKILL.md | 20 ++++- .../skills/decompose/context/re-decompose.md | 5 +- 13 files changed, 200 insertions(+), 26 deletions(-) create mode 100644 .changes/planning/feat-10039-acceptance-criterion-ids-3f9c2a71.md create mode 100644 .changes/work-items/feat-10039-acceptance-criterion-ids-3f9c2a71.md diff --git a/.changes/planning/feat-10039-acceptance-criterion-ids-3f9c2a71.md b/.changes/planning/feat-10039-acceptance-criterion-ids-3f9c2a71.md new file mode 100644 index 0000000000..a25e971139 --- /dev/null +++ b/.changes/planning/feat-10039-acceptance-criterion-ids-3f9c2a71.md @@ -0,0 +1,12 @@ +--- +bump: minor +--- + +### Added + +- `check-plan-outcome.sh` grades a new `criteria` criterion: each acceptance criterion id (`AC1:`) must be named on some phase's `**Satisfies:**` line. It fails an unmapped id (`unmapped=`), an id no criterion defines (`unknown=`), a criterion without an id beside ones with ids, and an id used twice. A plan whose criteria carry no id passes with a warning. + +### Changed + +- `/planning:interview` writes each Brief acceptance criterion with an id, `- AC: `, with an EARS tag after the id under the `ears` format. +- `/planning:plan` numbers any Brief criteria that lack ids, gives a criterion it adds the next id, and has each phase name the ids it satisfies; its outcome gate maps them by script instead of by reading. diff --git a/.changes/work-items/feat-10039-acceptance-criterion-ids-3f9c2a71.md b/.changes/work-items/feat-10039-acceptance-criterion-ids-3f9c2a71.md new file mode 100644 index 0000000000..20df70fb1b --- /dev/null +++ b/.changes/work-items/feat-10039-acceptance-criterion-ids-3f9c2a71.md @@ -0,0 +1,7 @@ +--- +bump: minor +--- + +### Added + +- `/work-items:decompose` traces each source acceptance criterion id to the slices that carry it, reports every id no slice carries before approval, and keeps the id on each carried criterion in an item's `## Done when`. diff --git a/plugins/planning/scripts/check-plan-outcome.sh b/plugins/planning/scripts/check-plan-outcome.sh index 5cb369e634..0f310cb1d7 100755 --- a/plugins/planning/scripts/check-plan-outcome.sh +++ b/plugins/planning/scripts/check-plan-outcome.sh @@ -34,10 +34,21 @@ # A plan with no such line passes. Failures are listed as # `invalid=:`, the reason one of kind, until, for, # missing-phase, not-later, last-phase or outside-phase +# criteria every acceptance criterion id maps to a phase. A criterion is a +# list item under an `Acceptance criteria` heading; its id opens +# the item after any checkbox or bold, `AC` then digits +# (`- AC3: ...`). A phase names the ids it satisfies on a +# `**Satisfies:** AC1, AC3` line (a list marker before it is +# allowed); one outside a phase section maps nothing. Fails on a +# criterion id no phase satisfies (`unmapped=`), a satisfied id no +# criterion defines (`unknown=`), a criterion with no id once any +# has one (`without-id=`) and an id defined twice +# (`duplicate=`). Pass prints `ids= mapped=`. A plan with no +# id anywhere passes `ids=0`, with a `warning=criteria-without-ids` +# line when it has criteria; an item reading `none ...` is not one # -# "Every brief acceptance criterion maps to a phase, and no phase does -# Out-of-scope work" is judgment and stays in the skill's prose; this gate does -# not claim it. +# "No phase does Out-of-scope work, and every phase traces to a criterion" is +# judgment and stays in the skill's prose; this gate does not claim it. # # Headings inside fenced code blocks are ignored. A fence closes only on the same # character with at least the opening length and no info string, so a three-backtick @@ -153,6 +164,7 @@ structural="$( /^#+[ \t]/ { heading = $0 in_table = 0 + in_ac = (tolower(heading) ~ /^#+[ \t]+acceptance criteria[ \t:]*$/) if (heading ~ /^###[ \t]+Phase[ \t]+([0-9]+(\.[0-9]+)?[A-Za-z]?|[IVXLC]+)[ \t]*:/) { flush_phase() phases++ @@ -184,6 +196,26 @@ structural="$( sub(/^[ \t]*([-*+][ \t]+)?\*\*Planned breakage:\*\*[ \t]*/, "", text) breakage_text[breakages] = text } + in_ac && /^ ?[-*+][ \t]+/ { + text = $0 + sub(/^ ?[-*+][ \t]+/, "", text) + sub(/^\[[ xX]\][ \t]+/, "", text) + sub(/^[*_]+/, "", text) + if (match(text, /^AC[0-9]+/) && substr(text, RLENGTH + 1, 1) !~ /[A-Za-z0-9]/) { + id = substr(text, 1, RLENGTH) + if (id in ac_def) ac_dup = ac_dup " " id + else { ac_n++; ac_order[ac_n] = id; ac_def[id] = 1 } + } else if (tolower(text) !~ /^none([^a-z]|$)/) ac_noid = ac_noid " " NR + } + in_phase && /^[ \t]*([-*+][ \t]+)?\*\*Satisfies:\*\*/ { + text = $0 + sub(/^[ \t]*([-*+][ \t]+)?\*\*Satisfies:\*\*/, "", text) + while (match(text, /AC[0-9]+/)) { + id = substr(text, RSTART, RLENGTH) + text = substr(text, RSTART + RLENGTH) + if (!(id in sat)) { sat_n++; sat_order[sat_n] = id; sat[id] = 1 } + } + } { if (in_phase && $0 ~ /Sanity Check/) phase_sanity = 1 if ($0 ~ /^[ \t]*\|[ \t]*Decision[ \t]*\|[ \t]*What it changes/) { in_table = 1; decisions_table = 1; next } @@ -255,6 +287,18 @@ structural="$( printf "id_without_row=%s\n", no_row printf "row_without_tag=%s\n", no_tag for (i = 1; i <= bare_n; i++) printf "bare_tag=%s\n", bare_tag[i] + for (i = 1; i <= ac_n; i++) { + if (ac_order[i] in sat) ac_mapped++ + else ac_unmapped = ac_unmapped " " ac_order[i] + } + for (i = 1; i <= sat_n; i++) if (!(sat_order[i] in ac_def)) ac_unknown = ac_unknown " " sat_order[i] + printf "ac_ids=%d\n", ac_n + printf "ac_cited=%d\n", sat_n + printf "ac_mapped=%d\n", ac_mapped + printf "ac_unmapped=%s\n", ac_unmapped + printf "ac_unknown=%s\n", ac_unknown + printf "ac_noid=%s\n", ac_noid + printf "ac_dup=%s\n", ac_dup } ' "$plan" )" || die "could not parse: $plan" @@ -273,6 +317,13 @@ id_without_row="$(field id_without_row)" row_without_tag="$(field row_without_tag)" breakages="$(field breakages)" breakage_invalid="$(field breakage_invalid)" +ac_ids="$(field ac_ids)" +ac_cited="$(field ac_cited)" +ac_mapped="$(field ac_mapped)" +ac_unmapped="$(field ac_unmapped)" +ac_unknown="$(field ac_unknown)" +ac_noid="$(field ac_noid)" +ac_dup="$(field ac_dup)" failed=0 report() { @@ -337,6 +388,25 @@ else report planned-breakage fail "invalid=${invalid// /,}" fi +commas() { sed 's/^ //; s/ /,/g' <<<"$1"; } +if [[ "$ac_ids" -eq 0 && "$ac_cited" -eq 0 ]]; then + report criteria pass "ids=0" + if [[ -n "${ac_noid// /}" ]]; then + printf 'warning=criteria-without-ids count=%s (no acceptance criterion carries an AC id, so none is traced to a phase)\n' "$(wc -w <<<"$ac_noid" | tr -d ' ')" + fi +else + detail="ids=$ac_ids mapped=$ac_mapped" + [[ -z "${ac_unmapped// /}" ]] || detail+=" unmapped=$(commas "$ac_unmapped")" + [[ -z "${ac_unknown// /}" ]] || detail+=" unknown=$(commas "$ac_unknown")" + [[ -z "${ac_noid// /}" ]] || detail+=" without-id=$(commas "$ac_noid")" + [[ -z "${ac_dup// /}" ]] || detail+=" duplicate=$(commas "$ac_dup")" + if [[ "$detail" == "ids=$ac_ids mapped=$ac_mapped" ]]; then + report criteria pass "$detail" + else + report criteria fail "$detail" + fi +fi + readonly max_path_hits=20 # /Users and /home must begin a path: not preceded by a path-continuation character. diff --git a/plugins/planning/scripts/check-plan-outcome.test.sh b/plugins/planning/scripts/check-plan-outcome.test.sh index 4d1b71d7b2..371c4d8ebd 100755 --- a/plugins/planning/scripts/check-plan-outcome.test.sh +++ b/plugins/planning/scripts/check-plan-outcome.test.sh @@ -316,6 +316,50 @@ good_plan | sed '/^## Brief$/a\ **Planned breakage:** build red until Phase 2 for src/: before any phase' >"$TMP/pb-outside.md" run "a declaration outside any phase section fails" 1 "$TMP/pb-outside.md" '^criterion=planned-breakage status=fail invalid=[0-9]+:outside-phase$' +# criteria: each AC id under an Acceptance criteria heading maps to a phase's +# **Satisfies:** line. +run "a plan with no criteria passes criteria" 0 "$TMP/good.md" '^criterion=criteria status=pass ids=0$' + +traced_plan() { + good_plan | sed '/^Move dotfiles under chezmoi\.$/a\ +\ +### Acceptance criteria\ +- AC1: `chezmoi apply` manages the shell config\ +- AC2: [event-driven] WHEN chezmoi applies, the README names it' | + sed '/^### Phase 1:/a\ +**Satisfies:** AC1' | + sed '/^### Phase 3:/a\ +- **Satisfies:** AC2' +} + +traced_plan >"$TMP/ac-traced.md" +run "every criterion id satisfied by a phase passes" 0 "$TMP/ac-traced.md" '^criterion=criteria status=pass ids=2 mapped=2$' + +traced_plan | grep -v '^- \*\*Satisfies:\*\* AC2$' >"$TMP/ac-unmapped.md" +run "a criterion id no phase satisfies fails and names the id" 1 "$TMP/ac-unmapped.md" '^criterion=criteria status=fail ids=2 mapped=1 unmapped=AC2$' + +traced_plan | sed 's/^\*\*Satisfies:\*\* AC1$/**Satisfies:** AC1, AC7/' >"$TMP/ac-unknown.md" +run "a satisfied id no criterion defines fails and names the id" 1 "$TMP/ac-unknown.md" '^criterion=criteria status=fail ids=2 mapped=2 unknown=AC7$' + +traced_plan | sed 's/^- AC2: .*$/&\n- the README links the chezmoi docs/' >"$TMP/ac-noid.md" +run "a criterion with no id beside ones with ids fails" 1 "$TMP/ac-noid.md" '^criterion=criteria status=fail ids=2 mapped=2 without-id=[0-9]+$' + +traced_plan | sed 's/^- AC2: .*$/&\n- AC1: a second criterion reusing the id/' >"$TMP/ac-dup.md" +run "an id defined twice fails" 1 "$TMP/ac-dup.md" '^criterion=criteria status=fail ids=2 mapped=2 duplicate=AC1$' + +traced_plan | grep -v 'Satisfies' | sed 's/^- AC[0-9]*: /- /' >"$TMP/ac-bare.md" +run "criteria with no id anywhere pass with a warning" 0 "$TMP/ac-bare.md" '^criterion=criteria status=pass ids=0$' +run "the no-id warning counts the criteria" 0 "$TMP/ac-bare.md" '^warning=criteria-without-ids count=2 ' + +traced_plan | grep -v '^- \*\*Satisfies:\*\* AC2$' | sed 's/^## Blast radius$/&\n**Satisfies:** AC2/' >"$TMP/ac-outside.md" +run "a Satisfies line outside a phase maps nothing" 1 "$TMP/ac-outside.md" 'unmapped=AC2$' + +traced_plan | sed 's/^- AC1: /- [ ] **AC1:** /; s/^\*\*Satisfies:\*\* AC1$/**Satisfies:** AC10, AC1/' | sed 's/^- AC2: .*$/&\n- AC10: a tenth criterion/' >"$TMP/ac-forms.md" +run "a checkbox or bold id maps, and AC10 is not AC1" 0 "$TMP/ac-forms.md" '^criterion=criteria status=pass ids=3 mapped=3$' + +{ traced_plan; printf '\n```markdown\n### Acceptance criteria\n- AC9: quoted\n```\n'; } >"$TMP/ac-fenced.md" +run "a criterion inside a code fence is not graded" 0 "$TMP/ac-fenced.md" '^criterion=criteria status=pass ids=2 mapped=2$' + run "the default run does not require an Approval line" 0 "$TMP/good.md" '^phases=3 status=ok$' run "the default run ignores an unapproved placeholder line" 0 "$TMP/ap-template.md" '^phases=3 status=ok$' diff --git a/plugins/planning/skills/interview/SKILL.md b/plugins/planning/skills/interview/SKILL.md index f369e2f664..c59fe7e2d8 100644 --- a/plugins/planning/skills/interview/SKILL.md +++ b/plugins/planning/skills/interview/SKILL.md @@ -261,9 +261,9 @@ non-interactive, so never read an environment variable to decide this. ### EARS tags, when the convention selects them Resolve `acceptance_criteria_format` per the ladder below. Under `free-text`, the default and every -degrade, emit criteria exactly as today: `- `, untagged, byte-comparable in -shape to a run with no convention surface at all. Under `ears`, every emitted criterion carries a -bracketed pattern prefix on that same plain-bullet form: +degrade, emit criteria exactly as today: `- AC: `, untagged, byte-comparable +in shape to a run with no convention surface at all. Under `ears`, every emitted criterion carries a +bracketed pattern prefix after its id on that same plain-bullet form: | Tag | Pattern | |---|---| @@ -274,8 +274,8 @@ bracketed pattern prefix on that same plain-bullet form: | `[optional-feature]` | `WHERE , ` | ```text -- [event-driven] WHEN the upload completes, the manifest is rewritten -- [unwanted-behavior] IF the upload fails, THEN the partial manifest is discarded +- AC1: [event-driven] WHEN the upload completes, the manifest is rewritten +- AC2: [unwanted-behavior] IF the upload fails, THEN the partial manifest is discarded ``` The five names are exactly `ubiquitous`, `event-driven`, `state-driven`, `unwanted-behavior`, and @@ -288,7 +288,7 @@ owed a sixth pattern. Where the tags travel: they land in the Brief's `### Acceptance criteria`, and a consumer that publishes the Brief verbatim as a tracker container body carries them with it. They do not reach per-slice criteria, which are freshly authored by whatever decomposes the work, because a vertical -slice owns everything it grades. +slice owns everything it grades; a slice keeps only the criterion's id. ### Resolution ladder for `acceptance_criteria_format` diff --git a/plugins/planning/skills/interview/context/loop.md b/plugins/planning/skills/interview/context/loop.md index d64d2357da..20e1449ee1 100644 --- a/plugins/planning/skills/interview/context/loop.md +++ b/plugins/planning/skills/interview/context/loop.md @@ -461,7 +461,7 @@ Each section in the PLAN.md Brief captures a specific shape. Keep tight. - **Ecosystem / stack constraints:** e.g. "no new languages", "no new mapping library" - **Timeline constraints:** e.g. "must ship before mobile release freeze" -**Acceptance criteria:** bullet list. How to verify done. Each criterion must map to a test or verifiable observation: +**Acceptance criteria:** bullet list, each opening with its id (`AC1:`, `AC2:`, ...). How to verify done. Each criterion must map to a test or verifiable observation: - ✅ "`GET /api/users/me` returns 401 when the session token is missing" (testable) - ❌ "Authentication works correctly" (fuzzy) @@ -482,11 +482,11 @@ Two behaviors attach here, both defined in the SKILL.md section "Acceptance-crit ```text ### Acceptance criteria -- [ubiquitous] The manifest is valid JSON at rest -- [event-driven] WHEN the upload completes, the manifest is rewritten -- [state-driven] WHILE a rebuild is in flight, reads are served from the previous manifest -- [unwanted-behavior] IF the upload fails, THEN the partial manifest is discarded -- [optional-feature] WHERE checksum verification is enabled, the manifest records a digest per entry +- AC1: [ubiquitous] The manifest is valid JSON at rest +- AC2: [event-driven] WHEN the upload completes, the manifest is rewritten +- AC3: [state-driven] WHILE a rebuild is in flight, reads are served from the previous manifest +- AC4: [unwanted-behavior] IF the upload fails, THEN the partial manifest is discarded +- AC5: [optional-feature] WHERE checksum verification is enabled, the manifest records a digest per entry ``` Spell them exactly this way: `ubiquitous`, `event-driven`, `state-driven`, `unwanted-behavior`, `optional-feature`. A downstream reader matches on the literal name, so a variant spelling is not a near miss; it is an untagged criterion that looks tagged. @@ -526,7 +526,7 @@ Write this into `//PLAN.md` (default `.work/`; the topic - Quality requirement: : [; target: ] ### Acceptance criteria -- +- AC1: ### Captured assumptions - Quality bar: prototype | internal | production @@ -547,6 +547,6 @@ Write this into `//PLAN.md` (default `.work/`; the topic **The Intent line keeps the user's own words.** Quote the user's stated reason, never a paraphrase, and write `none stated` on any of the three Goal lines the user gave nothing for. -**The criteria bullet stays a plain bullet.** `- ` is the emitted shape in both formats: `free-text` fills it as-is, `ears` fills it as `- [] `. Never a checkbox: `- [ ]` is decompose's slice shape, and a `[pattern]` prefix on a checkbox line is ambiguous with an unchecked box. +**The criteria bullet stays a plain bullet.** `- AC: ` is the emitted shape in both formats: `free-text` fills it as-is, `ears` fills it as `- AC: [] `. `AC` is the criterion's id, numbered from `AC1` in order and never reused: `/planning:plan` maps each id to the phases that satisfy it and `/work-items:decompose` to the slices that carry it. Never a checkbox: `- [ ]` is decompose's slice shape, and a `[pattern]` prefix on a checkbox line is ambiguous with an unchecked box. **The arbiter tag decides who resolves the question.** Default `/planning:plan` is fine for execution-shape decisions (orchestration shape, agent rosters, phase nesting) within already-approved scope. Use `USER-RESERVED` for any deferred question whose resolution could change the brief's acceptance criteria, out-of-scope list, or constraints. When in doubt, mark `USER-RESERVED` and let `/planning:plan` surface it at approval time. diff --git a/plugins/planning/skills/interview/context/surface.md b/plugins/planning/skills/interview/context/surface.md index 8190332375..41828d9702 100644 --- a/plugins/planning/skills/interview/context/surface.md +++ b/plugins/planning/skills/interview/context/surface.md @@ -193,7 +193,7 @@ The event stream sends a `ping` every 15 seconds while idle. The page re-fetches On the page surface, SKILL.md Step 3's confirmation gate runs through the page: 1. Restate the shared understanding with a `restate` op, `problem` first (the Brief's Problem fields per `problem-framing.md` "Problem fields", Gap first and `unknown` where no evidence exists, so the Confirm covers it), then `workSize`, `goal`, `constraints`, `decisions`, `acceptance`, `outOfScope`, `deferred`, and `planningOwned` (the decisions the interview hands to `/planning:plan` to make). The page's summary screen shows it with Confirm and Something's off. The restatement carries the same register-sourced recap as Step 3: one line per `Q` (`Q : ()`), in `decisions`, generated from `round.sh export-ledger --recap --ledger '' --out '/recap.md'`, never from the transcript. The exporter's compact form keeps one line per live question, collapses every withdrawn row into one closing `Withdrawn (N): Q, ...` line, and cuts long answers so the recap fits the 20,000-character restate cap; use its output as `decisions` without hand-trimming (it exits 1 after writing only when even the shortest cut cannot fit). The ledger's register lags the page until `round.sh sync-ledger` rewrites its rows, so sync it first, as Wrap-up step 1 does, then run the Step 3 procedure check and cite its exit code. - The `goal` section carries the outcome paragraph and then the Brief Goal's three lines, `- Intent (why, in the user's words):`, `- Vision (where this heads):` and `- When goals conflict, prefer:` ([`loop.md`](loop.md) "Brief template (the literal shape)"); the exporter folds goal text into one paragraph, so after `export-brief --into` writes PLAN.md, rewrite its `### Goal` so those three lines stand as separate `-` bullets. + The `goal` section carries the outcome paragraph and then the Brief Goal's three lines, `- Intent (why, in the user's words):`, `- Vision (where this heads):` and `- When goals conflict, prefer:` ([`loop.md`](loop.md) "Brief template (the literal shape)"); the exporter folds goal text into one paragraph, so after `export-brief --into` writes PLAN.md, rewrite its `### Goal` so those three lines stand as separate `-` bullets. The `acceptance` section writes one criterion per line, each opening with its id (`AC1: `), which the export keeps. 2. Wait for a `confirm-understanding` event. `alt: confirm` whose `contentRev` equals the current restatement `rev` passes the gate: `handle` it. The server refuses a Confirm on an older `rev` as stale, so a passing event always names the current restatement. 3. `alt: off` means the gate has not passed. `note-reply` to its `text` with its `seq`, fix the understanding (re-ask or revise questions as needed), and post a new `restate`; the page shows the new one unconfirmed. diff --git a/plugins/planning/skills/plan/SKILL.md b/plugins/planning/skills/plan/SKILL.md index 3b5e4a92d2..d9126478e7 100644 --- a/plugins/planning/skills/plan/SKILL.md +++ b/plugins/planning/skills/plan/SKILL.md @@ -76,13 +76,14 @@ Run `bash "${CLAUDE_PLUGIN_ROOT}/scripts/check-plan-outcome.sh" ` and r - **Every unilateral decision has a table row**. When the plan carries an `[EXEC-SHAPE]` / `[FALLBACK]` tag from Step 4.6, the "Decisions made (gate-passed)" table (`| Decision | What it changes in the plan | ...`) is in PLAN.md with at least one row. A tag may carry an id (`[EXEC-SHAPE D4]`) that its row starts its first column with; the script fails a tag id with no row and a row id no tag cites, and prints `mapped=` (see [context/tag-decisions.md](context/tag-decisions.md) "Presentation contract"). - **Blast radius assessed**. A Blast-radius line naming LOW, MEDIUM, HIGH, or CRITICAL exists (from Step 3b). - **Planned breakage is well formed**. Each `**Planned breakage:**` line (the optional per-phase line in [context/plan-template.md](context/plan-template.md)) sits in a phase section, names a kind of `build`, `tests` or `lint`, a later `### Phase` heading to stay red until, and the failing paths or test filter: the `planned-breakage` criterion. A plan with no such line passes. +- **Every acceptance criterion maps to a phase**. Each criterion opens with its id (`AC1:`) and each phase names the ids it satisfies on a `**Satisfies:**` line; the `criteria` criterion names each id no phase satisfies (`unmapped=`), per [context/plan-template.md](context/plan-template.md) "Criterion ids". A `warning=criteria-without-ids` line means the criteria still need ids: add them, then rerun. - **Paths are portable**. No drive-letter path and no `/Users/` or `/home/` path, since a published PLAN.md is read on other machines; a deliberate example carries `` on its line. Then run `bash "${CLAUDE_PLUGIN_ROOT}/scripts/check-plan-citations.sh" --commit HEAD ` from the repository the plan cites and require exit 0: every `path:line` citation (a path from the repository root, then a line, a `start-end` range, or a comma list of them) names a file and lines that exist at that commit, and each one that does not prints a `citation=` line with its reason. Cite lines as they stand at that commit, and cite a file outside it (a memory-slice file) without a line number. Write the `commit=` value it prints into PLAN.md's `Citations checked at:` line, so a reader knows which tree the line numbers describe. A deliberate example, or a host and port the script reads as a citation, carries `` on its line. Checked by reading, or by their own script: -- **Every brief acceptance criterion maps to a phase, and every phase traces to one**. Walk the Brief's Acceptance criteria and Out-of-scope sections: each criterion lands in at least one phase, no phase does work the Brief lists as Out-of-scope, and every phase and work item serves a criterion or something a criterion needs (a test, a migration, a dead-code removal the `phase_order` step calls for). Cut anything else, or surface it as an Open Decision for the user to keep. Evidence: an agent-built change shipped about 1,900 production lines, about 380 of them beyond the legacy behavior and the ticket's criteria, caught only in review. +- **Every phase traces to a criterion**. Walk the phases against the Brief's Out-of-scope section: no phase does work it lists, and every phase and work item serves a criterion or something a criterion needs (a test, a migration, a dead-code removal the `phase_order` step calls for). Cut anything else, or surface it as an Open Decision for the user to keep. Evidence: an agent-built change shipped about 1,900 production lines, about 380 of them beyond the legacy behavior and the ticket's criteria, caught only in review. - **Each tag has its own row**. Every tagged decision appears in that table with its what-it-changes column filled; below-bar decisions were interviewed, not decided. The script maps only tags that carry an id; a bare tag passes with a `warning=bare-tag` line, so map those by reading. - **Displaced answers are listed at Step 5**. When an interview ledger exists, run `bash "${CLAUDE_PLUGIN_ROOT}/scripts/check-open-questions.sh" --ledger `. It passes when the verdict reports `open=0` and `superseded=N` (exit 1 is expected when N > 0) and every id on the ledger's `| superseded-by-plan |` rows (the verdict prints only the count) appears in the Step 5 "Displaced answers and new external effects" block. diff --git a/plugins/planning/skills/plan/context/plan-template.md b/plugins/planning/skills/plan/context/plan-template.md index 00f0da3691..9158284ac5 100644 --- a/plugins/planning/skills/plan/context/plan-template.md +++ b/plugins/planning/skills/plan/context/plan-template.md @@ -11,6 +11,7 @@ - [Execution-shape analysis](#execution-shape-analysis) - [Large-scale changes (migrations, library swaps, broad refactoring)](#large-scale-changes-migrations-library-swaps-broad-refactoring) - [Decision ids (tagged decisions)](#decision-ids-tagged-decisions) +- [Criterion ids (traced to phases)](#criterion-ids-traced-to-phases) - [Phase Review tags (optional per phase)](#phase-review-tags-optional-per-phase) - [Planned breakage (optional per phase)](#planned-breakage-optional-per-phase) - [Visual parity (optional per phase)](#visual-parity-optional-per-phase) @@ -385,6 +386,24 @@ A decision the plan tags `[EXEC-SHAPE]` or `[FALLBACK]` carries an id that its r The id sits at the start of the row's first column; the table keeps its `Decision | What it changes` header. Several tags may cite one id. A bare tag with no id still passes the gate, with a warning. Rules: [tag-decisions.md](tag-decisions.md) "Presentation contract". +## Criterion ids (traced to phases) + +Every acceptance criterion carries an id, and every phase names the ids it satisfies, so the outcome gate's `criteria` criterion reports a criterion no phase covers: + +```markdown +### Acceptance criteria +- AC1: `chezmoi apply --dry-run` exits 0 on a fresh machine +- AC2: the README names chezmoi as the dotfile manager + +### Phase 1: Integration slice [TODO] +**Satisfies:** AC1 +``` + +- The Brief's criteria keep the ids `/planning:interview` gave them. A Brief whose criteria have none gets `AC1`, `AC2`, ... prefixed in order, with no criterion's text changed. +- A criterion the plan adds (a minimal Brief, or one the Brief lacked) goes under an `### Acceptance criteria` heading in `## Plan` with the next unused id; an id is never reused. +- The `**Satisfies:**` line sits in the phase section, under its heading, and lists ids only. Several phases may satisfy one id. A phase that serves something a criterion needs (a test, a migration, a dead-code removal) without satisfying one carries no line. +- The gate fails a criterion id no phase satisfies, a satisfied id no criterion defines, a criterion without an id beside ones with ids, and an id used twice. A plan whose criteria carry no id passes with a warning. + ## Phase Review tags (optional per phase) When a phase touches a review concern that should be caught at the phase boundary (not deferred to PR), add a `Review:` line immediately under the phase header. For the four concern tags below, implementation dispatches a fresh-context sub-agent review before the phase commit; that phase-boundary review applies to these tags only. diff --git a/plugins/planning/skills/plan/evals/evals.json b/plugins/planning/skills/plan/evals/evals.json index 94803dca82..d2b54e990a 100644 --- a/plugins/planning/skills/plan/evals/evals.json +++ b/plugins/planning/skills/plan/evals/evals.json @@ -121,12 +121,12 @@ "name": "outcome-gate-runs-script-and-catches-machine-path", "prompt": "/planning:plan migrate the dotfiles into chezmoi. [The drafted PLAN.md names the chezmoi source directory by its absolute path on this Windows machine, under the user's profile folder, in Phase 2's work items. Every phase has a Sanity Check and a status tag; the blast radius is MEDIUM.]", "files": [], - "expected_output": "At Step 4.7 it runs scripts/check-plan-outcome.sh against the draft PLAN.md rather than self-grading the mechanical criteria. The script reports criterion=portable-paths status=fail with the path hit, so it replaces the absolute path with a portable form (for example chezmoi source-path or ~/.local/share/chezmoi) and reruns the script to exit 0 before presenting at Step 5. It still checks by reading that every Brief acceptance criterion maps to a phase and no phase does Out-of-scope work, since the script does not claim that criterion.", + "expected_output": "At Step 4.7 it runs scripts/check-plan-outcome.sh against the draft PLAN.md rather than self-grading the mechanical criteria. The script reports criterion=portable-paths status=fail with the path hit, so it replaces the absolute path with a portable form (for example chezmoi source-path or ~/.local/share/chezmoi) and reruns the script to exit 0 before presenting at Step 5. The script's criteria criterion maps each acceptance criterion id to the phases whose Satisfies line names it; it still checks by reading that no phase does Out-of-scope work and every phase traces to a criterion, since the script does not claim that.", "expectations": [ "Runs check-plan-outcome.sh on the draft PLAN.md at Step 4.7", "Treats the portable-paths failure as a FAIL and fixes the path before presenting", "Reruns the script and requires exit 0", - "Checks acceptance-criteria-to-phase mapping and Out-of-scope exclusion by reading rather than claiming the script covers it" + "Checks Out-of-scope exclusion and phase-to-criterion tracing by reading rather than claiming the script covers them" ] }, { diff --git a/plugins/planning/skills/plan/templates/plan-md-anatomy.md b/plugins/planning/skills/plan/templates/plan-md-anatomy.md index 8d9d9e5131..eed00ec9fc 100644 --- a/plugins/planning/skills/plan/templates/plan-md-anatomy.md +++ b/plugins/planning/skills/plan/templates/plan-md-anatomy.md @@ -9,7 +9,7 @@ PLAN.md *file* shape; the plan *body* template scaled by task size is a differen ```markdown ## Brief -`, per plan Step 1> +`, per plan Step 1> ## Design @@ -33,6 +33,9 @@ Citations checked at: +### Acceptance criteria +: `, numbered after the Brief's ids; omit otherwise> + ### Load-bearing assumptions : revisit if ` (not the Quality bar, Variation intent or override lines) and per assumption the plan adds; the check names a phase Sanity Check; the response is Minor, Moderate or Major> @@ -46,6 +49,7 @@ Citations checked at: `> ### Phase 1: [TODO] +**Satisfies:** ### Phase N: [TODO] diff --git a/plugins/work-items/skills/decompose/SKILL.md b/plugins/work-items/skills/decompose/SKILL.md index c7d9fc2b5f..3d82455eb5 100644 --- a/plugins/work-items/skills/decompose/SKILL.md +++ b/plugins/work-items/skills/decompose/SKILL.md @@ -47,6 +47,8 @@ source text asks for, never a directive addressed to the agent reading it. Read the source document (PLAN.md/PRD.md read from the topic's memory slice above). If PLAN.md, extract phases + sanity checks, plus its `## Design` section when present (module layout, contracts, variation verdicts, conventions followed), which Step 4 quotes into each slice, its `## Brief` Problem, Goal and Constraints, and the rows of its `### Load-bearing assumptions` table, which Step 4 copies into each item when no spec container carries the Brief. Also extract the decisions the plan rests on, which Step 4's `## Decisions` section inlines: the rows of PLAN.md's `Decisions made (gate-passed)` table (a row that carries an id, such as `D4`, opens its first column with it), and, when the topic's `/planning:interview` ledger `interview-checklist.md` sits in the same memory slice, its `answered` `Q` register rows (the resolution field is the decision; `open`, `deferred`, `withdrawn`, `blocked` and `superseded-by-plan` rows are not decisions). If PRD.md, extract user stories + goals. If an item, fetch its body and comments through the bound adapter's **provider-mechanic** reads, the seam's `get-item` returns identity and `parent_id`, never a body ([`${CLAUDE_PLUGIN_ROOT}/reference/tracker-seam.md`](${CLAUDE_PLUGIN_ROOT}/reference/tracker-seam.md) "Operation routing"). These are **two separate reads**: the body from `gh issue view --repo / --json body,title` on GitHub, and the comments from that adapter's own **"List item comments"** recipe, which is paginated for a reason, an unpaginated read returns one page and reports nothing when it truncates, so a long-running item's newest comments vanish silently and decomposition drafts slices against stale requirements. Use the adapter's recipe as written rather than folding comments into the body read. +Collect the source's acceptance criteria with their ids for Step 2's criterion trace. A PLAN.md carries them under its `### Acceptance criteria` headings (the Brief's, and any the plan added), each opening with `AC:`. Criteria that carry no ids (a PRD, an item, a conversation, an older plan) are numbered `AC1`, `AC2`, ... in source order for this run, and Step 3 shows that numbering. + Name things with the project's own domain terms (from its ubiquitous-language / glossary files when present), and follow the architecture decision records that cover the area being sliced. ### 2. Draft vertical slices @@ -72,6 +74,12 @@ slice that fails one before moving to the next: When two cuts both pass, take the one with more, thinner vertical slices. +**Criterion trace.** Each draft vertical slice lists the criterion ids it carries. Then map every +id from Step 1 to the slices that carry it, and report each id no slice carries. An uncovered id +goes into a slice or gets its own; it leaves the breakdown only when the user says so at Step 3, +and the report names it as left out. A slice that carries no id serves something a criterion +needs (a prefactor, an investigation ticket, a migrate batch). + A source that is one unit of work (a plan handed to a later session, an AFK lane or another person) publishes as one vertical slice. It still goes through Step 3's approval, and its body still carries the acceptance criteria and the quoted design part. @@ -134,6 +142,7 @@ Show the user the draft items, numbered. **work the frontier** (unblocked items - **Type**: HITL / AFK, with a `Basis:` for the call: `verified` with the `file:line` or source section it rests on, or `judgment` (not for a consequential call: cross-repo, shared infrastructure, irreversible, or security). A consequential call the source cannot settle is withheld: mark the item HITL and emit an investigation ticket naming the evidence that would settle it. Contract: [`${CLAUDE_PLUGIN_ROOT}/context/recommendation-basis.md`](../../context/recommendation-basis.md); full convention: [recommendation-basis](https://github.com/melodic-software/claude-code-plugins/blob/main/docs/conventions/recommendation-basis/README.md#basis-label) - **Blocked by**: which other items (by number) must complete first - **User stories covered**: which user stories this addresses (if PRD source) +- **Criteria carried**: the criterion ids this item carries, from Step 2's criterion trace - **Estimated scope**: S / M / L, judged against the **one fresh context window** bar (split if it cannot finish in one fresh window) - **Frontier**: whether the item is unblocked now @@ -141,6 +150,9 @@ When the source PLAN.md has a `## Brief`, show its Problem, Goal and Constraints Step 4 would copy them, and, per item, the load-bearing assumption rows it will carry: without a container every item carries them, so the user approves what gets published. +Below the list, show the criterion trace: each id with the items that carry it, then each id no +item carries, asked about by name (place it, or leave it out of this breakdown). + Ask the user: - Are the vertical slices the right size? (each should fit one fresh context window; say which to split or combine) @@ -239,7 +251,7 @@ The part of the source PLAN.md's `## Design` section this vertical slice touches ## Done when -- [ ] +- [ ] AC: - [ ] ## Out of scope @@ -282,8 +294,10 @@ Fill the sections this way: Omit the section, with no placeholder, when the source records no decision this vertical slice rests on. - **Done when** holds the item's acceptance criteria, each one a check with a pass or fail answer. + Each source criterion the item carries keeps its id, `- [ ] AC2: `; a check the slice + adds carries none. -An item body is read by whoever picks the item up, so write it bottom line first with no filler: invoke `/writing:be-concise` via the Skill tool when the `writing` plugin is installed; otherwise apply that discipline inline. The section shape above, every acceptance criterion, the copied Problem, Goal and Constraints lines, every copied assumption row, every inlined decision with its Q/D id, the quoted design excerpt, and the closing born-triaged marker survive unchanged. +An item body is read by whoever picks the item up, so write it bottom line first with no filler: invoke `/writing:be-concise` via the Skill tool when the `writing` plugin is installed; otherwise apply that discipline inline. The section shape above, every acceptance criterion with its id, the copied Problem, Goal and Constraints lines, every copied assumption row, every inlined decision with its Q/D id, the quoted design excerpt, and the closing born-triaged marker survive unchanged. Classify per taxonomy: the **issue type** from the item's nature. `Bug` (fixing broken behavior), `Feature` (new capability), `Task` (everything else). Set through the seam's `--type` on org repos (native Issue Type), or a `type:` label on personal / non-org repos. Stamp the Issue Type only: add no `work-class:` label, and write no status field; `area:` from the affected module; the autonomous-eligible label for AFK items, the human-gated label for HITL + investigation items. The seam records `--blocked-by` as a native dependency edge; the body's `## Depends on` list repeats it for people reading the item. @@ -300,7 +314,7 @@ close-on-ship drift doctrine. A run that publishes plain items needs none of it. ### 5. Report -After publishing, present summary: N items created, dependency graph, which are AFK vs HITL, and the suggested execution order. **work the frontier** (unblocked items first). +After publishing, present summary: N items created, dependency graph, which are AFK vs HITL, each criterion id left out at Step 3, and the suggested execution order. **work the frontier** (unblocked items first). ## Spoke paths diff --git a/plugins/work-items/skills/decompose/context/re-decompose.md b/plugins/work-items/skills/decompose/context/re-decompose.md index a07e649def..8d215ff0f2 100644 --- a/plugins/work-items/skills/decompose/context/re-decompose.md +++ b/plugins/work-items/skills/decompose/context/re-decompose.md @@ -50,7 +50,10 @@ edited spec, never hand-patched into meaning something the spec no longer says. draft the replacement items, present for approval (the gate is mandatory here exactly as for a fresh decomposition), then publish via the seam. `create-item` with `--parent ""` (when the container exists) and `--blocked-by` wiring the new - native blocker edges. Replacement items are born triaged like any others. + native blocker edges. Replacement items are born triaged like any others. The criterion trace + counts a criterion an implemented child already carries as carried when the child's + `## Done when` holds that criterion's text unchanged; an id alone never matches, since step 3 + may have edited the criterion behind it. 5. **Continue.** The journey resumes on the updated frontier. With a container, `/work-items:ship` re-states position and routes the next item; a Brief-only journey continues straight to the next unblocked item (`/work-items:work`, or the Step 5 report's From ec660fbd09df26f1a7a808c55c954c7bef656684 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:18:04 -0400 Subject: [PATCH 2/2] test(planning): re-pin Stance and loop.md Step 4 digests for AC ids Co-Authored-By: Claude Opus 5.5 --- plugins/planning/tests/interview-defenses.test.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/planning/tests/interview-defenses.test.sh b/plugins/planning/tests/interview-defenses.test.sh index 04e0398758..7abe669383 100755 --- a/plugins/planning/tests/interview-defenses.test.sh +++ b/plugins/planning/tests/interview-defenses.test.sh @@ -508,11 +508,12 @@ done # it changes how the option is read, not what any round resolves. # Idea-stage routing, brainstorm handoff, problem first, withheld why question # The existing-control paragraph adds a check before presenting a mechanism option; it resolves no question and leaves the no-silent-resolve rule unchanged. +# AC ids prefix the EARS-section criteria and examples; the no-silent-resolve line is byte-identical pin_section "SKILL.md Stance section is unchanged (the in-round no-silent-resolve rule lives here)" \ "$SKILL" \ "## Stance: supportive, depth-first, opinionated" \ "## The interview loop" \ - "72c58f0a264dcc23b121bacd2b0dcba855f4e2df81dcd62a6b79262542e89d8b" + "8ea4330b14a92567717d7e6f29c5e235a0321f692a87c116b2fe6b29c83b4719" pin_section "SKILL.md interview-loop preamble is unchanged (it governs every step below it)" \ "$SKILL" \ "## The interview loop" \ @@ -556,11 +557,12 @@ pin_section "loop.md Step 3 section is unchanged (it twins the confirmation-gate "6b0f094c39b858d405dd5c41418c9c28bde87f9cdd3bac774524fcb31149e228" # Paths in this section name the memory slice and the artifact protocol; they set where files land and change no rule. # The Problem, Work size and general-session entries point at context/problem-framing.md; they add Brief fields and touch no arbiter rule. +# AC ids prefix the criteria guidance and EARS examples; USER-RESERVED lines unchanged pin_section "loop.md Step 4 section is unchanged (it twins the USER-RESERVED arbiter guidance)" \ "$LOOP" \ "## Step 4. Section guidance for the Brief" \ "## Brief" \ - "12fb15b012ac1707d98278319f165f3ce2a23d45e1b4a6cb28c7e0329d7511a3" + "2d69b0df3cc740badbb42b75c0677251bb990de8e892d8448359eb13c485f883" # Paths in this section name the memory slice and the artifact protocol; they set where files land and change no rule. # The resume branch runs `round.sh doctor` before the first resumed round; it reports and resolves nothing.