mandrel 2.59.0 → 2.60.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 (97) hide show
  1. package/.agents/README.md +11 -9
  2. package/.agents/agents/acceptance-critic.md +24 -43
  3. package/.agents/agents/story-worker.md +18 -19
  4. package/.agents/docs/SDLC.md +6 -6
  5. package/.agents/docs/agentrc-reference.json +1 -2
  6. package/.agents/docs/configuration.md +29 -46
  7. package/.agents/docs/quality-gates.md +8 -4
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +4 -5
  10. package/.agents/rules/ci-remediation.md +41 -8
  11. package/.agents/rules/known-tooling-behavior.md +65 -15
  12. package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
  13. package/.agents/schemas/agentrc.schema.json +6 -11
  14. package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
  15. package/.agents/scripts/README.md +11 -1
  16. package/.agents/scripts/acceptance-eval.js +25 -27
  17. package/.agents/scripts/ceremony-derive.js +15 -10
  18. package/.agents/scripts/check-context-budget.js +148 -228
  19. package/.agents/scripts/check-schema-references.js +5 -3
  20. package/.agents/scripts/check-workflow-citations.js +33 -147
  21. package/.agents/scripts/coverage-capture.js +7 -4
  22. package/.agents/scripts/deliver-light.js +41 -100
  23. package/.agents/scripts/deliver-run.js +631 -0
  24. package/.agents/scripts/file-ci-gap.js +59 -11
  25. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
  26. package/.agents/scripts/lib/changed-files.js +30 -0
  27. package/.agents/scripts/lib/config/delivery-routing.js +5 -4
  28. package/.agents/scripts/lib/config/explain.js +1 -3
  29. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
  30. package/.agents/scripts/lib/config-resolver.js +1 -0
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
  32. package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
  33. package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
  34. package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
  35. package/.agents/scripts/lib/doc-tiers.js +4 -2
  36. package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
  37. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
  38. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  39. package/.agents/scripts/lib/gh-exec.js +160 -0
  40. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  41. package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
  42. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
  43. package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
  44. package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
  45. package/.agents/scripts/lib/orchestration/plan-context.js +13 -25
  46. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
  47. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +76 -95
  48. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +35 -18
  49. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
  50. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
  51. package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
  52. package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
  53. package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
  54. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
  55. package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
  56. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
  57. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
  58. package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
  59. package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
  60. package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
  61. package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
  62. package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
  63. package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
  64. package/.agents/scripts/lib/story-body/story-body.js +83 -29
  65. package/.agents/scripts/lib/templates/decomposer-prompts.js +7 -15
  66. package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
  67. package/.agents/scripts/merge-baseline.js +4 -5
  68. package/.agents/scripts/plan-context.js +117 -28
  69. package/.agents/scripts/plan-persist.js +79 -28
  70. package/.agents/scripts/plan-run-epilogue.js +11 -8
  71. package/.agents/scripts/pr-watch-with-update.js +9 -2
  72. package/.agents/scripts/run-verify.js +13 -6
  73. package/.agents/scripts/single-story-init.js +7 -57
  74. package/.agents/scripts/stories-wave-tick.js +160 -26
  75. package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
  76. package/.agents/skills/skills.index.json +2 -2
  77. package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
  78. package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
  79. package/.agents/workflows/helpers/code-review.md +4 -2
  80. package/.agents/workflows/helpers/deliver-digest.md +31 -24
  81. package/.agents/workflows/helpers/deliver-light.md +92 -101
  82. package/.agents/workflows/helpers/deliver-reference.md +116 -100
  83. package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
  84. package/.agents/workflows/helpers/deliver-story.md +17 -18
  85. package/.agents/workflows/helpers/plan-reference.md +65 -54
  86. package/.agents/workflows/mandrel-deliver.md +47 -31
  87. package/.agents/workflows/mandrel-plan.md +22 -21
  88. package/.agents/workflows/mandrel-update.md +36 -21
  89. package/docs/CHANGELOG.md +35 -0
  90. package/lib/cli/update.js +376 -17
  91. package/lib/migrations/index.js +2 -0
  92. package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
  93. package/package.json +2 -1
  94. package/.agents/schemas/model-attribution.schema.json +0 -53
  95. package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
  96. package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
  97. package/.agents/scripts/lib/orchestration/structured-comment-parser.js +0 -67
@@ -0,0 +1,631 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * deliver-run.js — one beat of a multi-Story `/mandrel-deliver` run
5
+ * (Story #5345).
6
+ *
7
+ * The multi-Story path used to be a hand-driven protocol: loop
8
+ * `stories-wave-tick.js`, keep an append-only `--dispatched` list across
9
+ * beats, paste a `node --input-type=module -e` block to build each worker's
10
+ * checklist, and remember to add `--merge-watch-mode async` to every close
11
+ * because close cannot see run topology. Four pieces of bookkeeping, all of
12
+ * them carried in the session's head, each of them silently wrong when
13
+ * forgotten — a dropped `--dispatched` id joins a second worker to a live
14
+ * branch, and a forgotten async flag serializes the run's merge waits.
15
+ *
16
+ * This CLI is that bookkeeping, scripted. One beat per invocation:
17
+ *
18
+ * 1. Tick with `--probe-live`, seeding `dispatched` from the **run ledger**
19
+ * rather than from the caller (`<tempRoot>/run-<id>/ledger.json`).
20
+ * 2. Write one dispatch prompt per ready Story — id, `workCwd` conventions,
21
+ * docs digest path, checklist path, change-set discipline — so the
22
+ * session's spawn is "use this file as the prompt" and nothing else.
23
+ * 3. Print the exact `single-story-close.js` command for every hand-off,
24
+ * with `--merge-watch-mode async` decided from run topology here, where
25
+ * the topology is known.
26
+ *
27
+ * Stdout is one compact JSON envelope (`kind: "deliver-run-beat"`). The
28
+ * tick's exit-code contract is preserved byte for byte — 0 ok · 1 input
29
+ * error · 2 cycle · 3 wedged · 4 blocked — because the loop's stopping rules
30
+ * are the tick's and this script must not invent a second dialect of them.
31
+ *
32
+ * The ledger is append-only on purpose, which is also its one sharp edge: an
33
+ * id whose spawn never reached `single-story-init.js` stays in it, is withheld
34
+ * as in flight on every later beat, and the run reads as "waiting" forever.
35
+ * The beat therefore reports every ledgered id live state still calls
36
+ * `agent::ready` in `stalledDispatch[]` with the recovery in
37
+ * `stalledDispatchReason`. It never auto-releases one: a slow init and a dead
38
+ * spawn are indistinguishable at this altitude, and releasing the first joins
39
+ * a second worker to a live branch (Story #5363).
40
+ *
41
+ * Scheduling itself is untouched: the ready set, the concurrency cap, the
42
+ * footprint guard and the foreign-lease withholding all come from
43
+ * `stories-wave-tick.js#runProbedStoriesWaveTick`. This is a ledger, a
44
+ * prompt writer and a command renderer around that one beat.
45
+ */
46
+
47
+ import { createHash } from 'node:crypto';
48
+ import fs from 'node:fs';
49
+ import path from 'node:path';
50
+ import { parseArgs } from 'node:util';
51
+ import { buildDispatchChecklist } from './lib/audit-suite/index.js';
52
+ import { runAsCli } from './lib/cli-utils.js';
53
+ import { getPaths, resolveConfig } from './lib/config-resolver.js';
54
+ import { Logger } from './lib/Logger.js';
55
+ import { ensureDocsDigest } from './lib/orchestration/docs-digest.js';
56
+ import { parse as parseStoryBody } from './lib/story-body/story-body.js';
57
+ import { expandIdList } from './lib/util/parse-id-list.js';
58
+ import { runProbedStoriesWaveTick } from './stories-wave-tick.js';
59
+
60
+ const INPUT_ERROR_EXIT_CODE = 1;
61
+
62
+ const USAGE = {
63
+ invocation:
64
+ 'node .agents/scripts/deliver-run.js --stories <ids> [--handoff <id>]... [--concurrency <n>] [--run-id <id>] [--cwd <path>]',
65
+ summary:
66
+ 'Run one beat of a multi-Story delivery: tick from live state, write a dispatch prompt per ready Story, and render the close command for each hand-off. Prints one JSON envelope.',
67
+ flags: [
68
+ [
69
+ '--stories <ids>',
70
+ 'Story ids in the run. Singles, commas and inclusive A-B ranges (5340,5342-5345).',
71
+ ],
72
+ [
73
+ '--handoff <id>',
74
+ 'A Story whose worker has pushed its branch. Repeatable. Each one gets a close[] entry carrying the exact single-story-close.js command.',
75
+ ],
76
+ [
77
+ '--concurrency <n>',
78
+ 'Per-beat concurrency override, forwarded to the tick. Omit it so delivery.deliverRunner.concurrencyCap (and any .agentrc.local.json override) wins.',
79
+ ],
80
+ [
81
+ '--run-id <id>',
82
+ 'Pin the run directory under <tempRoot>. Default: a stable digest of the Story id set, so every beat of the same run finds the same ledger.',
83
+ ],
84
+ ['--cwd <path>', 'Main checkout. Default: the current directory.'],
85
+ ],
86
+ notes: [
87
+ 'The run ledger (<tempRoot>/run-<id>/ledger.json) records every id handed out\nas ready, so a repeat beat withholds it with no --dispatched bookkeeping from\nthe caller. It is additive: the tick still filters it against live state, so a\nledgered id that has since gone agent::done is dropped for you.',
88
+ 'A ledgered id that live state still reports as agent::ready is named in\nstalledDispatch[], with stalledDispatchReason carrying the recovery. It is a\nreport, never a release: a slow init and a dead spawn read alike here, so the\noperator edits the ledger and re-beats with --run-id.',
89
+ '--merge-watch-mode async is added to every close command when the run holds\nmore than one Story, and omitted for a run of one. Close sees a single Story\nand cannot make that call for itself.',
90
+ 'Exit codes:\n 0 beat emitted\n 1 input error\n 2 dependency cycle (cycleError)\n 3 wedged\n 4 blocked — the HITL pause; stop the loop, do not poll',
91
+ ],
92
+ };
93
+
94
+ /**
95
+ * Derive the run's stable identity from its Story id set.
96
+ *
97
+ * The identity must be the same on every beat of one run (so the ledger is
98
+ * found again) and different across runs (so two concurrent deliveries do not
99
+ * share a dispatched list). The sorted id set is the only thing that satisfies
100
+ * both — a timestamp fails the first, and a single id fails the second.
101
+ *
102
+ * @param {number[]} ids
103
+ * @returns {string} an 8-hex-character digest
104
+ */
105
+ export function deriveRunId(ids) {
106
+ const key = [...new Set(ids)].sort((a, b) => a - b).join(',');
107
+ return createHash('sha1').update(key).digest('hex').slice(0, 8);
108
+ }
109
+
110
+ /**
111
+ * Read the run ledger's dispatched ids, tolerating absence and corruption.
112
+ *
113
+ * A ledger that cannot be read is treated as empty rather than fatal: the
114
+ * consequence is one extra beat of the init window (which `--dispatched`
115
+ * existed to close), whereas refusing the beat would strand a live run on a
116
+ * bookkeeping artifact.
117
+ *
118
+ * @param {string} ledgerPath
119
+ * @param {{ readFileFn?: (p: string, enc: string) => string }} [deps]
120
+ * @returns {number[]}
121
+ */
122
+ export function readLedgerDispatched(
123
+ ledgerPath,
124
+ { readFileFn = fs.readFileSync } = {},
125
+ ) {
126
+ let raw;
127
+ try {
128
+ raw = readFileFn(ledgerPath, 'utf8');
129
+ } catch {
130
+ return [];
131
+ }
132
+ try {
133
+ const parsed = JSON.parse(raw);
134
+ const ids = Array.isArray(parsed?.dispatched) ? parsed.dispatched : [];
135
+ return ids.filter((id) => Number.isInteger(id) && id > 0);
136
+ } catch {
137
+ return [];
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Persist the union of the previously-ledgered ids and the ids handed out on
143
+ * this beat. Append-only by construction: nothing here removes an id, so the
144
+ * caller cannot get the "forgot to re-list one" failure `--dispatched` had.
145
+ *
146
+ * @param {object} args
147
+ * @param {string} args.ledgerPath
148
+ * @param {string} args.runId
149
+ * @param {number[]} args.stories
150
+ * @param {number[]} args.dispatched ids already ledgered
151
+ * @param {number[]} args.ready ids handed out this beat
152
+ * @param {{ writeFileFn?: (p: string, c: string, enc: string) => void }} [deps]
153
+ * @returns {number[]} the persisted dispatched set
154
+ */
155
+ function writeLedger(
156
+ { ledgerPath, runId, stories, dispatched, ready },
157
+ { writeFileFn = fs.writeFileSync } = {},
158
+ ) {
159
+ const merged = [...new Set([...dispatched, ...ready])].sort((a, b) => a - b);
160
+ const payload = {
161
+ kind: 'deliver-run-ledger',
162
+ runId,
163
+ stories,
164
+ dispatched: merged,
165
+ updatedAt: new Date().toISOString(),
166
+ };
167
+ writeFileFn(ledgerPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
168
+ return merged;
169
+ }
170
+
171
+ /**
172
+ * Render the close command for one hand-off.
173
+ *
174
+ * `--merge-watch-mode async` is a **run-topology** decision, which is why it
175
+ * is made here: the close process sees one Story and cannot tell whether a
176
+ * sibling is queued behind its merge wait. On a multi-Story run the serialized
177
+ * close tail is the dominant cost, and each synchronous close holds the
178
+ * foreground for its full merge wait before the next may start; on a run of
179
+ * one there is no sibling to unblock and the sync default reaches `landed`
180
+ * fastest.
181
+ *
182
+ * @param {{ storyId: number, mainRepo: string, storyCount: number }} args
183
+ * @returns {string}
184
+ */
185
+ export function renderCloseCommand({ storyId, mainRepo, storyCount }) {
186
+ const parts = [
187
+ 'node',
188
+ path.join(mainRepo, '.agents', 'scripts', 'single-story-close.js'),
189
+ `--story ${storyId}`,
190
+ `--cwd ${mainRepo}`,
191
+ ];
192
+ if (storyCount > 1) parts.push('--merge-watch-mode async');
193
+ return parts.join(' ');
194
+ }
195
+
196
+ /**
197
+ * Render one ready Story's dispatch prompt — the whole spawn payload, so the
198
+ * session's `Agent` call is this file and nothing else.
199
+ *
200
+ * @param {object} args
201
+ * @param {number} args.storyId
202
+ * @param {string} args.mainRepo
203
+ * @param {string|null} args.docsDigestPath
204
+ * @param {string|null} args.checklistPath
205
+ * @returns {string} markdown
206
+ */
207
+ export function renderDispatchPrompt({
208
+ storyId,
209
+ mainRepo,
210
+ docsDigestPath,
211
+ checklistPath,
212
+ }) {
213
+ const lines = [
214
+ `# Deliver Story #${storyId}`,
215
+ '',
216
+ `Main checkout: \`${mainRepo}\`.`,
217
+ '',
218
+ `You own Steps 0 through 2.5 of \`.agents/workflows/helpers/deliver-story.md\`.`,
219
+ 'The orchestrator owns Step 3 (close): do not open a PR, do not run',
220
+ '`single-story-close.js`, and do not compose a terminal envelope.',
221
+ '',
222
+ '## Reads',
223
+ '',
224
+ '1. `.agents/workflows/helpers/deliver-digest.md` — once, first.',
225
+ '2. `.agents/workflows/helpers/deliver-story.md` — the steps.',
226
+ `3. The Story body (\`gh issue view ${storyId}\`) — its \`## Spec\`,`,
227
+ ' `acceptance[]` and `verify[]` are the contract.',
228
+ '',
229
+ `- Docs digest: ${docsDigestPath ? `\`${docsDigestPath}\`` : 'none (project.docsContextFiles is unset) — no mandatory docs read'}`,
230
+ `- Write-time checklist: ${checklistPath ? `\`${checklistPath}\`` : 'none matched this footprint — the maker-blind close-scope pass still covers it'}`,
231
+ '',
232
+ '## Worktree',
233
+ '',
234
+ 'Initialize from the main checkout, synchronously, at the maximum Bash',
235
+ 'timeout — never in the background:',
236
+ '',
237
+ '```bash',
238
+ `node ${path.join(mainRepo, '.agents', 'scripts', 'single-story-init.js')} --story ${storyId}`,
239
+ '```',
240
+ '',
241
+ 'Capture `workCwd` from its envelope and prefix **every** path-based',
242
+ 'Read/Edit/Write with that absolute worktree root — `cd` alone does not',
243
+ 'scope those tools. `remoteVerified: false` → flip `agent::blocked` quoting',
244
+ '`remoteProbe.detail` and stop.',
245
+ '',
246
+ '## Change-set discipline',
247
+ '',
248
+ 'Derive the change set, the level and the ceremony with **one** call, and',
249
+ 'hand that one list to the verdict owner — never let a critic re-run its',
250
+ 'own `git diff`:',
251
+ '',
252
+ '```bash',
253
+ `node ${path.join(mainRepo, '.agents', 'scripts', 'ceremony-derive.js')} --story ${storyId} --cwd <workCwd>`,
254
+ '```',
255
+ '',
256
+ '## Hand-off',
257
+ '',
258
+ 'Run the bounded acceptance self-eval (digest § 4), the one credited suite',
259
+ 'run (digest § 5), then push `story-' + storyId + '` to `origin` and',
260
+ 'confirm the remote ref moved. Return: Story id, `workCwd`, branch, pushed',
261
+ 'head SHA, the self-eval verdict, and the `verify[]` evidence. Say the',
262
+ 'branch is pushed and unclosed.',
263
+ '',
264
+ ];
265
+ return lines.join('\n');
266
+ }
267
+
268
+ /**
269
+ * Build one ready Story's dispatch prompt file and return its entry.
270
+ *
271
+ * @param {object} args
272
+ * @param {number} args.storyId
273
+ * @param {string} args.body the Story body the probe already fetched
274
+ * @param {string} args.runTempDir
275
+ * @param {string} args.mainRepo
276
+ * @param {string|null} args.docsDigestPath
277
+ * @param {object} [deps]
278
+ * @returns {{ id: number, promptPath: string }}
279
+ */
280
+ function buildDispatchEntry(
281
+ { storyId, body, runTempDir, mainRepo, docsDigestPath },
282
+ {
283
+ buildChecklistFn = buildDispatchChecklist,
284
+ writeFileFn = fs.writeFileSync,
285
+ } = {},
286
+ ) {
287
+ let changes = [];
288
+ let references = [];
289
+ try {
290
+ // `parse` returns `{ body, warnings, info }` — the path entries live on
291
+ // `.body`, not at the top level. The retired `node -e` snippet in
292
+ // `helpers/deliver-reference.md` destructured the top level and so built
293
+ // every checklist from an empty footprint.
294
+ const { body: parsed } = parseStoryBody(body ?? '');
295
+ changes = parsed?.changes ?? [];
296
+ references = parsed?.references ?? [];
297
+ } catch {
298
+ // An unparseable body costs a footprint-matched checklist, never the
299
+ // dispatch: the worker reads the real Story body itself, and close-scope
300
+ // lens coverage runs maker-blind regardless.
301
+ changes = [];
302
+ references = [];
303
+ }
304
+ const { checklistPath } = buildChecklistFn({
305
+ storyId,
306
+ changes,
307
+ references,
308
+ runTempDir,
309
+ });
310
+ const promptPath = path.join(runTempDir, `dispatch-${storyId}.md`);
311
+ writeFileFn(
312
+ promptPath,
313
+ renderDispatchPrompt({
314
+ storyId,
315
+ mainRepo,
316
+ docsDigestPath,
317
+ checklistPath,
318
+ }),
319
+ 'utf8',
320
+ );
321
+ return { id: storyId, promptPath };
322
+ }
323
+
324
+ /**
325
+ * Flatten the two withhold reports the tick emits into one list, so an
326
+ * unfilled dispatch slot is explained in a single place.
327
+ *
328
+ * @param {object} envelope the tick envelope
329
+ * @returns {Array<{id: number, blockedBy: number, reason: string, paths: string[]}>}
330
+ */
331
+ function collectWithheld(envelope) {
332
+ const reservation = envelope?.inFlightReservation?.withheld ?? [];
333
+ const guard = envelope?.footprintGuard?.withheld ?? [];
334
+ return [
335
+ ...reservation.map((w) => ({
336
+ id: w.id,
337
+ blockedBy: w.blockedBy,
338
+ reason: w.reason ?? 'in-flight-earlier-beat',
339
+ paths: w.paths ?? [],
340
+ })),
341
+ ...guard.map((w) => ({
342
+ id: w.id,
343
+ blockedBy: w.blockedBy,
344
+ reason: 'beat-peer',
345
+ paths: w.paths ?? [],
346
+ })),
347
+ ];
348
+ }
349
+
350
+ /**
351
+ * Render the operator-facing reason for the ledgered ids live state still
352
+ * reports as `agent::ready`.
353
+ *
354
+ * **Report, do not release.** A slow `single-story-init.js` and a spawn that
355
+ * died before reaching one look identical from here, and releasing the first
356
+ * re-dispatches a live Story onto its own branch — the exact failure the
357
+ * ledger exists to prevent. So the beat names the id and hands the operator
358
+ * the two things the call needs: where the ledger lives, and the flag that
359
+ * pins the run directory once they have edited it. Neither is derivable from
360
+ * the envelope's other fields, which is why both are spelled out here rather
361
+ * than left to a reader of the source.
362
+ *
363
+ * @param {number[]} ids
364
+ * @param {{ ledgerPath: string, runId: string }} run
365
+ * @returns {string|null} null when nothing is stalled
366
+ */
367
+ export function renderStalledDispatchReason(ids, { ledgerPath, runId }) {
368
+ if (!Array.isArray(ids) || ids.length === 0) return null;
369
+ const list = ids.map((id) => `#${id}`).join(', ');
370
+ const subject = ids.length === 1 ? 'it' : 'they';
371
+ return (
372
+ `${list}: handed out as ready on an earlier beat, but live state still ` +
373
+ `reports ${subject} as agent::ready. Either single-story-init.js is still ` +
374
+ 'running, or the spawn never reached it and the id is pinned in flight ' +
375
+ 'for every later beat. This beat does not release it — re-dispatching a ' +
376
+ 'live Story onto its own branch is the failure the ledger prevents. ' +
377
+ 'Confirm no worker is running, then remove the id from "dispatched" in ' +
378
+ `${ledgerPath} and beat again with --run-id ${runId} so the same run ` +
379
+ 'directory is reused.'
380
+ );
381
+ }
382
+
383
+ /**
384
+ * The input-error result, shaped like a beat envelope so a caller branching on
385
+ * `kind` never has to special-case the failure.
386
+ *
387
+ * @param {string} message
388
+ * @returns {{ envelope: object, exitCode: 1 }}
389
+ */
390
+ function inputError(message) {
391
+ return {
392
+ envelope: { kind: 'deliver-run-beat', inputError: message },
393
+ exitCode: INPUT_ERROR_EXIT_CODE,
394
+ };
395
+ }
396
+
397
+ /**
398
+ * Resolve the ids for `--stories` and `--handoff`, or the error to report.
399
+ *
400
+ * @param {{ stories?: string, handoff?: string[] }} args
401
+ * @returns {{ ids: number[]|null, handoffIds: number[], error: string|null }}
402
+ */
403
+ export function resolveRunIds({ stories, handoff }) {
404
+ const { ids, error } = expandIdList(stories, {
405
+ flag: '--stories',
406
+ prefix: '[deliver-run] ',
407
+ });
408
+ if (error) return { ids: null, handoffIds: [], error };
409
+ if (ids.length === 0) {
410
+ return {
411
+ ids: null,
412
+ handoffIds: [],
413
+ error:
414
+ '[deliver-run] --stories is required: node .agents/scripts/deliver-run.js --stories 5340,5341',
415
+ };
416
+ }
417
+ const raw = (Array.isArray(handoff) ? handoff : [handoff]).filter(
418
+ (v) => v != null && v !== '',
419
+ );
420
+ const handoffIds = [];
421
+ for (const token of raw) {
422
+ const expanded = expandIdList(String(token), {
423
+ flag: '--handoff',
424
+ prefix: '[deliver-run] ',
425
+ });
426
+ if (expanded.error)
427
+ return { ids: null, handoffIds: [], error: expanded.error };
428
+ handoffIds.push(...expanded.ids);
429
+ }
430
+ return { ids, handoffIds: [...new Set(handoffIds)], error: null };
431
+ }
432
+
433
+ /**
434
+ * Run one beat: tick, write the dispatch prompts, persist the ledger, render
435
+ * the close commands, and shape the envelope.
436
+ *
437
+ * Every collaborator is injectable so the beat is testable without a provider,
438
+ * a network call or the repository's own temp root.
439
+ *
440
+ * @param {object} args
441
+ * @param {string} args.stories raw `--stories` value
442
+ * @param {string[]} [args.handoff] raw `--handoff` values
443
+ * @param {string} [args.concurrency] raw `--concurrency` value
444
+ * @param {string} [args.runId] explicit run id
445
+ * @param {string} [args.cwd] main checkout
446
+ * @param {object} [args.config] pre-resolved config (test injection)
447
+ * @param {Function} [args.probe] tick probe seam (test injection)
448
+ * @param {Function} [args.context] tick provider-context seam
449
+ * @param {object} [deps]
450
+ * @returns {Promise<{ envelope: object, exitCode: number }>}
451
+ */
452
+ export async function runDeliverRunBeat(
453
+ {
454
+ stories,
455
+ handoff = [],
456
+ concurrency,
457
+ runId: runIdOverride,
458
+ cwd,
459
+ config,
460
+ probe,
461
+ context,
462
+ } = {},
463
+ deps = {},
464
+ ) {
465
+ const {
466
+ tickFn = runProbedStoriesWaveTick,
467
+ resolveConfigFn = resolveConfig,
468
+ ensureDocsDigestFn = ensureDocsDigest,
469
+ mkdirFn = fs.mkdirSync,
470
+ ...entryDeps
471
+ } = deps;
472
+
473
+ const { ids, handoffIds, error } = resolveRunIds({ stories, handoff });
474
+ if (error) return inputError(error);
475
+
476
+ const mainRepo = path.resolve(cwd ?? process.cwd());
477
+ const resolved = config ?? resolveConfigFn({ cwd: mainRepo });
478
+ const runId = runIdOverride ?? deriveRunId(ids);
479
+ const runTempDir = path.resolve(
480
+ mainRepo,
481
+ getPaths(resolved).tempRoot,
482
+ `run-${runId}`,
483
+ );
484
+ mkdirFn(runTempDir, { recursive: true });
485
+ const ledgerPath = path.join(runTempDir, 'ledger.json');
486
+ const ledgered = readLedgerDispatched(ledgerPath, entryDeps);
487
+
488
+ const {
489
+ envelope: tick,
490
+ exitCode,
491
+ records = [],
492
+ } = await tickFn({
493
+ stories,
494
+ concurrency,
495
+ // The ledger IS the dispatched list. It is additive, never authoritative:
496
+ // the probe unions it into the label-derived in-flight set and then
497
+ // filters it against live state, so an id that has since gone done is
498
+ // dropped rather than pinned in flight forever.
499
+ dispatched: ledgered.join(','),
500
+ cwd: mainRepo,
501
+ ...(config ? { config } : {}),
502
+ ...(probe ? { probe } : {}),
503
+ ...(context ? { context } : {}),
504
+ });
505
+
506
+ if (tick?.inputError) return inputError(tick.inputError);
507
+
508
+ const stalledDispatch = Array.isArray(tick.stalledDispatch)
509
+ ? tick.stalledDispatch
510
+ : [];
511
+
512
+ const readyIds = Array.isArray(tick.ready) ? tick.ready : [];
513
+ const digest = await ensureDocsDigestFn({
514
+ docsContextFiles: resolved?.project?.docsContextFiles,
515
+ docsRoot: getPaths(resolved).docsRoot,
516
+ outputPath: path.join(runTempDir, 'docs-digest.md'),
517
+ });
518
+ const docsDigestPath = digest?.outputPath ?? null;
519
+
520
+ const bodyById = new Map(
521
+ records.map((record) => [record.id, record.body ?? '']),
522
+ );
523
+ const ready = readyIds.map((storyId) =>
524
+ buildDispatchEntry(
525
+ {
526
+ storyId,
527
+ body: bodyById.get(storyId) ?? '',
528
+ runTempDir,
529
+ mainRepo,
530
+ docsDigestPath,
531
+ },
532
+ entryDeps,
533
+ ),
534
+ );
535
+
536
+ writeLedger(
537
+ { ledgerPath, runId, stories: ids, dispatched: ledgered, ready: readyIds },
538
+ entryDeps,
539
+ );
540
+
541
+ return {
542
+ envelope: {
543
+ kind: 'deliver-run-beat',
544
+ runId,
545
+ runTempDir,
546
+ stories: ids,
547
+ ready,
548
+ close: handoffIds.map((storyId) => ({
549
+ id: storyId,
550
+ command: renderCloseCommand({
551
+ storyId,
552
+ mainRepo,
553
+ storyCount: ids.length,
554
+ }),
555
+ })),
556
+ done: tick.epilogueDue === true,
557
+ doneStories: tick.done ?? [],
558
+ inFlight: tick.inFlight ?? 0,
559
+ concurrencyCap: tick.concurrencyCap ?? null,
560
+ docsDigestPath,
561
+ cycleError: tick.cycleError ?? null,
562
+ wedged: tick.wedged ?? null,
563
+ blocked: tick.blocked ?? [],
564
+ blockedReason: tick.blockedReason ?? null,
565
+ foreignHeld: tick.foreignHeld ?? [],
566
+ // Its own reason, deliberately beside `withheld[]` rather than inside
567
+ // it: a footprint withhold names a blocking peer Story and its colliding
568
+ // paths, and a stalled dispatch has neither — it names a recovery the
569
+ // operator owns.
570
+ stalledDispatch,
571
+ stalledDispatchReason: renderStalledDispatchReason(stalledDispatch, {
572
+ ledgerPath,
573
+ runId,
574
+ }),
575
+ withheld: collectWithheld(tick),
576
+ },
577
+ exitCode,
578
+ };
579
+ }
580
+
581
+ /**
582
+ * Parse argv into the beat's inputs.
583
+ *
584
+ * @param {string[]} argv
585
+ * @returns {{ stories?: string, handoff: string[], concurrency?: string, runId?: string, cwd?: string }}
586
+ */
587
+ function parseArgv(argv) {
588
+ const { values } = parseArgs({
589
+ args: argv,
590
+ options: {
591
+ stories: { type: 'string' },
592
+ handoff: { type: 'string', multiple: true },
593
+ concurrency: { type: 'string' },
594
+ 'run-id': { type: 'string' },
595
+ cwd: { type: 'string' },
596
+ },
597
+ strict: false,
598
+ allowPositionals: false,
599
+ });
600
+ return {
601
+ stories: values.stories,
602
+ handoff: values.handoff ?? [],
603
+ concurrency: values.concurrency,
604
+ runId: values['run-id'],
605
+ cwd: values.cwd,
606
+ };
607
+ }
608
+
609
+ async function main(argv) {
610
+ const { envelope, exitCode } = await runDeliverRunBeat(parseArgv(argv));
611
+ process.stdout.write(`${JSON.stringify(envelope)}\n`);
612
+ if (exitCode !== 0) {
613
+ Logger.error(
614
+ `deliver-run: ${
615
+ envelope.inputError ??
616
+ envelope.cycleError ??
617
+ envelope.blockedReason ??
618
+ envelope.wedged?.reason ??
619
+ 'error'
620
+ }`,
621
+ );
622
+ }
623
+ return exitCode;
624
+ }
625
+
626
+ runAsCli(import.meta.url, () => main(process.argv.slice(2)), {
627
+ source: 'deliver-run',
628
+ propagateExitCode: true,
629
+ errorPrefix: '[deliver-run] ❌ Fatal error',
630
+ usage: USAGE,
631
+ });