mandrel 1.76.0 → 1.77.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 (46) hide show
  1. package/.agents/docs/configuration.md +2 -2
  2. package/.agents/schemas/agentrc.schema.json +1 -1
  3. package/.agents/schemas/dispatch-manifest.json +1 -1
  4. package/.agents/schemas/validation-evidence.schema.json +2 -1
  5. package/.agents/scripts/audit-to-stories.js +43 -1
  6. package/.agents/scripts/epic-deliver-prepare.js +31 -0
  7. package/.agents/scripts/evidence-gate.js +48 -12
  8. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +141 -34
  9. package/.agents/scripts/lib/cli-args.js +6 -0
  10. package/.agents/scripts/lib/close-validation/runner.js +25 -8
  11. package/.agents/scripts/lib/config/temp-paths.js +1 -1
  12. package/.agents/scripts/lib/config/worktree-isolation.js +18 -3
  13. package/.agents/scripts/lib/config-resolver.js +4 -1
  14. package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
  15. package/.agents/scripts/lib/git-branch-lifecycle.js +90 -0
  16. package/.agents/scripts/lib/orchestration/auto-merge-cwd.js +128 -0
  17. package/.agents/scripts/lib/orchestration/column-sync.js +88 -9
  18. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +20 -2
  19. package/.agents/scripts/lib/orchestration/project-meta-cache.js +238 -0
  20. package/.agents/scripts/lib/orchestration/reassert-status-column.js +3 -1
  21. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +25 -2
  22. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +80 -14
  23. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +74 -25
  24. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +10 -1
  25. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +48 -1
  26. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +148 -4
  27. package/.agents/scripts/lib/orchestration/ticketing/transition.js +8 -1
  28. package/.agents/scripts/lib/story-body/story-body.js +76 -7
  29. package/.agents/scripts/lib/story-init/branch-initializer.js +29 -43
  30. package/.agents/scripts/lib/story-init/hierarchy-tracer.js +25 -4
  31. package/.agents/scripts/lib/story-init/task-graph-builder.js +22 -12
  32. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -0
  33. package/.agents/scripts/lib/validation-evidence.js +63 -25
  34. package/.agents/scripts/lib/worktree/node-modules-strategy.js +239 -31
  35. package/.agents/scripts/resync-status-column.js +5 -0
  36. package/.agents/scripts/run-coverage.js +85 -45
  37. package/.agents/scripts/single-story-init.js +22 -29
  38. package/.agents/scripts/story-init.js +38 -63
  39. package/.agents/scripts/story-phase.js +46 -4
  40. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  41. package/.agents/workflows/helpers/acceptance-self-eval.md +27 -0
  42. package/.agents/workflows/helpers/deliver-epic.md +19 -2
  43. package/.agents/workflows/helpers/epic-deliver-story.md +50 -14
  44. package/.agents/workflows/helpers/single-story-deliver.md +12 -0
  45. package/docs/CHANGELOG.md +33 -0
  46. package/package.json +1 -1
@@ -30,6 +30,62 @@
30
30
  * decomposer prompt and authoring SKILL.
31
31
  */
32
32
 
33
+ import { parse as parseStoryBody } from '../story-body/story-body.js';
34
+
35
+ /**
36
+ * Normalize a Story's `body` to the structured object the sizing layers
37
+ * score, mirroring `validateAcFreshness` / `collectStoryAssumptionEntries`
38
+ * (Story #3302) and `resolveStructuredBody` in `task-body-validator.js`.
39
+ *
40
+ * The decomposer emits `body` as the canonical serialized **string**
41
+ * (`decomposer-prompts.js`), but the sizing layers historically read
42
+ * `story.body` only when it was already an object — so on the production
43
+ * string shape `changes` / `wide` fell through to empty and the `hardFiles`
44
+ * / unanchored-constant backstops emitted nothing. A defensive parse here
45
+ * restores parity:
46
+ * - **string body** → parsed via `parseStoryBody`; an unparseable string
47
+ * yields `null` (the gate degrades to "no structured signal", never
48
+ * throws mid-validation).
49
+ * - **object body** → returned verbatim (a caller may pass the
50
+ * pre-serialize shape directly; `parse` round-trips it).
51
+ * - **null / other** → `null`.
52
+ *
53
+ * @param {object} story
54
+ * @returns {object|null}
55
+ */
56
+ function resolveStoryBody(story) {
57
+ const body = story?.body;
58
+ if (typeof body === 'string') {
59
+ if (body.trim().length === 0) return null;
60
+ try {
61
+ return parseStoryBody(body).body;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+ if (body !== null && typeof body === 'object') return body;
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Resolve the acceptance-criteria array for a Story, preferring the
72
+ * authoritative top-level `story.acceptance` (the binding contract the
73
+ * validator already requires every Story to carry inline) over the
74
+ * structured body's `acceptance`. Reading the top-level array makes the
75
+ * acceptance ceiling correct regardless of body shape — a string body whose
76
+ * structured `acceptance` is only reachable after a parse, or an object body
77
+ * (Story #4271). Falls back to `resolveStoryBody(story).acceptance` only when
78
+ * the top-level array is absent.
79
+ *
80
+ * @param {object} story
81
+ * @returns {unknown[]}
82
+ */
83
+ function resolveAcceptance(story) {
84
+ if (Array.isArray(story?.acceptance)) return story.acceptance;
85
+ const body = resolveStoryBody(story);
86
+ return Array.isArray(body?.acceptance) ? body.acceptance : [];
87
+ }
88
+
33
89
  export const DEFAULT_TASK_SIZING = Object.freeze({
34
90
  // Typical-Story warning thresholds (soft — emit advisory findings).
35
91
  // Story #4162 raised `softFiles` 8 → 15: a capability-sized Story routinely
@@ -71,6 +127,39 @@ export const DELIVERABLE_GRANULARITY_GUIDANCE = Object.freeze({
71
127
  '**Single-consumer merge rule.** A Story whose only consumer is one sibling Story should be **merged into that sibling** rather than emitted separately — a single-consumer downstream slice is not its own unit of work.',
72
128
  });
73
129
 
130
+ /**
131
+ * `AUTHORING_ALTITUDE_GUIDANCE` is the **single source of truth** for the
132
+ * binding-vs-advisory authoring altitude (Epic #4131 F8) and the New-File
133
+ * Contract (Story #4272). It is stated ONCE here and consumed by BOTH the
134
+ * decomposer prompt template
135
+ * (`.agents/scripts/lib/templates/decomposer-prompts.js`, which interpolates
136
+ * the strings verbatim into the rendered system prompt) AND the authoring
137
+ * SKILL (`.agents/skills/core/epic-plan-decompose-author/SKILL.md`, whose
138
+ * prose mirrors these sentences). The SKILL cannot import JS, so the
139
+ * `ticket-decomposer` prompt test asserts the canonical phrasing on both
140
+ * surfaces — a divergent restatement fails that gate. This reuses the #3777
141
+ * single-source mechanism (one constant, two surfaces, drift-gated by tests).
142
+ *
143
+ * The altitude: `acceptance[]` / `verify[]` are the **binding contract** (the
144
+ * sole definition of "done"); `changes[]` / `references[]` are an **advisory
145
+ * implementation sketch** the executor MAY revise. Author acceptance to assert
146
+ * the **outcome** independent of file layout — never pin an incidental helper
147
+ * name or private path into an acceptance item. The advisory sketch is still
148
+ * validated (base-branch probes, New-File Contract) and never licenses
149
+ * skipping `acceptance[]` / `verify[]` or any `rules/security-baseline.md` MUST.
150
+ */
151
+ export const AUTHORING_ALTITUDE_GUIDANCE = Object.freeze({
152
+ // The binding-vs-advisory altitude statement.
153
+ altitude:
154
+ '**Binding contract vs advisory sketch.** `acceptance[]` and `verify[]` are the Story\'s **binding contract** — the executor MUST satisfy them exactly, and they are the only definition of "done." `changes[]` and `references[]` are an **advisory implementation sketch**: your best prediction of the file footprint, which the executor MAY revise when the real codebase diverges from the sketch. Author `acceptance[]` / `verify[]` to assert the **outcome** independent of any one file layout — never pin an incidental implementation detail (an internal helper name, a private file path) into an acceptance item that the advisory `changes[]` is free to reshape; assert the observable behaviour instead.',
155
+ // The advisory-does-not-mean-unvalidated caveat.
156
+ advisoryCaveat:
157
+ "**Advisory does not mean unvalidated.** `changes[]` paths still pass the base-branch file-assumption probes (a `creates` against an existing path still fails), the New-File Contract still holds, and the executor's latitude to revise the approach never licenses skipping `acceptance[]` / `verify[]` or relaxing any `rules/security-baseline.md` MUST.",
158
+ // The New-File Contract.
159
+ newFileContract:
160
+ '**New-File Contract.** Any path named in a Story\'s `goal`, `acceptance`, or `verify` that does NOT already exist on `main` MUST also appear in that Story\'s `changes[]` with `assumption: "creates"`; otherwise the freshness validator rejects the decompose — even when the Story is the one authoring the file.',
161
+ });
162
+
74
163
  /**
75
164
  * Configuration-constant phrase patterns the `unanchored-constant` heuristic
76
165
  * scans Story acceptance criteria for. Each entry matches the *kind* of
@@ -143,8 +232,11 @@ function makeUnanchoredConstant(slug, criterion) {
143
232
  */
144
233
  function computeUnanchoredConstantFindings(story) {
145
234
  const out = [];
146
- const body = story.body && typeof story.body === 'object' ? story.body : null;
147
- const acceptance = Array.isArray(body?.acceptance) ? body.acceptance : [];
235
+ // Read the authoritative top-level `story.acceptance` (the binding
236
+ // contract), falling back to the structured body's acceptance only when the
237
+ // top-level array is absent. This is correct regardless of body shape —
238
+ // string or object (Story #4271).
239
+ const acceptance = resolveAcceptance(story);
148
240
  for (const item of acceptance) {
149
241
  const criterion = String(item ?? '');
150
242
  if (CONCRETE_VALUE_RE.test(criterion)) continue;
@@ -158,6 +250,47 @@ function computeUnanchoredConstantFindings(story) {
158
250
  return out;
159
251
  }
160
252
 
253
+ /**
254
+ * Soft, advisory `missing-reason-to-exist` finding (Story #4273). Surfaces a
255
+ * Story whose body carries no non-empty `reason_to_exist` — the
256
+ * machine-checkable form of the cohesion rule (**one Story = one coherent
257
+ * change with one reason to exist**). `reason_to_exist` is marked REQUIRED by
258
+ * the decomposer prompt and is the field the `epic-plan-consolidate` critic
259
+ * gates on, but that critic is an honor-system LLM check with no runtime
260
+ * backstop. This deterministic finding is the cheap backstop.
261
+ *
262
+ * Severity is `soft` (not a hard reject) so existing `reason_to_exist`-less
263
+ * standalone / audit Stories are surfaced as an advisory nudge rather than
264
+ * blocked — matching the `unanchored-constant` finding's advisory contract.
265
+ */
266
+ function makeMissingReasonToExist(slug) {
267
+ return {
268
+ kind: 'missing-reason-to-exist',
269
+ severity: 'soft',
270
+ ticketSlug: slug,
271
+ message:
272
+ 'Story body carries no non-empty `reason_to_exist`. State the single coherent reason this Story exists in one sentence (the machine-checkable form of "one Story = one coherent change with one reason to exist"), encoded as the `reason_to_exist` field of the body meta comment.',
273
+ };
274
+ }
275
+
276
+ /**
277
+ * Emit a soft `missing-reason-to-exist` finding when the Story body resolves
278
+ * to no non-empty `reason_to_exist`. The body parser
279
+ * (`story-body/story-body.js`) already normalizes `reason_to_exist` to a
280
+ * non-empty trimmed string or `null`, so reading `body.reason_to_exist` after
281
+ * `resolveStoryBody` is correct regardless of body shape — a serialized
282
+ * **string** body (the production decomposer shape) or an object body
283
+ * (Story #4271). A body that fails to parse resolves to `null` and trips the
284
+ * finding, which is the right advisory signal: the author should re-emit a
285
+ * parseable body carrying the field. One finding per Story.
286
+ */
287
+ function computeMissingReasonToExistFinding(story) {
288
+ const body = resolveStoryBody(story);
289
+ const reason = body?.reason_to_exist;
290
+ const hasReason = typeof reason === 'string' && reason.trim().length > 0;
291
+ return hasReason ? [] : [makeMissingReasonToExist(story.slug)];
292
+ }
293
+
161
294
  /**
162
295
  * Returns true when a `changes[]` entry is a glob pattern. Handles both the
163
296
  * canonical PathEntry object form `{ path, assumption }` and legacy strings.
@@ -253,8 +386,12 @@ function isDeclaredWide(wide) {
253
386
  */
254
387
  function computeStorySizingFindings(story, sizing) {
255
388
  const out = [];
256
- const body = story.body && typeof story.body === 'object' ? story.body : null;
257
- const acceptance = Array.isArray(body?.acceptance) ? body.acceptance : [];
389
+ // Story #4271: normalize the body so the canonical serialized **string**
390
+ // shape the decomposer emits is scored at parity with the pre-serialize
391
+ // object shape. The acceptance ceiling reads the authoritative top-level
392
+ // `story.acceptance` (the binding contract), not `body.acceptance`.
393
+ const body = resolveStoryBody(story);
394
+ const acceptance = resolveAcceptance(story);
258
395
  const changes = Array.isArray(body?.changes) ? body.changes : [];
259
396
  const declaredWide = isDeclaredWide(body?.wide ?? null);
260
397
 
@@ -263,6 +400,13 @@ function computeStorySizingFindings(story, sizing) {
263
400
  // the numeric sizing layers below — purely an authoring nudge.
264
401
  out.push(...computeUnanchoredConstantFindings(story));
265
402
 
403
+ // Soft, advisory: flag a Story body that carries no non-empty
404
+ // `reason_to_exist` (Story #4273). The decomposer prompt marks the field
405
+ // REQUIRED and the consolidate critic gates on it, but that critic has no
406
+ // runtime backstop — this deterministic finding is the cheap backstop.
407
+ // Independent of the numeric sizing layers below.
408
+ out.push(...computeMissingReasonToExistFinding(story));
409
+
266
410
  // Acceptance ceiling + soft warn.
267
411
  if (acceptance.length > sizing.maxAcceptance) {
268
412
  out.push(
@@ -179,6 +179,7 @@ async function syncProjectStatusColumn(
179
179
  ticketId,
180
180
  newState,
181
181
  _makeColumnSync,
182
+ config,
182
183
  ) {
183
184
  try {
184
185
  let sync;
@@ -191,10 +192,15 @@ async function syncProjectStatusColumn(
191
192
  // The instance's `_meta` cache survives across label transitions
192
193
  // so the invariant project metadata (projectId, fieldId, options)
193
194
  // is only fetched once per process run. Story #3661.
195
+ //
196
+ // Story #4252 — `config` is threaded so the on-disk board-metadata
197
+ // cache lands under the project's configured tempRoot. It is read at
198
+ // construction only; the registry caches the first instance per
199
+ // provider, so a later transition's config is intentionally ignored.
194
200
  if (!_columnSyncRegistry.has(provider)) {
195
201
  _columnSyncRegistry.set(
196
202
  provider,
197
- new ColumnSync({ provider, logger: Logger }),
203
+ new ColumnSync({ provider, logger: Logger, config }),
198
204
  );
199
205
  }
200
206
  sync = _columnSyncRegistry.get(provider);
@@ -363,6 +369,7 @@ export async function transitionTicketState(
363
369
  ticketId,
364
370
  newState,
365
371
  opts._makeColumnSync,
372
+ opts.config,
366
373
  );
367
374
 
368
375
  // Automatically trigger upward cascade on every transition (Story
@@ -15,6 +15,7 @@
15
15
  * acceptance: string[], // observable criteria
16
16
  * verify: string[], // exact commands / tier annotation
17
17
  * references: PathEntry[], // read-only paths (optional)
18
+ * non_goals: string[], // negative-scope bullets (optional, advisory)
18
19
  * wide: { reason } | null,// declared-wide footprint (optional)
19
20
  * reason_to_exist: string | null, // one-sentence cohesion reason (optional)
20
21
  * depends_on: string[], // blocker story slugs or #ids
@@ -69,6 +70,7 @@ import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js
69
70
  * @property {string[]} acceptance - Observable acceptance criteria.
70
71
  * @property {string[]} verify - Exact commands with tier annotation.
71
72
  * @property {PathEntry[]} references - Read-only paths (may be empty).
73
+ * @property {string[]} non_goals - Negative-scope bullets (advisory; may be empty).
72
74
  * @property {{ reason: string }|null} wide - Declared-wide footprint (reason), or null.
73
75
  * @property {string|null} reason_to_exist - One-sentence cohesion reason ("why this Story exists"), or null.
74
76
  * @property {string[]} depends_on - Blocking story slugs / issue refs.
@@ -89,6 +91,7 @@ import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js
89
91
  * @property {boolean} hasAcceptanceSection - Whether a `## Acceptance` section was found.
90
92
  * @property {boolean} hasVerifySection - Whether a `## Verify` section was found.
91
93
  * @property {boolean} hasReferencesSection - Whether a `## References` section was found.
94
+ * @property {boolean} hasNonGoalsSection - Whether a `## Non-Goals` section was found.
92
95
  * @property {boolean} isLegacyStringBody - True when no structured sections were found.
93
96
  */
94
97
 
@@ -125,13 +128,16 @@ export class StoryBodyParseError extends Error {
125
128
  // Section heading map
126
129
  // ---------------------------------------------------------------------------
127
130
 
128
- // Heading text → body field name
131
+ // Heading text → body field name. Keys are normalized: lower-cased with `-`
132
+ // folded to `_` (see splitSections), so the hyphenated `## Non-Goals` heading
133
+ // maps to the `non_goals` field.
129
134
  const HEADING_TO_FIELD = new Map([
130
135
  ['goal', 'goal'],
131
136
  ['changes', 'changes'],
132
137
  ['acceptance', 'acceptance'],
133
138
  ['verify', 'verify'],
134
139
  ['references', 'references'],
140
+ ['non_goals', 'non_goals'],
135
141
  ]);
136
142
 
137
143
  // ---------------------------------------------------------------------------
@@ -346,15 +352,53 @@ function splitSections(markdown) {
346
352
  // Forms (Story #4227) render every field label as a level-3 heading
347
353
  // (`### Goal`), not the level-2 the canonical serializer emits, so the
348
354
  // parser accepts both levels. Any other heading depth is ignored.
349
- const headingMatch = line.match(/^#{2,3}\s+(\w+)\s*$/i);
350
- if (headingMatch) {
351
- const name = headingMatch[1].toLowerCase();
355
+ //
356
+ // The token class is `[\w-]+` (not bare `\w+`) so a single hyphenated
357
+ // heading word — the canonical `## Non-Goals` negative-scope section —
358
+ // matches as one token. The captured name is normalized (lower-cased,
359
+ // `-` folded to `_`) before the HEADING_TO_FIELD lookup, so `Non-Goals`
360
+ // resolves to the `non_goals` field. Multi-word headings that contain a
361
+ // space (`## Out of Scope`, `## Agent Prompts`) still do NOT match this
362
+ // single-token shape — they fall through to the catch-all heading branch
363
+ // below, which closes the open section. The chosen canonical spelling is
364
+ // therefore the hyphenated single token `## Non-Goals`.
365
+ const fieldHeadingMatch = line.match(/^#{2,3}\s+([\w-]+)\s*$/i);
366
+ if (fieldHeadingMatch) {
367
+ const name = fieldHeadingMatch[1].toLowerCase().replace(/-/g, '_');
352
368
  if (HEADING_TO_FIELD.has(name)) {
353
369
  inPreamble = false;
354
370
  currentSection = name;
355
371
  if (!sections.has(currentSection)) sections.set(currentSection, []);
356
372
  continue;
357
373
  }
374
+ // A heading that matches the canonical `## Word` shape but is not a
375
+ // recognized field name (e.g. a trailing free-form `## Notes`) closes
376
+ // the currently-open section. Without this reset, the unknown heading
377
+ // and its bullets bleed into the previously-recognized section,
378
+ // silently corrupting `verify[]` / `acceptance[]`. We do NOT re-enter
379
+ // the preamble (`inPreamble` stays false), so a later recognized
380
+ // heading still registers normally; we only stop appending to the
381
+ // closed section. The heading line and its body are dropped from all
382
+ // sections. (Multi-word free-form headings like `## Out of Scope` —
383
+ // with internal spaces — do not match the `[\w-]+` single-token shape
384
+ // and reach this branch too. The hyphenated single-token canonical
385
+ // negative-scope heading is `## Non-Goals`, which IS recognized above.)
386
+ currentSection = null;
387
+ continue;
388
+ }
389
+
390
+ // Any other markdown heading (`## …` / `### …`, single- or multi-word)
391
+ // that is NOT a canonical field heading TERMINATES the current structured
392
+ // section. Trailing extended content a producer appends after the
393
+ // canonical block — `audit-to-stories`'s `## Agent Prompts` / `## Context`
394
+ // / `## Sequencing` blocks, for instance — must not bleed into the last
395
+ // structured section's bullet list (Story #4270). Without this, those
396
+ // lines were silently absorbed into `verify[]` / `acceptance[]`. The
397
+ // heading and everything under it is dropped from structured parsing
398
+ // (it is extended, non-canonical markdown).
399
+ if (!inPreamble && /^#{1,6}\s+\S/.test(line)) {
400
+ currentSection = null;
401
+ continue;
358
402
  }
359
403
 
360
404
  // The trailing `<!-- meta: {...} -->` block is machine metadata, not
@@ -407,6 +451,7 @@ function parseLegacyStringBody(input, preamble, footer) {
407
451
  acceptance: [],
408
452
  verify: [],
409
453
  references: [],
454
+ non_goals: [],
410
455
  wide: null,
411
456
  reason_to_exist: null,
412
457
  depends_on: extractBlockedBy(footer),
@@ -421,6 +466,7 @@ function parseLegacyStringBody(input, preamble, footer) {
421
466
  hasAcceptanceSection: false,
422
467
  hasVerifySection: false,
423
468
  hasReferencesSection: false,
469
+ hasNonGoalsSection: false,
424
470
  isLegacyStringBody: true,
425
471
  },
426
472
  };
@@ -522,6 +568,7 @@ export function parse(input) {
522
568
  const hasAcceptanceSection = sections.has('acceptance');
523
569
  const hasVerifySection = sections.has('verify');
524
570
  const hasReferencesSection = sections.has('references');
571
+ const hasNonGoalsSection = sections.has('non_goals');
525
572
 
526
573
  // If no structured sections found, treat as legacy string body.
527
574
  const isLegacyStringBody =
@@ -545,6 +592,7 @@ export function parse(input) {
545
592
  sections.get('references') ?? [],
546
593
  warnings,
547
594
  );
595
+ const non_goals = parseTextListSection(sections.get('non_goals') ?? []);
548
596
  const dependsOn = extractBlockedBy(footer);
549
597
 
550
598
  // --- Recover wide / estimated_test_files from the meta block ---
@@ -566,6 +614,7 @@ export function parse(input) {
566
614
  acceptance,
567
615
  verify,
568
616
  references,
617
+ non_goals,
569
618
  wide,
570
619
  reason_to_exist,
571
620
  depends_on: dependsOn,
@@ -581,6 +630,7 @@ export function parse(input) {
581
630
  hasAcceptanceSection,
582
631
  hasVerifySection,
583
632
  hasReferencesSection,
633
+ hasNonGoalsSection,
584
634
  isLegacyStringBody: false,
585
635
  },
586
636
  };
@@ -625,6 +675,11 @@ function parseStructuredObject(obj) {
625
675
  if (entry !== null) references.push(entry);
626
676
  }
627
677
 
678
+ // non_goals (advisory negative-scope bullets)
679
+ const non_goals = Array.isArray(obj.non_goals)
680
+ ? obj.non_goals.filter((n) => typeof n === 'string' && n.trim().length > 0)
681
+ : [];
682
+
628
683
  const wide = normalizeWide(obj.wide);
629
684
  const reason_to_exist = normalizeReasonToExist(obj.reason_to_exist);
630
685
 
@@ -650,6 +705,7 @@ function parseStructuredObject(obj) {
650
705
  acceptance,
651
706
  verify,
652
707
  references,
708
+ non_goals,
653
709
  wide,
654
710
  reason_to_exist,
655
711
  depends_on,
@@ -665,6 +721,7 @@ function parseStructuredObject(obj) {
665
721
  hasAcceptanceSection: 'acceptance' in obj,
666
722
  hasVerifySection: 'verify' in obj,
667
723
  hasReferencesSection: 'references' in obj,
724
+ hasNonGoalsSection: 'non_goals' in obj,
668
725
  isLegacyStringBody: false,
669
726
  },
670
727
  };
@@ -689,7 +746,7 @@ function serializePathEntry(entry) {
689
746
  /**
690
747
  * Descriptor table for the human-readable Story-body sections, in canonical
691
748
  * emit order (`## Goal`, `## Changes`, `## Acceptance`, `## Verify`,
692
- * `## References`). Each descriptor reads one body field and returns the
749
+ * `## References`, `## Non-Goals`). Each descriptor reads one body field and returns the
693
750
  * section's markdown block when the field is present and non-empty, or `null`
694
751
  * to omit the section.
695
752
  *
@@ -735,6 +792,18 @@ const SERIALIZE_SECTIONS = [
735
792
  ? `## References\n${references.map((r) => `- ${serializePathEntry(r)}`).join('\n')}`
736
793
  : null,
737
794
  },
795
+ {
796
+ // Advisory negative-scope bullets. Rendered as the hyphenated canonical
797
+ // `## Non-Goals` heading (the spelling the parser's widened
798
+ // `[\w-]+` field-heading regex recognizes). Render-when-non-empty: an
799
+ // empty or absent `non_goals` emits nothing, so every pre-existing body
800
+ // round-trips byte-identically.
801
+ field: 'non_goals',
802
+ render: (nonGoals) =>
803
+ Array.isArray(nonGoals) && nonGoals.length > 0
804
+ ? `## Non-Goals\n${nonGoals.map((n) => `- ${n}`).join('\n')}`
805
+ : null,
806
+ },
738
807
  ];
739
808
 
740
809
  /**
@@ -793,8 +862,8 @@ function serializeFooter(body, opts) {
793
862
  * format written to GitHub issue bodies.
794
863
  *
795
864
  * The output matches the section order the spec-renderer uses:
796
- * `## Goal`, `## Changes`, `## Acceptance`, `## Verify`, `## References`
797
- * (omitted when empty).
865
+ * `## Goal`, `## Changes`, `## Acceptance`, `## Verify`, `## References`,
866
+ * `## Non-Goals` (each omitted when empty).
798
867
  *
799
868
  * `wide`, `reason_to_exist`, and `estimated_test_files` are emitted as a
800
869
  * fenced `<!-- meta -->` comment block so round-trips preserve them without
@@ -27,6 +27,7 @@ import {
27
27
  classifyBranchSeed,
28
28
  ensureEpicBranch,
29
29
  ensureEpicBranchRef,
30
+ seedStoryBranchRef,
30
31
  } from '../git-branch-lifecycle.js';
31
32
  import { gitSpawn } from '../git-utils.js';
32
33
  import { Logger } from '../Logger.js';
@@ -186,7 +187,10 @@ export function ensureStoryBranchSeed({
186
187
  progress = defaultProgress(),
187
188
  git,
188
189
  }) {
189
- const spawn = git?.spawn ?? ((...args) => gitSpawn(mainCwd, ...args));
190
+ const spawn =
191
+ git?.spawn != null
192
+ ? (args) => git.spawn(...args)
193
+ : (args) => gitSpawn(mainCwd, ...args);
190
194
  const existsLocally =
191
195
  git?.existsLocally ?? ((b) => branchExistsLocally(b, mainCwd));
192
196
  // `ensureStoryBranchSeed` is always called after `fetchMainRefs` in
@@ -195,49 +199,31 @@ export function ensureStoryBranchSeed({
195
199
  const existsRemotely =
196
200
  git?.existsRemotely ?? ((b) => branchExistsViaTrackingRef(b, mainCwd));
197
201
 
198
- const action = planStoryBranchSeed({
199
- localHas: existsLocally(storyBranch),
200
- remoteHas: existsRemotely(storyBranch),
202
+ // The seed-action switch shell is single-homed in `seedStoryBranchRef`
203
+ // (Story #4255). The Epic path runs under concurrent wave dispatch, so it
204
+ // opts into `swallowCreateRace: true` to treat a lost probe→create race
205
+ // (`git branch` exits "already exists") as reuse (Story #3482) — the ref
206
+ // exists, which is exactly the post-condition this function guarantees.
207
+ // No `fetchError` is supplied: the fetch exit status is intentionally not
208
+ // inspected here (the worktree bootstrap re-checks the ref downstream).
209
+ seedStoryBranchRef({
210
+ storyBranch,
211
+ baseRef: epicBranch,
212
+ swallowCreateRace: true,
213
+ spawn,
214
+ existsLocally,
215
+ existsRemotely,
216
+ progress,
217
+ messages: {
218
+ reuse: (b) => `Reusing existing story branch ref: ${b} (no re-seed)`,
219
+ fetch: (b) => `Fetching remote story branch: ${b}`,
220
+ create: (b, ref) => `Creating story branch ref: ${b} from ${ref}`,
221
+ createRace: (b) =>
222
+ `Story branch ref ${b} already exists (created concurrently) — reusing.`,
223
+ createError: (b, ref, stderr) =>
224
+ `ensureStoryBranchSeed: failed to create ${b} from ${ref}: ${stderr}`,
225
+ },
201
226
  });
202
- if (action === 'none') {
203
- // Story #3482 — a pre-existing `story-<id>` ref is reuse, not an error.
204
- // The worktree bootstrap below seeds onto whatever the ref already points
205
- // at (resuming a partially-implemented Story), so seeding is a no-op here.
206
- progress(
207
- 'GIT',
208
- `Reusing existing story branch ref: ${storyBranch} (no re-seed)`,
209
- );
210
- return;
211
- }
212
- if (action === 'fetch') {
213
- progress('GIT', `Fetching remote story branch: ${storyBranch}`);
214
- spawn('fetch', 'origin', `${storyBranch}:${storyBranch}`);
215
- return;
216
- }
217
- progress(
218
- 'GIT',
219
- `Creating story branch ref: ${storyBranch} from ${epicBranch}`,
220
- );
221
- const res = spawn('branch', storyBranch, epicBranch);
222
- // Story #3482 — close the probe→create race: another concurrent
223
- // dispatch (or a prior interrupted run) may have created the ref between
224
- // our existence probe above and this `git branch` call. `git branch`
225
- // exits non-zero with "already exists" in that window. Treat it as reuse
226
- // rather than letting story-init abort — the ref exists, which is exactly
227
- // the post-condition this function guarantees.
228
- if (res.status !== 0) {
229
- const stderr = res.stderr || res.stdout || '';
230
- if (/already exists/i.test(stderr)) {
231
- progress(
232
- 'GIT',
233
- `Story branch ref ${storyBranch} already exists (created concurrently) — reusing.`,
234
- );
235
- return;
236
- }
237
- throw new Error(
238
- `ensureStoryBranchSeed: failed to create ${storyBranch} from ${epicBranch}: ${stderr}`,
239
- );
240
- }
241
227
  }
242
228
 
243
229
  function verifyWorkspaceSafe({
@@ -2,10 +2,19 @@ import { Logger } from '../Logger.js';
2
2
  /**
3
3
  * hierarchy-tracer.js — Stage 2 of the story-init pipeline.
4
4
  *
5
- * Given an epicId, resolves the linked PRD and Tech Spec issue IDs by
6
- * fetching the Epic. Fetch failures are logged but non-fatal — the result
7
- * simply reports `null` for whichever linkage could not be resolved, which
8
- * mirrors legacy behaviour in story-init.js.
5
+ * Resolves the linked PRD and Tech Spec issue IDs for a Story's parent Epic.
6
+ *
7
+ * Story #4253: when both `prdId` and `techSpecId` are supplied as input
8
+ * (the `/deliver` fan-out resolves the immutable Epic once at the top of the
9
+ * run and threads the two ids down via `story-init.js --prd/--tech-spec`),
10
+ * this stage short-circuits and does NOT call `provider.getEpic`. The Epic
11
+ * issue is invariant for the lifetime of a delivery run, so the N per-Story
12
+ * `getEpic` round-trips collapse to one parent-side resolution.
13
+ *
14
+ * When the flags are absent (interactive / single-story use), the legacy
15
+ * `getEpic` resolution runs unchanged. Fetch failures are logged but
16
+ * non-fatal — the result simply reports `null` for whichever linkage could
17
+ * not be resolved, preserving the graceful degradation on a missing Epic.
9
18
  */
10
19
 
11
20
  /**
@@ -14,12 +23,24 @@ import { Logger } from '../Logger.js';
14
23
  * @param {object} [deps.logger]
15
24
  * @param {object} deps.input
16
25
  * @param {number} deps.input.epicId
26
+ * @param {number|null} [deps.input.prdId] Pre-resolved PRD id (from --prd).
27
+ * @param {number|null} [deps.input.techSpecId] Pre-resolved Tech Spec id
28
+ * (from --tech-spec). When both `prdId` and `techSpecId` are supplied,
29
+ * `getEpic` is skipped.
17
30
  * @returns {Promise<{ prdId: number|null, techSpecId: number|null }>}
18
31
  */
19
32
  export async function traceHierarchy({ provider, logger, input }) {
20
33
  const { epicId } = input;
21
34
  const warn = logger?.warn ?? ((msg) => Logger.error(msg));
22
35
 
36
+ // Short-circuit: the parent already resolved both linkages once and threaded
37
+ // them in, so there is nothing left to fetch. Skip the per-Story getEpic.
38
+ const suppliedPrdId = input.prdId ?? null;
39
+ const suppliedTechSpecId = input.techSpecId ?? null;
40
+ if (suppliedPrdId !== null && suppliedTechSpecId !== null) {
41
+ return { prdId: suppliedPrdId, techSpecId: suppliedTechSpecId };
42
+ }
43
+
23
44
  let prdId = null;
24
45
  let techSpecId = null;
25
46
  try {
@@ -82,22 +82,32 @@ export async function buildTaskGraph({ provider, logger, input }) {
82
82
  const warn = logger?.warn ?? ((msg) => Logger.error(msg));
83
83
  const progress = logger?.progress ?? (() => {});
84
84
 
85
+ // Story #4251 — under the 2-tier hierarchy every Story is childless, so the
86
+ // `fetchChildTickets` call (a `getTicket` + empty sub-issues GraphQL query +
87
+ // a never-matching `/search/issues` fallback) is pure waste on every
88
+ // story-init. The Story body is already in scope, so detect the inline-
89
+ // acceptance 2-tier shape FIRST and short-circuit without any child fetch —
90
+ // sparing the most aggressively rate-limited GitHub endpoint exactly during
91
+ // wide wave fan-out. A body lacking inline acceptance still falls through to
92
+ // the legacy child-enumeration path below.
93
+ if (hasInlineAcceptance(storyBody)) {
94
+ progress(
95
+ 'TASKS',
96
+ `Story #${storyId} has inline acceptance — no child Tasks expected (2-tier shape).`,
97
+ );
98
+ return { sortedTasks: [], mode: '2-tier' };
99
+ }
100
+
101
+ // Legacy / 4-tier fall-through: a body lacking inline acceptance still
102
+ // enumerates child Tasks for the topological sort below.
85
103
  const tasks = await fetchChildTickets(provider, storyId);
86
104
 
87
- const inlineAcceptance = hasInlineAcceptance(storyBody);
88
- const mode = tasks.length === 0 && inlineAcceptance ? '2-tier' : '4-tier';
105
+ const mode = '4-tier';
89
106
 
90
107
  if (tasks.length === 0) {
91
- if (inlineAcceptance) {
92
- progress(
93
- 'TASKS',
94
- `Story #${storyId} has inline acceptance — no child Tasks expected (2-tier shape).`,
95
- );
96
- } else {
97
- warn(
98
- `[story-init] Warning: Story #${storyId} has no child Tasks. The agent will need to work from the Story body directly.`,
99
- );
100
- }
108
+ warn(
109
+ `[story-init] Warning: Story #${storyId} has no child Tasks. The agent will need to work from the Story body directly.`,
110
+ );
101
111
  }
102
112
 
103
113
  const sortedTasks = sortTasksByDependencies(tasks);