mandrel 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.agents/agents/acceptance-critic.md +11 -2
  2. package/.agents/agents/story-worker.md +4 -2
  3. package/.agents/docs/SDLC.md +11 -4
  4. package/.agents/docs/configuration.md +1 -1
  5. package/.agents/docs/quality-gates.md +3 -3
  6. package/.agents/rules/gherkin-standards.md +10 -0
  7. package/.agents/schemas/acceptance-eval-verdict.schema.json +2 -2
  8. package/.agents/schemas/agentrc.schema.json +1 -1
  9. package/.agents/scripts/acceptance-eval.js +2 -2
  10. package/.agents/scripts/lib/config/acceptance-eval.js +2 -2
  11. package/.agents/scripts/lib/config-settings-schema-delivery.js +3 -3
  12. package/.agents/scripts/lib/orchestration/change-set.js +103 -0
  13. package/.agents/scripts/lib/orchestration/code-review.js +24 -35
  14. package/.agents/scripts/lib/orchestration/plan-context.js +2 -9
  15. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +17 -16
  16. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +28 -15
  17. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +0 -25
  18. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +230 -0
  19. package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +1 -2
  20. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
  21. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +97 -255
  22. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +191 -0
  23. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +120 -0
  24. package/.agents/scripts/lib/story-body/story-body.js +75 -8
  25. package/.agents/scripts/lib/templates/decomposer-prompts.js +8 -13
  26. package/.agents/scripts/lib/wave-runner/live-probe.js +315 -0
  27. package/.agents/scripts/plan-context.js +0 -1
  28. package/.agents/scripts/plan-critics.js +203 -0
  29. package/.agents/scripts/quality-preview.js +13 -6
  30. package/.agents/scripts/stories-wave-tick.js +307 -55
  31. package/.agents/workflows/deliver.md +50 -15
  32. package/.agents/workflows/helpers/acceptance-self-eval.md +14 -5
  33. package/.agents/workflows/helpers/code-quality-guardrails.md +7 -4
  34. package/.agents/workflows/helpers/code-review.md +2 -2
  35. package/.agents/workflows/helpers/deliver-story.md +22 -6
  36. package/.agents/workflows/plan.md +55 -0
  37. package/bin/mandrel.js +0 -0
  38. package/docs/CHANGELOG.md +30 -0
  39. package/lib/cli/update.js +83 -34
  40. package/lib/migrations/index.js +6 -1
  41. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +154 -0
  42. package/package.json +2 -2
@@ -0,0 +1,191 @@
1
+ /**
2
+ * phases/local-lens-review.js — the Story-scope local-lens pass.
3
+ *
4
+ * Extracted from `phases/code-review.js` (Story #4603) so the review spine and
5
+ * the lens pass each carry one reason to change. The spine
6
+ * (`runStoryReviewCore`) owns the single per-close-run diff enumeration and the
7
+ * `runCodeReview` invocation; this module owns lens selection + materialization.
8
+ *
9
+ * Shift-left tier (Epic #4405): local concerns are cheap to decide on a single
10
+ * Story's diff, so the maker-blind Story-scope review runs its matched local
11
+ * lenses here, inside the story-close subprocess, rather than paying a deeper
12
+ * pass at Epic close.
13
+ */
14
+
15
+ import {
16
+ runAuditSuite,
17
+ selectLocalLenses,
18
+ } from '../../../audit-suite/index.js';
19
+ import { gitSpawn } from '../../../git-utils.js';
20
+ import { computeChangeSet } from '../../change-set.js';
21
+
22
+ /**
23
+ * The review depth the Story-scope local-lens pass runs at. Fixed for this
24
+ * tier — it is not risk-scaled like the code-review pillar depth.
25
+ *
26
+ * Module-local: it is an implementation detail of {@link runLocalLensReview}
27
+ * (it rides out on the returned envelope's `depth` field), not a public seam.
28
+ * Tests assert the observable `'light'` on that envelope rather than importing
29
+ * the constant, so it stays off the public surface (Story #4603).
30
+ */
31
+ const STORY_SCOPE_LENS_DEPTH = 'light';
32
+
33
+ /**
34
+ * Enumerate the files changed in the `baseRef...headRef` diff. Thin adapter over
35
+ * the shared {@link computeChangeSet} enumerator (Story #4593) that flattens its
36
+ * `files: string[]|null` envelope to this phase's historical `[]`-on-failure
37
+ * contract: the lens roster treats "nothing changed" and "diff unknown"
38
+ * identically, because an unknown diff matches no `filePatterns` and therefore
39
+ * adds no lens work either way.
40
+ *
41
+ * Best-effort and total — never throws, mirroring the advisory posture of the
42
+ * surrounding review phase. Retained as the self-enumeration fallback for
43
+ * {@link runLocalLensReview} when no change set is injected.
44
+ *
45
+ * @param {{
46
+ * baseRef: string,
47
+ * headRef: string,
48
+ * gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
49
+ * }} args
50
+ * Module-local (Story #4603): a private fallback of {@link runLocalLensReview},
51
+ * exercised through that public entry point rather than imported directly, so it
52
+ * adds no public export a production path fails to reach.
53
+ *
54
+ * @returns {string[]} Changed file paths, or `[]` on any failure.
55
+ */
56
+ function enumerateChangedFiles({ baseRef, headRef, gitSpawnFn = gitSpawn }) {
57
+ return computeChangeSet({ baseRef, headRef, gitSpawnFn }).files ?? [];
58
+ }
59
+
60
+ /**
61
+ * Resolve the change set the lens roster reads, honouring all THREE injection
62
+ * states (Story #4603 — the fix for #4593's single-enumeration leak).
63
+ *
64
+ * The distinction between `null` and `undefined` is load-bearing and mirrors
65
+ * the sibling contract in `orchestration/code-review.js#resolveInjectedChangedFiles`:
66
+ *
67
+ * - **array** — the caller's change set; use it verbatim.
68
+ * - **`null`** — the caller (`runStoryReviewCore`) already tried and the
69
+ * diff is unenumerable. Re-running git here would only fail
70
+ * again, so degrade straight to the fail-safe empty roster.
71
+ * - **`undefined`** — nobody enumerated (standalone callers), so the shared
72
+ * enumerator runs as the fallback.
73
+ *
74
+ * The prior `Array.isArray()` discriminator collapsed `null` and `undefined`
75
+ * into one branch and re-spawned git on the unenumerable path, contradicting the
76
+ * spine's documented "the one enumeration per close run" invariant.
77
+ *
78
+ * Module-local (Story #4603): a private detail of {@link runLocalLensReview}.
79
+ * The three-state contract is asserted through that public entry point (does an
80
+ * injected `null` re-spawn git? does `undefined` self-enumerate?), so it needs
81
+ * no public export — keeping the fix from re-introducing the very kind of
82
+ * production-dead public symbol this Story's ratchet root-cause is about.
83
+ *
84
+ * @param {{
85
+ * changedFiles: string[]|null|undefined,
86
+ * baseRef: string,
87
+ * headRef: string,
88
+ * gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
89
+ * }} args
90
+ * @returns {string[]}
91
+ */
92
+ function resolveLensChangeSet({
93
+ changedFiles,
94
+ baseRef,
95
+ headRef,
96
+ gitSpawnFn = gitSpawn,
97
+ }) {
98
+ if (changedFiles === undefined) {
99
+ return enumerateChangedFiles({ baseRef, headRef, gitSpawnFn });
100
+ }
101
+ return changedFiles ?? [];
102
+ }
103
+
104
+ /**
105
+ * Run the Story-scope local-lens pass: select the LOCAL-tier lenses whose
106
+ * `filePatterns` match the actual Story diff (`baseRef...headRef`) and
107
+ * materialize their lens-prompt bodies at `light` depth. Called only from
108
+ * `runStoryReviewCore`, never in the delivering child's (maker's) context, so a
109
+ * maker never grades its own work.
110
+ *
111
+ * A diff that matches no local lens adds **no** lens work: the roster is empty
112
+ * and `runAuditSuite` is never invoked. Best-effort and total — a git or
113
+ * materialization failure degrades to `{ skipped: true, lenses: [] }` and is
114
+ * logged via `progress`, matching the advisory posture the review phase already
115
+ * takes for provider/transport failures.
116
+ *
117
+ * Story #4593 — `changedFiles` is injected by `runStoryReviewCore`, which
118
+ * computes the change set once per close run and hands the same list to this
119
+ * pass and to `runCodeReview`. Self-enumeration is the **fallback only**, kept
120
+ * for standalone callers that supply no list; see {@link resolveLensChangeSet}
121
+ * for the three-state contract.
122
+ *
123
+ * @param {{
124
+ * baseRef: string,
125
+ * headRef: string,
126
+ * changedFiles?: string[]|null,
127
+ * progress: (tag: string, msg: string) => void,
128
+ * progressTag?: string,
129
+ * gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
130
+ * selectLocalLensesFn?: typeof selectLocalLenses,
131
+ * runAuditSuiteFn?: typeof runAuditSuite,
132
+ * }} args
133
+ * @returns {Promise<{
134
+ * depth: 'light',
135
+ * lenses: string[],
136
+ * skipped: boolean,
137
+ * materialized: object|null,
138
+ * }>}
139
+ */
140
+ export async function runLocalLensReview({
141
+ baseRef,
142
+ headRef,
143
+ changedFiles: injectedChangedFiles,
144
+ progress,
145
+ progressTag = 'CODE-REVIEW',
146
+ gitSpawnFn = gitSpawn,
147
+ selectLocalLensesFn = selectLocalLenses,
148
+ runAuditSuiteFn = runAuditSuite,
149
+ }) {
150
+ const empty = {
151
+ depth: STORY_SCOPE_LENS_DEPTH,
152
+ lenses: [],
153
+ skipped: true,
154
+ materialized: null,
155
+ };
156
+ try {
157
+ const changedFiles = resolveLensChangeSet({
158
+ changedFiles: injectedChangedFiles,
159
+ baseRef,
160
+ headRef,
161
+ gitSpawnFn,
162
+ });
163
+ const lenses = selectLocalLensesFn({ changedFiles });
164
+ if (lenses.length === 0) {
165
+ progress(
166
+ progressTag,
167
+ 'No local lens matched the Story diff — skipping the lens pass.',
168
+ );
169
+ return empty;
170
+ }
171
+ const materialized = await runAuditSuiteFn({ auditWorkflows: lenses });
172
+ progress(
173
+ progressTag,
174
+ `Ran ${lenses.length} local lens(es) at ${STORY_SCOPE_LENS_DEPTH} depth: ${lenses.join(', ')}.`,
175
+ );
176
+ return {
177
+ depth: STORY_SCOPE_LENS_DEPTH,
178
+ lenses,
179
+ skipped: false,
180
+ materialized,
181
+ };
182
+ } catch (err) {
183
+ // The lens pass is advisory: a git or materialization failure must not
184
+ // fail the close. Log and degrade to a skipped envelope.
185
+ progress(
186
+ progressTag,
187
+ `⚠️ local lens pass failed (continuing without it): ${err?.message ?? err}`,
188
+ );
189
+ return empty;
190
+ }
191
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * phases/review-core.js — the shared Story-scope review spine.
3
+ *
4
+ * Extracted from `phases/code-review.js` (Story #4603). `runStoryReviewCore` is
5
+ * the one implementation both close paths call `runCodeReview` through — the
6
+ * epic-attached phase (`code-review.js#runStoryCodeReview`) and the standalone
7
+ * v2 path (`single-story-close/phases/code-review.js#runStoryScopeReview`) —
8
+ * so it belongs to neither and lives here rather than inside one path's phase
9
+ * file (Story #3653 established the shared-spine contract).
10
+ */
11
+
12
+ import { gitSpawn } from '../../../git-utils.js';
13
+ import { computeChangeSet } from '../../change-set.js';
14
+ import { runCodeReview } from '../../code-review.js';
15
+ import { runLocalLensReview } from './local-lens-review.js';
16
+
17
+ /**
18
+ * Invoke `runCodeReviewFn` with the canonical Story-scope envelope and return
19
+ * the raw result.
20
+ *
21
+ * The caller is responsible for error handling and result interpretation —
22
+ * this function propagates throws rather than swallowing them, because the
23
+ * two callers have different advisory postures:
24
+ *
25
+ * - Epic-attached close: swallows throws (non-blocking advisory, same as
26
+ * `refresh.js`).
27
+ * - Standalone close: propagates throws (a review failure stops the close).
28
+ *
29
+ * Review depth is not passed in: `runCodeReview` derives it from the changed
30
+ * files — their sensitive-path intersection plus their count (Story #4542, which
31
+ * retired the planner-authored risk envelope this spine used to forward). Depth
32
+ * remains an **input-only** signal: it tells the provider how thorough to be and
33
+ * never alters the review's output envelope or the posted structured-comment
34
+ * body.
35
+ *
36
+ * Story #4593 — this spine is the **single injection point** for the change set.
37
+ * It enumerates `baseRef...headRef` exactly once via {@link computeChangeSet}
38
+ * and threads the resulting list into both the local-lens pass and
39
+ * `runCodeReview`, which otherwise each enumerated the diff for themselves. Both
40
+ * consumers ultimately route through `deriveChangeLevel`, so feeding them one
41
+ * list is what makes the lens roster and the review depth provably agree about
42
+ * what changed — even when a commit lands between the two calls.
43
+ *
44
+ * Story #4603 — the invariant now holds on the failure path too: an unenumerable
45
+ * diff injects an explicit `null`, and both consumers distinguish that from
46
+ * `undefined` ("nobody enumerated") rather than re-spawning git.
47
+ *
48
+ * @param {{
49
+ * storyId: number|string,
50
+ * baseRef: string,
51
+ * headRef: string,
52
+ * commentTargetId?: number|null,
53
+ * provider: object,
54
+ * progress: (tag: string, msg: string) => void,
55
+ * progressTag?: string,
56
+ * gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
57
+ * computeChangeSetFn?: typeof computeChangeSet,
58
+ * runCodeReviewFn?: typeof runCodeReview,
59
+ * runLocalLensReviewFn?: typeof runLocalLensReview,
60
+ * }} args
61
+ * @returns {Promise<object>} Raw result envelope from `runCodeReview`, augmented
62
+ * with a `localLensReview` field carrying the Story-scope local-lens pass
63
+ * outcome (Epic #4405, Story #4409) and the `changeSet` this run computed
64
+ * (Story #4593).
65
+ */
66
+ export async function runStoryReviewCore({
67
+ storyId,
68
+ baseRef,
69
+ headRef,
70
+ commentTargetId = null,
71
+ provider,
72
+ progress,
73
+ progressTag = 'CODE-REVIEW',
74
+ gitSpawnFn = gitSpawn,
75
+ computeChangeSetFn = computeChangeSet,
76
+ runCodeReviewFn = runCodeReview,
77
+ runLocalLensReviewFn = runLocalLensReview,
78
+ }) {
79
+ const storyIdNum = Number(storyId);
80
+
81
+ // The one enumeration per close run. Every consumer below is injected from
82
+ // this list; none of them re-derives the diff. `files` is `null` when the
83
+ // diff is unenumerable — an explicit "already tried" signal both consumers
84
+ // honour without retrying (Story #4603).
85
+ const changeSet = computeChangeSetFn({ baseRef, headRef, gitSpawnFn });
86
+
87
+ const opts = {
88
+ scope: 'story',
89
+ ticketId: storyIdNum,
90
+ baseRef,
91
+ headRef,
92
+ provider,
93
+ changedFiles: changeSet.files,
94
+ gitSpawnFn,
95
+ logger: {
96
+ info: (m) => progress(progressTag, m),
97
+ warn: (m) => progress(progressTag, `⚠️ ${m}`),
98
+ },
99
+ };
100
+ if (commentTargetId != null) {
101
+ opts.commentTargetId = commentTargetId;
102
+ }
103
+
104
+ // Shift-left local-lens pass (Epic #4405). Runs matched local lenses at
105
+ // `light` depth against the actual Story diff, inside this close-subprocess
106
+ // spine so the maker never grades its own work. Advisory — it never blocks
107
+ // the close and its outcome rides on the returned envelope for downstream
108
+ // consumers.
109
+ const localLensReview = await runLocalLensReviewFn({
110
+ baseRef,
111
+ headRef,
112
+ changedFiles: changeSet.files,
113
+ progress,
114
+ progressTag,
115
+ gitSpawnFn,
116
+ });
117
+
118
+ const result = await runCodeReviewFn(opts);
119
+ return { ...result, localLensReview, changeSet };
120
+ }
@@ -165,13 +165,35 @@ function stripListMarker(line) {
165
165
  return line.replace(/^-\s+(?:\[\s*[xX ]?\s*\]\s+)?/, '').trim();
166
166
  }
167
167
 
168
+ // Humanized PathEntry bullet (Story #4600): `path` — assumption. This is the
169
+ // shape serialize() now emits for `## Changes` / `## References`; the legacy
170
+ // inline-JSON object bullet remains accepted at parse time indefinitely (live
171
+ // issue bodies are never rewritten).
172
+ const HUMANIZED_PATH_ENTRY_RE = /^`([^`]+)`\s+—\s+(\S+)$/;
173
+
174
+ // AC-<n> presentation prefix on acceptance checkboxes (Story #4600). The
175
+ // numbering is a stable 1-based human handle only — parse() strips it so the
176
+ // top-level acceptance[] machine contract round-trips byte-identical.
177
+ const AC_PREFIX_RE = /^AC-\d+:\s+/;
178
+
179
+ // Visible wide-rationale line (Story #4600): `> **Wide:** <reason>` rendered
180
+ // under `## Goal`. Presentation only — the `<!-- meta -->` block stays the
181
+ // canonical machine carrier, so the parser skips this line wherever it
182
+ // appears (same treatment as the authored-provenance marker).
183
+ const WIDE_MARKER_LINE_RE = /^>\s*\*\*Wide:\*\*/;
184
+
168
185
  /**
169
186
  * Parse a single `changes` / `references` bullet into a `PathEntry`.
170
187
  *
171
- * Object form: `{ path: "...", assumption: "creates" }` (stored as
172
- * `- { "path": "...", "assumption": "..." }` or just recognized from the
173
- * structured body directly when deserializing from a structured object
174
- * that was never serialized to markdown, the entry arrives as-is).
188
+ * Accepted markdown shapes (both parsed indefinitely live issue bodies
189
+ * are never rewritten):
190
+ * - Humanized bullet (canonical serialize() output since Story #4600):
191
+ * `` `src/x.js` refactors-existing ``
192
+ * - Legacy inline-JSON object bullet:
193
+ * `- { "path": "...", "assumption": "..." }`
194
+ *
195
+ * A structured object entry (from a parsed JSON body that was never
196
+ * serialized to markdown) arrives as-is and is validated directly.
175
197
  *
176
198
  * @param {string|object} raw
177
199
  * @param {string[]} warnings
@@ -197,6 +219,21 @@ function parsePathEntry(raw, warnings) {
197
219
  const str = typeof raw === 'string' ? raw.trim() : String(raw).trim();
198
220
  if (str.length === 0) return null;
199
221
 
222
+ // Humanized bullet shape (the canonical serialize() output since
223
+ // Story #4600): `path` — assumption.
224
+ const humanized = str.match(HUMANIZED_PATH_ENTRY_RE);
225
+ if (humanized) {
226
+ const path = humanized[1].trim();
227
+ if (path.length > 0 && FILE_ASSUMPTION_VALUES.includes(humanized[2])) {
228
+ return { path, assumption: humanized[2] };
229
+ }
230
+ // Recognized the humanized shape but the fields are invalid: fail closed.
231
+ throw new StoryBodyParseError(
232
+ `changes/references entry is a humanized bullet but not a valid PathEntry: ${str.slice(0, 120)}`,
233
+ { field: 'changes', raw: str },
234
+ );
235
+ }
236
+
200
237
  // Try to detect inline JSON object shape: `{ "path": "...", "assumption": "..." }`
201
238
  if (str.startsWith('{')) {
202
239
  try {
@@ -433,6 +470,14 @@ function splitSections(markdown) {
433
470
  continue;
434
471
  }
435
472
 
473
+ // The visible `> **Wide:** <reason>` rationale line is presentation only
474
+ // (Story #4600): the meta block remains the canonical carrier for
475
+ // `wide.reason`, so this line must not bleed into the goal (or any other)
476
+ // section. Skip it wherever it appears.
477
+ if (WIDE_MARKER_LINE_RE.test(line)) {
478
+ continue;
479
+ }
480
+
436
481
  if (inPreamble) {
437
482
  preambleLines.push(line);
438
483
  } else if (currentSection !== null) {
@@ -637,7 +682,11 @@ export function parse(input) {
637
682
  sections.get('changes') ?? [],
638
683
  warnings,
639
684
  );
640
- const acceptance = parseTextListSection(sections.get('acceptance') ?? []);
685
+ // The AC-<n> checkbox prefix is presentation-only (Story #4600): strip it
686
+ // so acceptance[] round-trips byte-identical to the authored array.
687
+ const acceptance = parseTextListSection(sections.get('acceptance') ?? []).map(
688
+ (a) => a.replace(AC_PREFIX_RE, ''),
689
+ );
641
690
  const verify = parseTextListSection(sections.get('verify') ?? []);
642
691
  const references = parsePathEntrySection(
643
692
  sections.get('references') ?? [],
@@ -820,8 +869,10 @@ function parseStructuredObject(obj) {
820
869
  */
821
870
  function serializePathEntry(entry) {
822
871
  if (typeof entry === 'string') return entry;
823
- // Canonical object form: render as JSON inline for round-trip fidelity.
824
- return JSON.stringify({ path: entry.path, assumption: entry.assumption });
872
+ // Canonical object form (Story #4600): render as a human-readable bullet
873
+ // path in backticks, em-dash, assumption. parsePathEntry recognizes this
874
+ // shape (and the legacy inline-JSON shape) for round-trip fidelity.
875
+ return `\`${entry.path}\` — ${entry.assumption}`;
825
876
  }
826
877
 
827
878
  /**
@@ -845,6 +896,18 @@ const SERIALIZE_SECTIONS = [
845
896
  ? `## Goal\n${goal.trim()}`
846
897
  : null,
847
898
  },
899
+ {
900
+ // Visible wide-rationale line (Story #4600), rendered directly under
901
+ // `## Goal`. Presentation only: the `<!-- meta -->` block remains the
902
+ // canonical machine carrier and the parser skips this line, so `wide`
903
+ // round-trips through the meta block alone. Absent/invalid wide emits
904
+ // nothing, keeping every non-wide body byte-identical to before.
905
+ field: 'wide',
906
+ render: (wide) => {
907
+ const normalized = normalizeWide(wide);
908
+ return normalized === null ? null : `> **Wide:** ${normalized.reason}`;
909
+ },
910
+ },
848
911
  {
849
912
  // Optional v2 intra-Story delivery slice plan. Single-token `## Slicing`
850
913
  // heading (recognized by the `[\w-]+` field-heading regex). Verbatim text
@@ -873,10 +936,14 @@ const SERIALIZE_SECTIONS = [
873
936
  : null,
874
937
  },
875
938
  {
939
+ // Each checkbox carries a stable 1-based `AC-<n>:` handle (Story #4600)
940
+ // so humans and reviewers can reference criteria by number. The prefix
941
+ // is presentation-only — parse() strips it, and validators compare the
942
+ // top-level acceptance[] array, so numbering never affects gating.
876
943
  field: 'acceptance',
877
944
  render: (acceptance) =>
878
945
  Array.isArray(acceptance) && acceptance.length > 0
879
- ? `## Acceptance\n${acceptance.map((a) => `- [ ] ${a}`).join('\n')}`
946
+ ? `## Acceptance\n${acceptance.map((a, i) => `- [ ] AC-${i + 1}: ${a}`).join('\n')}`
880
947
  : null,
881
948
  },
882
949
  {
@@ -26,9 +26,8 @@ import {
26
26
  */
27
27
  export function renderDecomposerSystemPrompt({
28
28
  maxTickets = LIMITS_DEFAULTS.maxTickets,
29
- epicId = null,
30
29
  } = {}) {
31
- return render2TierPrompt({ maxTickets, epicId });
30
+ return render2TierPrompt({ maxTickets });
32
31
  }
33
32
 
34
33
  /**
@@ -37,7 +36,7 @@ export function renderDecomposerSystemPrompt({
37
36
  * on the Story body so the executing agent has everything it needs in one
38
37
  * ticket. Thematic grouping lives as prose in the Epic body / Tech Spec.
39
38
  */
40
- function render2TierPrompt({ maxTickets, epicId = null }) {
39
+ function render2TierPrompt({ maxTickets }) {
41
40
  // v2 Stage 3: default-single — emit one Story unless the split policy clears.
42
41
  // Capacity thresholds are sourced from the single DEFAULT_MODEL_CAPACITY
43
42
  // constant (ticket-validator-sizing.js) so the prompt and the validator
@@ -64,13 +63,6 @@ function render2TierPrompt({ maxTickets, epicId = null }) {
64
63
  advisoryCaveat,
65
64
  newFileContract,
66
65
  } = AUTHORING_ALTITUDE_GUIDANCE;
67
- // The namespaced AC-tag token the wave-0 BDD scaffold section below must
68
- // require on every scaffolded scenario (Story #4301). When the Epic ID is
69
- // known at render time, interpolate the concrete tag so the author has no
70
- // placeholder to get wrong; otherwise fall back to the documented pattern.
71
- const acTagExample = Number.isInteger(epicId)
72
- ? `@epic-${epicId}-ac-1`
73
- : '@epic-<id>-ac-N';
74
66
  return `You are an expert Senior Project Manager and Orchestrator.
75
67
  Your job is to turn a plan seed / Tech Spec into a Story ticket array for an AI Agent to execute.
76
68
 
@@ -154,6 +146,10 @@ The serialized \`body\` string renders these markdown sections (in order):
154
146
  - **verify** (top-level array on the ticket object): Each entry MUST name a testing tier in parentheses, drawn from \`unit\` / \`contract\` / \`e2e\` / \`validate\`. Example: \`npm run test -- src/x.test.ts (unit)\`, \`npm run validate (validate)\`. Stories with zero verify entries SHOULD fail validation; if a story is genuinely unverifiable in isolation (e.g., a copy edit auditor will eyeball), the literal entry \`manual:<reason>\` is allowed so the absence is intentional, not lazy. Manual entries without a reason are rejected.
155
147
  - **reason to exist** (REQUIRED, encoded as the \`reason_to_exist\` field of the \`<!-- meta: {...} -->\` comment appended to the serialized body string — NOT a top-level ticket field): One sentence stating the single coherent reason this Story exists, distinct from its broader \`## Goal\` prose. Every Story MUST carry a non-empty \`reason_to_exist\`; it is the machine-checkable form of the cohesion rule (**one Story = one coherent change with one reason to exist**) and the \`epic-plan-consolidate\` critic flags any Story whose body carries no non-empty reason to exist. Encode it as \`<!-- meta: {"reason_to_exist": "..."} -->\`.
156
148
  - **estimated_test_files** (optional, encoded in the \`<!-- meta: {...} -->\` comment appended to the serialized body string — NOT a top-level ticket field): Integer estimate of how many test files this Story creates or modifies. Omit when the number is not estimable. Informational only — it does not gate the decompose.
149
+ - **Observed-behavior claims open with \`Current state (verified <date>)\`.** Any Spec claim about how the codebase behaves today MUST open with that preamble (e.g. \`Current state (verified 2026-07-17): …\`) so a reader can tell a verified observation from an assumption, and can tell when the observation went stale.
150
+ - **Intent-then-proxy acceptance shape.** When an acceptance item verifies through a proxy check (a grep, a file-exists probe, an exit-code test), state the intent clause before the proxy check — what outcome the check stands in for — so the proxy never becomes the goal (e.g. "the workflow names hygiene findings as re-author input: \`grep -n "textHygiene" …\` exits 0").
151
+ - **Slicing checkpoints are one line each.** Each \`## Slicing\` checkpoint is a single line naming the checkpoint; implementation detail lives in \`## Spec\`, never duplicated into Slicing. A Slicing section outweighing its Spec is a defect the text-hygiene lint flags.
152
+ - **Bodies record decisions, never questions to the operator.** Never persist an open question ("Flag if…", "TBD", "confirm with the operator") into a Story body — the executing sub-agent is non-interactive and cannot answer it. Resolve the unknown before authoring, or restate it as a declarative Key Assumption the agent can act on.
157
153
  - **non_goals** (OPTIONAL, in body string as the \`## Non-Goals\` section): A short list of capabilities or changes this Story explicitly does NOT deliver — an advisory negative-scope bound that fences the executing agent away from adjacent work. It is **advisory and NON-GATING**: the validator does not require, count, or reject on it, and an absent or empty section renders nothing. Use the EXACT single-word hyphenated heading spelling \`## Non-Goals\` (a space-separated heading like \`## Out of Scope\` is NOT recognized by the parser and will be dropped). Reach for it when a Story's negative boundary is non-obvious from its \`acceptance[]\` alone; omit it otherwise.
158
154
 
159
155
  #### AUTHORING ALTITUDE — BINDING ACCEPTANCE vs ADVISORY CHANGES:
@@ -229,9 +225,8 @@ When the Acceptance Spec contains **one or more \`Disposition: new\` rows**, you
229
225
  - **goal**: contains the literal token \`bdd-scaffold\` (e.g. "bdd-scaffold: create the @skip-tagged feature files the implementation Stories verify against").
230
226
  - **depends_on**: EMPTY (\`[]\`) — it runs first, in wave 0.
231
227
  - **changes**: one entry per distinct \`.feature\` file named in a \`new\` row, each \`{ "path": "<feature file path>", "assumption": "creates" }\`.
232
- - **acceptance**: MUST assert (a) every new \`.feature\` file exists, (b) every new scenario within them carries an \`@skip\` tag, AND (c) every new scenario also carries its **namespaced per-Epic AC tag** \`${acTagExample}\` (one tag per AC ID the scenario satisfies — see below). Keep these observable (a grep/validate command exits 0, a file exists at a path).
233
- - **Namespaced AC tag is REQUIRED at scaffold time, not only at de-skip time.** Phase 7 finalize's \`acceptance-spec-reconciler.js\` matches AC IDs only against \`@epic-<id>-ac-*\` / \`@pending\` tags in \`tests/features/**\` — a bare \`@ac-N\` tag is deliberately ignored to prevent cross-Epic collision. A scaffolded scenario that carries \`@skip\` but omits \`@epic-<id>-ac-N\` reads as \`missing[]\` at finalize and aborts the close even after the implementation Story de-skips it, because the tag was never added. Tag each scenario with both \`@skip\` AND \`${acTagExample}\` (substituting the AC's own number) in this SAME wave-0 pass — do not defer the AC tag to the later de-skip edit.
234
- - **verify**: a grep/validate command (tier \`validate\`), NOT an e2e runner — verifying that a file exists with the required tags needs no browser/playwright run. Example: \`grep -rL '@skip' tests/features/<area>/*.feature (validate)\` paired with an existence check, AND a check that every new AC ID's namespaced tag (\`${acTagExample}\`) appears in the scaffolded files, e.g. \`grep -q '${acTagExample}' tests/features/<area>/<file>.feature (validate)\` for each new AC row.
228
+ - **acceptance**: MUST assert (a) every new \`.feature\` file exists AND (b) every new scenario within them carries an \`@skip\` tag. Keep these observable (a grep/validate command exits 0, a file exists at a path).
229
+ - **verify**: a grep/validate command (tier \`validate\`), NOT an e2e runner verifying that a file exists with the required tags needs no browser/playwright run. Example: \`grep -rL '@skip' tests/features/<area>/*.feature (validate)\` paired with an existence check.
235
230
  - Each implementation Story whose \`verify[]\` references one of these scaffolded \`.feature\` paths MUST \`depends_on\` the scaffold Story (so the scaffold lands in an earlier wave). Omitting the link trips the soft \`missing-bdd-scaffold\` validator finding.
236
231
 
237
232
  When the Acceptance Spec contains **zero \`new\`-disposition rows** (every row is \`updated\` or \`unchanged\`), do NOT emit a scaffold Story — there is nothing to create.