mandrel 1.76.0 → 1.78.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 (68) hide show
  1. package/.agents/docs/configuration.md +2 -2
  2. package/.agents/docs/workflows.md +19 -0
  3. package/.agents/schemas/agentrc.schema.json +1 -1
  4. package/.agents/schemas/dispatch-manifest.json +1 -1
  5. package/.agents/schemas/lifecycle/loop.tick.schema.json +20 -0
  6. package/.agents/schemas/loop-unit.schema.json +70 -0
  7. package/.agents/schemas/validation-evidence.schema.json +2 -1
  8. package/.agents/scripts/audit-to-stories.js +43 -1
  9. package/.agents/scripts/check-doc-links.js +24 -1
  10. package/.agents/scripts/check-loop-units.js +204 -0
  11. package/.agents/scripts/epic-deliver-prepare.js +31 -0
  12. package/.agents/scripts/evidence-gate.js +48 -12
  13. package/.agents/scripts/generate-workflows-doc.js +37 -4
  14. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +141 -34
  15. package/.agents/scripts/lib/cli-args.js +6 -0
  16. package/.agents/scripts/lib/close-validation/process.js +61 -5
  17. package/.agents/scripts/lib/close-validation/runner.js +42 -9
  18. package/.agents/scripts/lib/config/temp-paths.js +1 -1
  19. package/.agents/scripts/lib/config/worktree-isolation.js +18 -3
  20. package/.agents/scripts/lib/config-resolver.js +4 -1
  21. package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
  22. package/.agents/scripts/lib/git-branch-lifecycle.js +90 -0
  23. package/.agents/scripts/lib/loop-units/validate-loop-unit.js +197 -0
  24. package/.agents/scripts/lib/mandrel-catalog.js +36 -0
  25. package/.agents/scripts/lib/orchestration/auto-merge-cwd.js +128 -0
  26. package/.agents/scripts/lib/orchestration/column-sync.js +88 -9
  27. package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +183 -0
  28. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +20 -2
  29. package/.agents/scripts/lib/orchestration/project-meta-cache.js +238 -0
  30. package/.agents/scripts/lib/orchestration/reassert-status-column.js +3 -1
  31. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +25 -2
  32. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +80 -14
  33. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +74 -25
  34. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +10 -1
  35. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +48 -1
  36. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +148 -4
  37. package/.agents/scripts/lib/orchestration/ticketing/transition.js +8 -1
  38. package/.agents/scripts/lib/story-body/story-body.js +76 -7
  39. package/.agents/scripts/lib/story-init/branch-initializer.js +29 -43
  40. package/.agents/scripts/lib/story-init/hierarchy-tracer.js +25 -4
  41. package/.agents/scripts/lib/story-init/task-graph-builder.js +22 -12
  42. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -0
  43. package/.agents/scripts/lib/validation-evidence.js +63 -25
  44. package/.agents/scripts/lib/worktree/node-modules-strategy.js +239 -31
  45. package/.agents/scripts/providers/github/branch-protection.js +1 -1
  46. package/.agents/scripts/providers/github/errors.js +53 -2
  47. package/.agents/scripts/providers/github/labels.js +1 -1
  48. package/.agents/scripts/providers/github/projects-v2-graphql.js +1 -1
  49. package/.agents/scripts/resync-status-column.js +5 -0
  50. package/.agents/scripts/run-coverage.js +85 -45
  51. package/.agents/scripts/run-lint.js +11 -0
  52. package/.agents/scripts/single-story-init.js +22 -29
  53. package/.agents/scripts/story-init.js +38 -63
  54. package/.agents/scripts/story-phase.js +46 -4
  55. package/.agents/scripts/sync-claude-commands.js +112 -29
  56. package/.agents/scripts/update-maintainability-baseline.js +19 -76
  57. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  58. package/.agents/workflows/helpers/acceptance-self-eval.md +27 -0
  59. package/.agents/workflows/helpers/deliver-epic.md +19 -2
  60. package/.agents/workflows/helpers/epic-deliver-story.md +50 -14
  61. package/.agents/workflows/helpers/single-story-deliver.md +12 -0
  62. package/.agents/workflows/loops/README.md +65 -0
  63. package/.agents/workflows/loops/fix-failing-tests.md +74 -0
  64. package/.agents/workflows/loops/nightly-audit.md +71 -0
  65. package/.agents/workflows/loops/watch-ci.md +68 -0
  66. package/docs/CHANGELOG.md +51 -0
  67. package/package.json +1 -1
  68. package/.agents/scripts/providers/github/transient-retry.js +0 -62
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * Usage:
15
15
  * node .agents/scripts/evidence-gate.js \
16
- * --epic-id <epicId> --scope-id <storyOrEpicId> --gate <name> \
16
+ * (--epic-id <epicId> | --standalone) --scope-id <storyOrEpicId> --gate <name> \
17
17
  * [--worktree <path>] [--no-evidence] -- <cmd> [args...]
18
18
  *
19
19
  * Examples:
@@ -21,11 +21,18 @@
21
21
  * node .agents/scripts/evidence-gate.js --epic-id 1030 --scope-id 1030 --gate test -- npm test
22
22
  * node .agents/scripts/evidence-gate.js --epic-id 1114 --scope-id 1120 --gate test \
23
23
  * --worktree .worktrees/story-1120 -- npm test
24
+ * node .agents/scripts/evidence-gate.js --standalone --scope-id 4250 --gate lint \
25
+ * --worktree .worktrees/story-4250 -- npm run lint
24
26
  *
25
- * `--epic-id` is required. When `scope-id === epic-id` the evidence file is
26
- * Epic-scoped (`<tempRoot>/epic-<eid>/validation-evidence.json`); when
27
- * `scope-id !== epic-id` it is Story-scoped under
28
- * `<tempRoot>/epic-<eid>/story-<sid>/validation-evidence.json`.
27
+ * Either `--epic-id` or `--standalone` is required. When `scope-id ===
28
+ * epic-id` the evidence file is Epic-scoped
29
+ * (`<tempRoot>/epic-<eid>/validation-evidence.json`); when `scope-id !==
30
+ * epic-id` it is Story-scoped under
31
+ * `<tempRoot>/epic-<eid>/story-<sid>/validation-evidence.json`. With
32
+ * `--standalone` (Story #4250) the file is anchored on the Story id alone at
33
+ * `<tempRoot>/standalone/stories/story-<sid>/validation-evidence.json` — the
34
+ * same keyspace the standalone close consults, so the acceptance-self-eval
35
+ * critic's verify[] runs (lint / typecheck) are shared with the close.
29
36
  *
30
37
  * **Worktree-aware spawn (Story #1120).** `--cwd` (default `PROJECT_ROOT`)
31
38
  * is the *evidence cwd* — it locates the per-Epic temp tree under the main
@@ -84,6 +91,7 @@ export function parseWrapperArgs(argv) {
84
91
  'epic-id': { type: 'string' },
85
92
  gate: { type: 'string' },
86
93
  'no-evidence': { type: 'boolean', default: false },
94
+ standalone: { type: 'boolean', default: false },
87
95
  cwd: { type: 'string' },
88
96
  worktree: { type: 'string' },
89
97
  },
@@ -94,6 +102,7 @@ export function parseWrapperArgs(argv) {
94
102
  return {
95
103
  scopeId: Number.isNaN(scopeId) || scopeId <= 0 ? null : scopeId,
96
104
  epicId: Number.isNaN(epicId) || epicId <= 0 ? null : epicId,
105
+ standalone: values.standalone === true,
97
106
  gate: values.gate ?? null,
98
107
  useEvidence: values['no-evidence'] !== true,
99
108
  cwd: values.cwd ?? PROJECT_ROOT,
@@ -118,7 +127,11 @@ function resolveHeadShaDefault(cwd, gitSpawnFn) {
118
127
  *
119
128
  * @param {object} params
120
129
  * @param {number} params.scopeId — Story / Epic ID (positive integer).
121
- * @param {string} params.gate Logical gate name (`lint`, `test`, …).
130
+ * @param {number|null} [params.epicId] Parent Epic id (Epic-keyed path).
131
+ * @param {boolean} [params.standalone] — When true, route to the
132
+ * storyId-anchored standalone keyspace instead of the Epic-keyed path
133
+ * (Story #4250). Substitutes for `epicId`.
134
+ * @param {string} params.gate — Logical gate name (`lint`, `typecheck`, …).
122
135
  * @param {boolean} params.useEvidence — When false, force the runner.
123
136
  * @param {string} params.cwd — Evidence cwd (locates the per-Epic
124
137
  * temp tree). The runner is spawned in `worktreePath` when set, else `cwd`.
@@ -144,15 +157,38 @@ export async function runEvidenceGate(params, deps = {}) {
144
157
  recordPassFn = recordPass,
145
158
  logger = Logger,
146
159
  } = deps;
147
- const { scopeId, epicId, gate, useEvidence, cwd, worktreePath, runnerArgs } =
148
- params ?? {};
160
+ const {
161
+ scopeId,
162
+ epicId,
163
+ standalone = false,
164
+ gate,
165
+ useEvidence,
166
+ cwd,
167
+ worktreePath,
168
+ runnerArgs,
169
+ } = params ?? {};
149
170
 
150
- if (!scopeId || !epicId || !gate || !runnerArgs || runnerArgs.length === 0) {
171
+ // `--epic-id` is required for the Epic-keyed path; `--standalone` (Story
172
+ // #4250) substitutes for it and routes the evidence file to the
173
+ // storyId-anchored standalone keyspace so the acceptance-self-eval critic
174
+ // can record verify[] evidence into the same keyspace the standalone close
175
+ // consults.
176
+ if (
177
+ !scopeId ||
178
+ (!epicId && !standalone) ||
179
+ !gate ||
180
+ !runnerArgs ||
181
+ runnerArgs.length === 0
182
+ ) {
151
183
  logger.fatal(
152
- 'Usage: node evidence-gate.js --epic-id <epicId> --scope-id <id> --gate <name> [--worktree <path>] [--no-evidence] -- <cmd> [args...]',
184
+ 'Usage: node evidence-gate.js (--epic-id <epicId> | --standalone) --scope-id <id> --gate <name> [--worktree <path>] [--no-evidence] -- <cmd> [args...]',
153
185
  );
154
186
  return { status: 1, skipped: false };
155
187
  }
188
+ // Evidence-store opts shared by shouldSkip + recordPass below. `standalone`
189
+ // routes to the storyId-anchored keyspace; otherwise the Epic-keyed path
190
+ // resolves under `epicId`.
191
+ const evidenceStoreOpts = { cwd, epicId, standalone };
156
192
 
157
193
  // Spawn cwd is the worktree when supplied — every gate command sees the
158
194
  // Story branch's tree, not the main checkout. Evidence cwd stays anchored
@@ -174,7 +210,7 @@ export async function runEvidenceGate(params, deps = {}) {
174
210
  currentSha: headSha,
175
211
  configHash,
176
212
  },
177
- { cwd, epicId },
213
+ evidenceStoreOpts,
178
214
  );
179
215
  if (verdict.skip) {
180
216
  const ts = verdict.record?.timestamp ?? 'n/a';
@@ -215,7 +251,7 @@ export async function runEvidenceGate(params, deps = {}) {
215
251
  exitCode: 0,
216
252
  durationMs: Date.now() - startedAt,
217
253
  },
218
- { cwd, epicId },
254
+ evidenceStoreOpts,
219
255
  );
220
256
  } catch (err) {
221
257
  logger.warn?.(
@@ -34,7 +34,7 @@ import { fileURLToPath } from 'node:url';
34
34
  import { parseArgs } from 'node:util';
35
35
  import { runAsCli } from './lib/cli-utils.js';
36
36
  import { Logger } from './lib/Logger.js';
37
- import { buildCatalog } from './lib/mandrel-catalog.js';
37
+ import { buildCatalog, buildLoopCatalog } from './lib/mandrel-catalog.js';
38
38
 
39
39
  const __filename = fileURLToPath(import.meta.url);
40
40
  const __dirname = path.dirname(__filename);
@@ -66,12 +66,14 @@ function cellEscape(description) {
66
66
  }
67
67
 
68
68
  /**
69
- * Render the full generated `workflows.md` content from a catalog.
69
+ * Render the full generated `workflows.md` content from the flat command
70
+ * catalog and the loop-unit catalog.
70
71
  *
71
72
  * @param {Array<{ name: string, description: string | null, vague: boolean }>} catalog
73
+ * @param {Array<{ name: string, description: string | null, vague: boolean }>} [loopCatalog]
72
74
  * @returns {string}
73
75
  */
74
- export function renderWorkflowsDoc(catalog) {
76
+ export function renderWorkflowsDoc(catalog, loopCatalog = []) {
75
77
  const lines = [
76
78
  '<!--',
77
79
  ' GENERATED FILE — do not edit by hand.',
@@ -95,6 +97,13 @@ export function renderWorkflowsDoc(catalog) {
95
97
  '`.claude/commands/<name>.md` — there is no plugin manifest and no',
96
98
  'marketplace listing. The commands load in every Claude Code environment.',
97
99
  '',
100
+ 'Loop units are the one namespaced exception: files under',
101
+ '`.agents/workflows/loops/<name>.md` project to',
102
+ '`.claude/commands/loops/<name>.md` and are invoked as the namespaced',
103
+ '`/loops:<name>` command. On hosts that flatten subdirectory commands the',
104
+ 'same unit surfaces under the flat fallback `/loops-<name>`. They are',
105
+ 'listed separately in the **Loops namespace** section below.',
106
+ '',
98
107
  'This index is regenerated from each workflow’s front-matter `description:`',
99
108
  'by `node .agents/scripts/generate-workflows-doc.js`; `npm run docs:check`',
100
109
  'fails when it drifts from the on-disk workflow set. To change a command’s',
@@ -110,6 +119,29 @@ export function renderWorkflowsDoc(catalog) {
110
119
  lines.push(`| \`/${entry.name}\` | ${cellEscape(entry.description)} |`);
111
120
  }
112
121
 
122
+ lines.push('');
123
+ lines.push(`## Loops namespace (${loopCatalog.length})`);
124
+ lines.push('');
125
+ lines.push(
126
+ 'Loop units project to `.claude/commands/loops/<name>.md` and are invoked',
127
+ );
128
+ lines.push(
129
+ 'as `/loops:<name>` (flat fallback `/loops-<name>` on hosts that flatten',
130
+ );
131
+ lines.push('subdirectory commands).');
132
+ lines.push('');
133
+ if (loopCatalog.length === 0) {
134
+ lines.push('> No loop units are shipped yet.');
135
+ } else {
136
+ lines.push('| Command | Description |');
137
+ lines.push('| --- | --- |');
138
+ for (const entry of loopCatalog) {
139
+ lines.push(
140
+ `| \`/loops:${entry.name}\` | ${cellEscape(entry.description)} |`,
141
+ );
142
+ }
143
+ }
144
+
113
145
  lines.push('');
114
146
  return lines.join('\n');
115
147
  }
@@ -121,7 +153,8 @@ export function renderWorkflowsDoc(catalog) {
121
153
  */
122
154
  export function buildExpected() {
123
155
  const catalog = buildCatalog(WORKFLOWS_DIR);
124
- const generated = renderWorkflowsDoc(catalog);
156
+ const loopCatalog = buildLoopCatalog(WORKFLOWS_DIR);
157
+ const generated = renderWorkflowsDoc(catalog, loopCatalog);
125
158
  const original = fs.existsSync(DOC_PATH)
126
159
  ? fs.readFileSync(DOC_PATH, 'utf8')
127
160
  : null;
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * lib/audit-to-stories/build-story-body.js
3
3
  *
4
- * Render the canonical Story body for the standalone grouping mode. The
5
- * body follows the contract spelled out in Story #2583 acceptance
6
- * criteria #8: Title (caller), Summary, Acceptance Criteria, Agent
7
- * Prompts, Context block, fingerprint footer.
4
+ * Render the canonical Story body for the standalone grouping mode so a
5
+ * generated audit Story clears the same inline-contract bar the decomposer
6
+ * enforces (`assertEveryStoryHasInlineContract`): a clean goal, observable
7
+ * `acceptance[]`, a populated `changes[]` footprint, and a non-empty,
8
+ * tier-tagged `verify[]` (Story #4270).
8
9
  *
9
10
  * Pure: returns { title, body, labels }. Labels carry one canonical
10
11
  * `audit::<lens>` per distinct source report represented in the merge
@@ -14,10 +15,11 @@
14
15
  * `risk::high`.
15
16
  *
16
17
  * The body is serialized via the canonical story-body serializer
17
- * (`.agents/scripts/lib/story-body/story-body.js`) so the output is
18
- * parseable by `parse()` and round-trippable. Audit-specific content
19
- * (agent prompts, context links, fingerprint footer) is appended after
20
- * the canonical sections as extended markdown.
18
+ * (`.agents/scripts/lib/story-body/story-body.js`) so the output round-trips
19
+ * through `parse()` / `serialize()`. Audit-specific content (agent prompts,
20
+ * context links, fingerprint footer) is appended after the canonical sections
21
+ * as extended markdown it is informational only and is not part of the
22
+ * structured contract.
21
23
  */
22
24
 
23
25
  import { serialize } from '../story-body/story-body.js';
@@ -26,36 +28,128 @@ import { renderFingerprintFooter } from './finding-adapter.js';
26
28
 
27
29
  const STATIC_LABELS = Object.freeze(['type::story', 'agent::ready']);
28
30
 
31
+ // The verify[] contract every generated audit Story carries. These commands
32
+ // exist in this repo's harness (package.json scripts) so the Story satisfies
33
+ // the inline-contract bar with runnable, tier-tagged gates rather than
34
+ // placeholder prose. Kept as a frozen constant so the same contract is
35
+ // asserted by the unit suite.
36
+ const DEFAULT_VERIFY = Object.freeze([
37
+ 'npm run lint (validate)',
38
+ 'npm test (unit)',
39
+ ]);
40
+
29
41
  function uniq(items) {
30
42
  return [...new Set(items)];
31
43
  }
32
44
 
33
- function summaryFromGroup(group) {
34
- const lines = group.findings.map((f, idx) => {
35
- const sev = f.severity ? `[${f.severity.toUpperCase()}]` : '[—]';
36
- const dim = f.dimension ? `(${f.dimension})` : '';
37
- return `${idx + 1}. ${sev} ${dim} **${f.title}** ${
38
- f.currentState || '_(no current-state captured)_'
39
- }`;
40
- });
41
- return lines.join('\n');
45
+ /**
46
+ * The goal is the group intent only — the synthesized `group.title`. It
47
+ * carries no leading ordinal (`1.`/`2.`) and no `[SEVERITY]` / `(dimension)`
48
+ * prefix (the polluted shape Story #4270 replaced); those signals live in the
49
+ * per-finding fingerprint footer and the extended Agent Prompts section, not
50
+ * in the goal.
51
+ *
52
+ * @param {object} group
53
+ * @returns {string}
54
+ */
55
+ function goalFromGroup(group) {
56
+ return (group.title ?? '').trim();
42
57
  }
43
58
 
44
- function goalFromGroup(group) {
45
- // Derive a concise goal statement from the group title + finding summary.
46
- const summary = summaryFromGroup(group);
47
- return `${group.title}\n\n${summary}`;
59
+ /**
60
+ * Map every distinct file mentioned across the merge onto a canonical
61
+ * `changes[]` PathEntry (`{ path, assumption }`). Audit findings remediate
62
+ * code that already exists, so the assumption is `refactors-existing`.
63
+ *
64
+ * `group.files` is an array post-`groupFindings`; fall back to scanning the
65
+ * findings' own `files[]` when an upstream caller hands a group whose `files`
66
+ * aggregate was not materialized.
67
+ *
68
+ * @param {object} group
69
+ * @returns {Array<{ path: string, assumption: string }>}
70
+ */
71
+ function changesFromGroup(group) {
72
+ const fromGroup = Array.isArray(group.files) ? group.files : [];
73
+ const fromFindings = (group.findings ?? []).flatMap((f) =>
74
+ Array.isArray(f.files) ? f.files : [],
75
+ );
76
+ const paths = uniq(
77
+ [...fromGroup, ...fromFindings].filter(
78
+ (p) => typeof p === 'string' && p.length > 0,
79
+ ),
80
+ );
81
+ return paths.map((path) => ({ path, assumption: 'refactors-existing' }));
82
+ }
83
+
84
+ /**
85
+ * Build an observable acceptance item from a single finding: a checkable
86
+ * end-state a reviewer can confirm, NOT the verbatim recommendation
87
+ * paragraph. The recommendation prose is preserved verbatim in the Agent
88
+ * Prompts / fingerprint footer for the implementer; the acceptance line is
89
+ * the binding, confirmable outcome.
90
+ *
91
+ * Shape: `<title> is remediated in <primary file>: the recommended end-state
92
+ * holds and the finding is no longer reproducible.` — anchored on the finding
93
+ * title and primary file so the reviewer knows exactly what to check.
94
+ *
95
+ * @param {object} finding
96
+ * @returns {string}
97
+ */
98
+ function acceptanceItemFromFinding(finding) {
99
+ const title = (finding.title ?? 'finding').trim();
100
+ const primaryFile =
101
+ Array.isArray(finding.files) && finding.files.length > 0
102
+ ? finding.files[0]
103
+ : null;
104
+ const where = primaryFile ? ` in \`${primaryFile}\`` : '';
105
+ return `${title} is remediated${where}: the recommended end-state holds and the finding is no longer reproducible`;
48
106
  }
49
107
 
50
108
  function acceptanceCriteriaFromGroup(group) {
51
- return group.findings.map((f) => {
52
- const rec = f.recommendation || '_(no recommendation captured)_';
53
- return `${f.title} — ${rec}`;
54
- });
109
+ return (group.findings ?? []).map(acceptanceItemFromFinding);
110
+ }
111
+
112
+ /**
113
+ * Resolve the `edges[]` sequencing anchored on this group. Each edge whose
114
+ * `fromGroupKey` matches this group's key contributes its `toGroupKey`. Group
115
+ * keys are the only stable identifier available at emit time — issues are not
116
+ * numbered yet — so the relationship is preserved as machine-readable keys the
117
+ * operator can resolve.
118
+ *
119
+ * @param {object} group
120
+ * @param {Array<{ fromGroupKey: string, toGroupKey: string }>} edges
121
+ * @returns {string[]}
122
+ */
123
+ function sequencingDepsForGroup(group, edges) {
124
+ if (!Array.isArray(edges) || edges.length === 0) return [];
125
+ const deps = edges
126
+ .filter((e) => e && e.fromGroupKey === group.groupKey)
127
+ .map((e) => e.toGroupKey)
128
+ .filter((k) => typeof k === 'string' && k.length > 0);
129
+ return uniq(deps);
130
+ }
131
+
132
+ /**
133
+ * Render the carried-through `edges[]` sequencing as a dedicated extended
134
+ * markdown block. The canonical `depends_on[]` footer only round-trips `#N`
135
+ * issue refs (`blocked by #123`), which do not exist before the issues are
136
+ * opened; rendering the group-key sequencing as its own informational section
137
+ * keeps the signal in the body (not discarded — Story #4270) and survives
138
+ * `parse()` / `serialize()` round-tripping (it is preamble/extended content,
139
+ * not a structured section). Returns the empty string when there is no
140
+ * sequencing to surface.
141
+ *
142
+ * @param {string[]} deps
143
+ * @returns {string}
144
+ */
145
+ function sequencingSection(deps) {
146
+ if (deps.length === 0) return '';
147
+ const lines = deps.map((k) => `- depends on group \`${k}\``);
148
+ return ['## Sequencing', '', lines.join('\n'), ''].join('\n');
55
149
  }
56
150
 
57
151
  function agentPromptsSection(group) {
58
- const blocks = group.findings
152
+ const blocks = (group.findings ?? [])
59
153
  .filter(
60
154
  (f) => typeof f.agentPrompt === 'string' && f.agentPrompt.length > 0,
61
155
  )
@@ -65,7 +159,7 @@ function agentPromptsSection(group) {
65
159
 
66
160
  function contextLinksFromGroup(group) {
67
161
  const reports = uniq(
68
- group.findings
162
+ (group.findings ?? [])
69
163
  .map((f) => f.sourceReport)
70
164
  .filter((s) => typeof s === 'string'),
71
165
  );
@@ -93,34 +187,47 @@ function labelsForGroup(group) {
93
187
  /**
94
188
  * @param {object} params
95
189
  * @param {object} params.group — output of `groupFindings` (one entry).
190
+ * @param {Array<{ fromGroupKey: string, toGroupKey: string }>} [params.edges]
191
+ * — the dependency `edges[]` emitted by `groupFindings`. Edges anchored on
192
+ * this group are carried through to `depends_on[]`; omit when no sequencing
193
+ * is known.
96
194
  * @returns {{ title: string, body: string, labels: string[] }}
97
195
  */
98
- export function buildStoryBody({ group }) {
196
+ export function buildStoryBody({ group, edges = [] }) {
99
197
  if (!group || !Array.isArray(group.findings)) {
100
198
  throw new Error('buildStoryBody: group with findings[] is required');
101
199
  }
102
200
  const title = group.title;
103
201
 
104
- // Build the canonical StoryBody object from the audit group data.
202
+ // Build the canonical StoryBody object from the audit group data. The
203
+ // acceptance + verify arrays are populated so the body clears the
204
+ // inline-contract bar; changes[] carries the file footprint. The edges[]
205
+ // sequencing is carried through as an extended `## Sequencing` block (see
206
+ // sequencingSection) — group keys are not `#N` refs, so they cannot ride the
207
+ // canonical depends_on footer.
105
208
  const storyBody = {
106
209
  goal: goalFromGroup(group),
107
- changes: [],
210
+ changes: changesFromGroup(group),
108
211
  acceptance: acceptanceCriteriaFromGroup(group),
109
- verify: [],
212
+ verify: [...DEFAULT_VERIFY],
110
213
  references: [],
111
214
  wide: null,
215
+ reason_to_exist: null,
112
216
  depends_on: [],
113
217
  estimated_test_files: null,
114
218
  };
115
219
 
116
- // Serialize via the canonical serializer.
220
+ // Serialize via the canonical serializer (no footer — depends_on is empty).
117
221
  const canonicalSections = serialize(storyBody);
222
+ const sequencing = sequencingSection(sequencingDepsForGroup(group, edges));
118
223
 
119
- // Append audit-specific extended sections (agent prompts, context links,
120
- // fingerprint footer) that are not part of the canonical shape.
224
+ // Append audit-specific extended sections (sequencing, agent prompts,
225
+ // context links, fingerprint footer) that are not part of the canonical
226
+ // shape.
121
227
  const body = [
122
228
  canonicalSections,
123
229
  '',
230
+ ...(sequencing ? [sequencing] : []),
124
231
  '## Agent Prompts',
125
232
  '',
126
233
  agentPromptsSection(group),
@@ -60,6 +60,8 @@ export function parseSprintArgs(args = process.argv) {
60
60
  'no-full-scope-crap': { type: 'boolean', default: false },
61
61
  executor: { type: 'string' },
62
62
  cwd: { type: 'string' },
63
+ prd: { type: 'string' },
64
+ 'tech-spec': { type: 'string' },
63
65
  'recut-of': { type: 'string' },
64
66
  resume: { type: 'boolean', default: false },
65
67
  restart: { type: 'boolean', default: false },
@@ -86,6 +88,10 @@ export function parseSprintArgs(args = process.argv) {
86
88
  process.env.AGENT_WORKTREE_ROOT ||
87
89
  null,
88
90
  recutOf: parseTicketId(values['recut-of']),
91
+ // Story #4253: pre-resolved Epic linkages threaded by the /deliver
92
+ // fan-out so `story-init.js` can skip the per-Story `getEpic` round-trip.
93
+ prdId: parseTicketId(values.prd),
94
+ techSpecId: parseTicketId(values['tech-spec']),
89
95
  resume: values.resume ?? false,
90
96
  restart: values.restart ?? false,
91
97
  noEvidence: values['no-evidence'] ?? false,
@@ -53,6 +53,34 @@ export function gateExitCode(code, sig) {
53
53
  return sig ? 143 : 1;
54
54
  }
55
55
 
56
+ /**
57
+ * Biome's marker for "you handed me a path set, but every one of them is
58
+ * excluded by my own config (`files.includes` allowlist / `files.ignore` /
59
+ * `overrides`), so I processed nothing" — biome exits 1 in that case.
60
+ *
61
+ * The format gate scopes biome to the changed-file subset (Story #3410). When
62
+ * that subset is non-empty by extension but every path is biome-config-ignored,
63
+ * the scoped invocation reports this message and exits 1 even though
64
+ * `biome format .` over the whole tree is clean — a false negative for the
65
+ * gate (Story #4292). Detecting the marker lets the runner treat that exit as
66
+ * a clean skip rather than a formatting failure.
67
+ */
68
+ const BIOME_NO_FILES_PROCESSED =
69
+ 'No files were processed in the specified paths';
70
+
71
+ /**
72
+ * Whether biome's combined gate output carries the "No files were processed"
73
+ * marker. Pure function — no I/O. Exported for unit coverage (Story #4292).
74
+ *
75
+ * @param {string} output - Combined stdout/stderr captured from the gate child.
76
+ * @returns {boolean}
77
+ */
78
+ export function isBiomeNoFilesProcessed(output) {
79
+ return (
80
+ typeof output === 'string' && output.includes(BIOME_NO_FILES_PROCESSED)
81
+ );
82
+ }
83
+
56
84
  /**
57
85
  * Default async gate runner — used by `runCloseValidation` when no `runner`
58
86
  * is injected. Spawns the gate via `child_process.spawn`, prefixes every
@@ -66,13 +94,19 @@ export function gateExitCode(code, sig) {
66
94
  * `runCloseValidation` sees a non-zero status and folds it into the
67
95
  * already-recorded first-failure.
68
96
  *
97
+ * When `opts.tolerateNoFilesProcessed` is set (the biome-scoped format gate —
98
+ * Story #4292), a non-zero exit whose combined output carries biome's
99
+ * "No files were processed" marker is downgraded to a clean `status: 0`,
100
+ * because that exit means every config-included path was already excluded,
101
+ * not that formatting drifted.
102
+ *
69
103
  * @param {string} cmd
70
104
  * @param {string[]} args
71
- * @param {{ cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void, env?: Record<string, string> }} opts
105
+ * @param {{ cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void, env?: Record<string, string>, tolerateNoFilesProcessed?: boolean }} opts
72
106
  * @returns {Promise<{ status: number }>}
73
107
  */
74
108
  export function defaultGateRunner(cmd, args, opts = {}) {
75
- const { cwd, signal, gateName, log, env } = opts;
109
+ const { cwd, signal, gateName, log, env, tolerateNoFilesProcessed } = opts;
76
110
  const child = spawn(cmd, args, {
77
111
  cwd,
78
112
  shell: process.platform === 'win32',
@@ -85,13 +119,35 @@ export function defaultGateRunner(cmd, args, opts = {}) {
85
119
  const prefix = gateName ? `[${gateName}] ` : '';
86
120
  const emit =
87
121
  typeof log === 'function' ? log : (m) => process.stdout.write(`${m}\n`);
88
- pipePrefixed(child.stdout, prefix, emit);
89
- pipePrefixed(child.stderr, prefix, emit);
122
+ // Capture the combined output only when we may need to inspect it for the
123
+ // biome "No files were processed" marker — otherwise the stream is purely
124
+ // piped through to the operator (no retained buffer).
125
+ let captured = '';
126
+ const tap = tolerateNoFilesProcessed
127
+ ? (line) => {
128
+ captured += `${line}\n`;
129
+ emit(line);
130
+ }
131
+ : emit;
132
+ pipePrefixed(child.stdout, prefix, tap);
133
+ pipePrefixed(child.stderr, prefix, tap);
90
134
  const detach = attachGateAbortHandler(child, signal);
91
135
  return new Promise((resolve) => {
92
136
  child.on('exit', (code, sig) => {
93
137
  detach();
94
- resolve({ status: gateExitCode(code, sig) });
138
+ const status = gateExitCode(code, sig);
139
+ if (
140
+ status !== 0 &&
141
+ tolerateNoFilesProcessed &&
142
+ isBiomeNoFilesProcessed(captured)
143
+ ) {
144
+ emit(
145
+ `${prefix}↳ biome processed zero files (all changed paths are config-ignored); treating as a clean skip`,
146
+ );
147
+ resolve({ status: 0 });
148
+ return;
149
+ }
150
+ resolve({ status });
95
151
  });
96
152
  child.on('error', () => {
97
153
  detach();