@renzrollon/interlock 1.0.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 (125) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +58 -0
  3. package/CHANGELOG.md +380 -0
  4. package/LICENSE +21 -0
  5. package/README.md +358 -0
  6. package/agents/ping.md +8 -0
  7. package/agents/worker.md +8 -0
  8. package/bin/interlock +2655 -0
  9. package/bin/interlock-graph +6 -0
  10. package/bin/interlock-run +508 -0
  11. package/bin/interlock-ship-acp +39 -0
  12. package/hooks/_shared.mjs +162 -0
  13. package/hooks/guard-commit.mjs +77 -0
  14. package/hooks/guard-tasks.mjs +107 -0
  15. package/hooks/guard-tests.mjs +63 -0
  16. package/hooks/preflight.mjs +107 -0
  17. package/lib/artifacts.mjs +316 -0
  18. package/lib/autonomy.mjs +149 -0
  19. package/lib/conformance.mjs +132 -0
  20. package/lib/doctor.mjs +1176 -0
  21. package/lib/drift.mjs +409 -0
  22. package/lib/eval-history.mjs +304 -0
  23. package/lib/evals-calibrate.mjs +317 -0
  24. package/lib/evals-capture.mjs +437 -0
  25. package/lib/evals-promote.mjs +257 -0
  26. package/lib/evals-triage.mjs +216 -0
  27. package/lib/findings.mjs +340 -0
  28. package/lib/graph/cli.mjs +266 -0
  29. package/lib/graph/extract/config-json.mjs +74 -0
  30. package/lib/graph/extract/create-extractor.mjs +35 -0
  31. package/lib/graph/extract/memory.mjs +99 -0
  32. package/lib/graph/extract/openspec.mjs +80 -0
  33. package/lib/graph/extract/python.mjs +137 -0
  34. package/lib/graph/extract/regex-fallback.mjs +187 -0
  35. package/lib/graph/extract/shell.mjs +71 -0
  36. package/lib/graph/extract/typescript.mjs +217 -0
  37. package/lib/graph/extract/walk.mjs +272 -0
  38. package/lib/graph/format.mjs +51 -0
  39. package/lib/graph/graph/build.mjs +374 -0
  40. package/lib/graph/graph/query.mjs +249 -0
  41. package/lib/graph/graph/report.mjs +122 -0
  42. package/lib/graph/graph/update.mjs +47 -0
  43. package/lib/graph/paths.mjs +66 -0
  44. package/lib/graph/retrieve/context.mjs +86 -0
  45. package/lib/graph/retrieve/docs-index.mjs +116 -0
  46. package/lib/graph/retrieve/markdown.mjs +246 -0
  47. package/lib/host/acp.mjs +551 -0
  48. package/lib/host/claude-cli.mjs +285 -0
  49. package/lib/host/codex.mjs +231 -0
  50. package/lib/host/model-map.mjs +129 -0
  51. package/lib/host/qwen.mjs +176 -0
  52. package/lib/host/registry.mjs +213 -0
  53. package/lib/host.mjs +393 -0
  54. package/lib/ledger.mjs +444 -0
  55. package/lib/limits.mjs +525 -0
  56. package/lib/locators.mjs +99 -0
  57. package/lib/merge-lanes.mjs +148 -0
  58. package/lib/metrics.mjs +99 -0
  59. package/lib/notify.mjs +229 -0
  60. package/lib/outcomes.mjs +560 -0
  61. package/lib/plan-fingerprint.mjs +570 -0
  62. package/lib/project-slug.mjs +18 -0
  63. package/lib/prompts/commit.mjs +26 -0
  64. package/lib/prompts/dimensions.mjs +70 -0
  65. package/lib/prompts/handoff.mjs +86 -0
  66. package/lib/prompts/implementer.mjs +150 -0
  67. package/lib/prompts/index.mjs +250 -0
  68. package/lib/prompts/planner.mjs +70 -0
  69. package/lib/prompts/remediate.mjs +137 -0
  70. package/lib/prompts/replan.mjs +23 -0
  71. package/lib/prompts/review.mjs +107 -0
  72. package/lib/prompts/schemas.mjs +206 -0
  73. package/lib/prompts/stage.mjs +48 -0
  74. package/lib/prompts/verify.mjs +88 -0
  75. package/lib/ready.mjs +812 -0
  76. package/lib/receipt.mjs +434 -0
  77. package/lib/remediate.mjs +275 -0
  78. package/lib/report-html.mjs +623 -0
  79. package/lib/report.mjs +1124 -0
  80. package/lib/review-core.mjs +713 -0
  81. package/lib/risk.mjs +370 -0
  82. package/lib/run-log.mjs +1004 -0
  83. package/lib/run-paths.mjs +239 -0
  84. package/lib/run.mjs +2921 -0
  85. package/lib/ship-stage.mjs +223 -0
  86. package/lib/spill.mjs +150 -0
  87. package/lib/surface.mjs +195 -0
  88. package/lib/verify.mjs +1074 -0
  89. package/lib/waves.mjs +2661 -0
  90. package/package.json +40 -0
  91. package/shared/CONTEXT-HYGIENE.md +84 -0
  92. package/shared/DECISION-LEDGER.md +141 -0
  93. package/shared/EXPLORE-BRIEF.md +96 -0
  94. package/shared/INVARIANT-SWEEP.md +57 -0
  95. package/shared/SESSION-SHAPES.md +261 -0
  96. package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
  97. package/shared/TEST-PROFILE.md +204 -0
  98. package/shared/TOOL-ECONOMY.md +155 -0
  99. package/skills/bootstrap/SKILL.md +226 -0
  100. package/skills/commit/SKILL.md +99 -0
  101. package/skills/dispatch/SKILL.md +78 -0
  102. package/skills/docs-digest/SKILL.md +109 -0
  103. package/skills/evals/SKILL.md +160 -0
  104. package/skills/explain-code/SKILL.md +42 -0
  105. package/skills/explain-code/commit-teachin.md +136 -0
  106. package/skills/explain-code/module-walkthrough.md +358 -0
  107. package/skills/explore/SKILL.md +159 -0
  108. package/skills/fix-tests/SKILL.md +120 -0
  109. package/skills/graph/SKILL.md +136 -0
  110. package/skills/manual-test-plan/SKILL.md +121 -0
  111. package/skills/mr/SKILL.md +170 -0
  112. package/skills/report/SKILL.md +121 -0
  113. package/skills/review-artifacts/SKILL.md +127 -0
  114. package/skills/review-code/REVIEW.template.md +49 -0
  115. package/skills/review-code/SKILL.md +156 -0
  116. package/skills/review-code/dimensions/architecture.md +10 -0
  117. package/skills/review-code/dimensions/devops.md +12 -0
  118. package/skills/review-code/dimensions/language.md +12 -0
  119. package/skills/review-code/dimensions/qa.md +12 -0
  120. package/skills/review-code/dimensions/security.md +12 -0
  121. package/skills/review-code/dimensions/technical-lead.md +19 -0
  122. package/skills/ship/SKILL.md +67 -0
  123. package/skills/spec/SKILL.md +264 -0
  124. package/skills/spec/continuity.md +66 -0
  125. package/workflows/ship.js +565 -0
package/lib/run.mjs ADDED
@@ -0,0 +1,2921 @@
1
+ // The run program — every step of the lean ship loop, emitted by the CLI.
2
+ //
3
+ // Interlock has two drivers of one loop: `workflows/ship.js` on the Claude Code
4
+ // Workflow runtime and `bin/interlock-ship-acp` over ACP. Each used to carry its
5
+ // own copy of the loop's control flow and of the text every agent is handed, and
6
+ // policy was shared only by smuggling marked blocks of one driver's source
7
+ // through `new Function`. Every new classifier field, banner or prompt sentence
8
+ // had to be added twice, and this repository's own memory records the times it
9
+ // was added once.
10
+ //
11
+ // So the CLI emits the whole program. A step names the agents to spawn — with
12
+ // their label, model, effort, agent type, tool allowlist, result schema and
13
+ // briefing — and the exact `interlock` argv to call once those agents return.
14
+ // The interpreter on either host is: spawn everything in `spawns` in parallel,
15
+ // write the results, call `then.argv`, repeat until `then` is null. A driver
16
+ // never branches on a flag, a mode, a count or a verdict (design D3).
17
+ //
18
+ // This module decides; it does not spawn, and it does not print. It reads and
19
+ // writes the run's own files and calls the pure state machine, the planner, the
20
+ // verifier and the receipt builder. The three helpers it cannot import — the
21
+ // trajectory loggers and the git readers that live in `bin/interlock` — arrive
22
+ // through `ctx.deps` rather than being written a second time here.
23
+
24
+ import { createHash } from 'node:crypto'
25
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
26
+ import { dirname, join, resolve } from 'node:path'
27
+
28
+ import { LIMITS, EFFORT } from './limits.mjs'
29
+ import { spawnPrefix } from './host.mjs'
30
+ import { PLUGIN_ROOT } from './doctor.mjs'
31
+ import { RED_SECTION_MARKER, inspectChange, planCoverage, tickTasks } from './artifacts.mjs'
32
+ import { FINGERPRINT_PATH, computeFingerprint, resolvePlanReuse, writeFingerprint } from './plan-fingerprint.mjs'
33
+ import { clearStage } from './ship-stage.mjs'
34
+ import { appendRunLogEvent, checkRunLog } from './run-log.mjs'
35
+ import { appendOutcome, observedFromReceipt } from './outcomes.mjs'
36
+ import { readPredictedPaths, readTouchedPaths } from './run-paths.mjs'
37
+ import {
38
+ buildReceipt,
39
+ closingFromWaveState,
40
+ degradationLines,
41
+ formatRunSummary,
42
+ summaryHeadline,
43
+ tailFromManifest
44
+ } from './receipt.mjs'
45
+ import { detectUnarchived } from './drift.mjs'
46
+ import { projectSlug } from './project-slug.mjs'
47
+ import { composeCloseMessage, postNtfy, readNotifyConfig } from './notify.mjs'
48
+ import { SKIP_REASONS, planVerification, judgeVerification } from './verify.mjs'
49
+ import { resolveReview, readReviewPolicy } from './review-core.mjs'
50
+ import { writeReviewMetrics } from './metrics.mjs'
51
+ import { planRemediation } from './remediate.mjs'
52
+ import { classifySurface } from './surface.mjs'
53
+ import { buildChecklist } from './conformance.mjs'
54
+ import { record as recordAutonomy } from './autonomy.mjs'
55
+ import {
56
+ applyReplan,
57
+ batchOutcomes,
58
+ createRunState,
59
+ laneEffort,
60
+ laneLabel,
61
+ laneModel,
62
+ nextStep,
63
+ planWaves,
64
+ recordBatchResult,
65
+ recordVerifyResult
66
+ } from './waves.mjs'
67
+ import {
68
+ assembleCommitPrompt,
69
+ assembleHandoffPrompt,
70
+ assembleImplementerPrompt,
71
+ assemblePlannerPrompt,
72
+ assembleRemediatePrompt,
73
+ assembleReplanPrompt,
74
+ assembleReviewPrompt,
75
+ assembleVerdictPrompt,
76
+ assembleVerifyPrompt,
77
+ publishStageLine,
78
+ selectDimensions
79
+ } from './prompts/index.mjs'
80
+ import {
81
+ COMMIT_SCHEMA,
82
+ HANDOFF_RESULT_SCHEMA,
83
+ LANE_SCHEMA,
84
+ PLANNER_SCHEMA,
85
+ REMEDIATE_RESULT_SCHEMA,
86
+ REPLAN_SCHEMA,
87
+ REVIEW_RESULT_SCHEMA,
88
+ SINGLE_TASK_SCHEMA,
89
+ VERDICT_RESULT_SCHEMA,
90
+ VERIFY_RESULT_SCHEMA
91
+ } from './prompts/schemas.mjs'
92
+
93
+ // --- the shapes -------------------------------------------------------------
94
+
95
+ /** The run manifest's schema (design D4). */
96
+ export const RUN_SCHEMA = 'interlock.run/1'
97
+
98
+ /** The step record's schema (design D3). */
99
+ export const STEP_SCHEMA = 'interlock.run-step/1'
100
+
101
+ /** Root of the per-run state tree, shared with `lib/ship-stage.mjs`. */
102
+ export const WORK_DIR = join('.claude', 'ship')
103
+ export const MANIFEST_PATH = join(WORK_DIR, 'run.json')
104
+ export const STATE_PATH = join(WORK_DIR, 'state.json')
105
+ export const BRIEFINGS_DIR = join(WORK_DIR, 'briefings')
106
+ export const CLASSIFIED_PATH = join(WORK_DIR, 'classified.json')
107
+ export const PLAN_FILE_PATH = join(WORK_DIR, 'plan.json')
108
+ export const REPLAN_PATH = join(WORK_DIR, 'replan.json')
109
+ // Where the review/remediation worker writes what it found, so `run reviewed`
110
+ // and `run remediated` can adjudicate it themselves rather than trust a count
111
+ // the agent reports about itself (design D2).
112
+ export const FINDINGS_PATH = join(WORK_DIR, 'findings.json')
113
+ export const VERDICTS_PATH = join(WORK_DIR, 'verdicts.json')
114
+
115
+ /** The first line of every briefing file: what it is, whose it is, and its hash. */
116
+ export const BRIEFING_HEADER = (label, sha) =>
117
+ `<!-- interlock briefing v1 label=${label} sha256=${sha} -->`
118
+
119
+ /**
120
+ * The actions a step may carry: the six the state machine already emits, plus
121
+ * the ones the program adds around them.
122
+ *
123
+ * `review`, `remediate` and `verdict` are the strict tail, emitted by the CLI
124
+ * itself (`emit-strict-tail-from-cli`, design D1) rather than handed back to a
125
+ * host that holds loop text of its own — the seam this change closes.
126
+ */
127
+ export const ACTIONS = Object.freeze([
128
+ 'run-batch',
129
+ 'test-wave',
130
+ 'verify',
131
+ 'replan',
132
+ 'done',
133
+ 'halt',
134
+ 'classify',
135
+ 'verify-final',
136
+ 'review',
137
+ 'remediate',
138
+ 'verdict',
139
+ 'handoff',
140
+ 'commit',
141
+ 'close',
142
+ 'complete'
143
+ ])
144
+
145
+ // --- the manifest -----------------------------------------------------------
146
+
147
+ /**
148
+ * What a run whose host declared no capabilities is assumed to be: the Workflow
149
+ * host, which owns its own lane worktrees and has everything else.
150
+ *
151
+ * Stated here rather than imported from `lib/host/registry.mjs` for two
152
+ * reasons. The run program is the only party that reads it — an adapter
153
+ * declares its own capabilities and never consults this default. And the CLI
154
+ * must not import the adapters: they spawn vendor binaries, and the policy
155
+ * engine has no business loading a transport in order to answer "which wave
156
+ * next".
157
+ */
158
+ export const ASSUMED_CAPABILITIES = Object.freeze({
159
+ schemaEnforced: true,
160
+ modelSelect: 'flag',
161
+ worktree: 'runtime',
162
+ hooks: true,
163
+ usage: true,
164
+ // The one thing the Workflow host does NOT have, and the reason cache
165
+ // accounting is declared separately from usage at all: its runtime exposes
166
+ // `budget.spent()`, a single cumulative scalar with no decomposition, so a run
167
+ // there can report what it spent and never what it re-read or re-wrote. A
168
+ // manifest written before this key existed reads `false` here, which is the
169
+ // honest answer — that run measured no cache figures either.
170
+ cacheAccounting: false,
171
+ billing: 'claude-subscription-programmatic'
172
+ })
173
+
174
+ const FLAG_FIELDS = Object.freeze([
175
+ 'applyOnly',
176
+ 'noCommit',
177
+ 'skipE2e',
178
+ 'skipCoverage',
179
+ 'isolateWaves',
180
+ 'laneMode',
181
+ 'tddMode',
182
+ 'maxParallel',
183
+ 'mode',
184
+ 'review',
185
+ 'handoff',
186
+ 'conformance',
187
+ 'strict'
188
+ ])
189
+
190
+ /**
191
+ * Write the run manifest. Every later `run` subcommand reads it, which is what
192
+ * lets a driver pass its parsed flags once and never consult them again — the
193
+ * flags are host-specific in their delivery and identical in their meaning
194
+ * (design D4).
195
+ */
196
+ export function writeManifest(root, manifest) {
197
+ const path = join(root, MANIFEST_PATH)
198
+ mkdirSync(dirname(path), { recursive: true })
199
+ writeFileSync(path, JSON.stringify(manifest, null, 2) + '\n')
200
+ return manifest
201
+ }
202
+
203
+ /**
204
+ * Read the run manifest. Returns null when there is none — a `run` subcommand
205
+ * reached without a `run start` — so the caller can say that rather than
206
+ * defaulting to a run shape nobody asked for.
207
+ */
208
+ export function readManifest(root) {
209
+ try {
210
+ const value = JSON.parse(readFileSync(join(root, MANIFEST_PATH), 'utf8'))
211
+ return value && typeof value === 'object' && value.schema === RUN_SCHEMA ? value : null
212
+ } catch {
213
+ return null
214
+ }
215
+ }
216
+
217
+ /**
218
+ * A fresh manifest for `run start`.
219
+ *
220
+ * `host` is an object rather than the bare id it used to be, because every later
221
+ * step has to read what the host CANNOT do: whether it creates its own lane
222
+ * worktrees, whether the plugin's guards fire in it, whether it reports token
223
+ * usage, which account it spends. A step that branched on the id instead would
224
+ * have to know every adapter, which is the coupling `lib/host/registry.mjs`
225
+ * exists to remove (design D1).
226
+ *
227
+ * A run that declared no capabilities is the Workflow host: it owns its own
228
+ * worktrees and has everything. That default is what keeps `workflows/ship.js`
229
+ * — which passes `--host workflow` and nothing else — byte-for-byte unchanged.
230
+ */
231
+ export function newManifest({ change, flags, host, hostCapabilities }) {
232
+ const carried = {}
233
+ for (const field of FLAG_FIELDS) carried[field] = flags && flags[field] !== undefined ? flags[field] : null
234
+ const capabilities =
235
+ hostCapabilities && typeof hostCapabilities === 'object' && !Array.isArray(hostCapabilities)
236
+ ? { ...ASSUMED_CAPABILITIES, ...hostCapabilities }
237
+ : { ...ASSUMED_CAPABILITIES }
238
+ return {
239
+ schema: RUN_SCHEMA,
240
+ change,
241
+ flags: carried,
242
+ host: host ? { id: host, ...capabilities } : null,
243
+ // Per-spawn output tokens, accumulated as results arrive (design D9). A
244
+ // `null` entry is a spawn whose host reported nothing, and it makes its wave
245
+ // — and the run — unknown rather than smaller.
246
+ usage: [],
247
+ startedAt: new Date().toISOString(),
248
+ steps: 0,
249
+ // The monotonic stage-marker write count. It lives on the manifest rather
250
+ // than in a module-level counter because every `run` subcommand is a fresh
251
+ // process, and a marker index that restarted at 1 each call would let a
252
+ // guard read a leftover marker as current.
253
+ stageIndex: 0,
254
+ // The argv this run last emitted as a continuation. A `run` subcommand
255
+ // called out of sequence is refused against it (design: the sequence guard),
256
+ // so a driver that skips or repeats a step is told which call was expected
257
+ // rather than silently advancing the state machine twice.
258
+ lastThen: null,
259
+ // The shared-tree HEAD captured when an isolated batch was dispatched, held
260
+ // across the driver round trip so `record-batch` folds against the base the
261
+ // lanes actually forked from. The emitted step carries it for the driver;
262
+ // this is where the CLI reads it back, because the driver returns only
263
+ // results and the wave state has no such field.
264
+ mergeBase: null,
265
+ // The inter-wave verification budget's own clock (`interWaveVerifyBudgetMs`).
266
+ // Every `run` subcommand is a fresh process running BETWEEN agent turns, so
267
+ // the interval a verify agent spends is only measurable across two
268
+ // invocations: the step records when it dispatched, the judge folds the
269
+ // elapsed time in and clears the mark.
270
+ verifyStartedAt: null,
271
+ verifyElapsedMs: 0,
272
+ // Banners the CLI raised on earlier steps, carried to the close so the
273
+ // summary reports everything the run degraded on rather than only what the
274
+ // last step happened to hold.
275
+ banners: [],
276
+ // The per-batch summary rows the receipt reports, accumulated as batches are
277
+ // recorded. Held here for the same reason `banners` is: the close is a fresh
278
+ // process and cannot recompute what earlier steps observed.
279
+ waves: [],
280
+ plan: null,
281
+ commit: null,
282
+ commitSkipped: false,
283
+ halted: null,
284
+ notes: [],
285
+ runId: null,
286
+ // The strict tail (design D2, D4, D7). `reviewDimensions` is the CLI's
287
+ // selection plus any the reviewer added of its own accord; `review` and
288
+ // `remediation` are the adjudicated counts, never the agent's self-report;
289
+ // `fixRoundsRun` is what the run actually consumed, not the published cap.
290
+ reviewDimensions: null,
291
+ review: null,
292
+ remediation: null,
293
+ fixRoundsRun: 0,
294
+ // What the handoff writer reported about the artifacts it authored — the
295
+ // one tail figure the CLI cannot re-derive, because it is a fact about
296
+ // files an agent wrote rather than a judgement with a correct answer
297
+ // (design D5).
298
+ handoff: null,
299
+ // The autonomy-ladder record `run close` wrote, from the CLI's own
300
+ // surviving-blocker count (design D6). Storage only; nothing prints a level.
301
+ autonomy: null
302
+ }
303
+ }
304
+
305
+ /**
306
+ * One declared capability of the run's host, or the Workflow host's value.
307
+ *
308
+ * Every read goes through here so a manifest written before hosts declared
309
+ * anything — or by a driver that passed only an id — answers as the Workflow
310
+ * host rather than as `undefined`, which would read as "cannot" for every
311
+ * boolean capability and silently degrade an old run.
312
+ *
313
+ * @param {object|null} manifest
314
+ * @param {string} key
315
+ */
316
+ export function hostCapability(manifest, key) {
317
+ const host = manifest && manifest.host && typeof manifest.host === 'object' ? manifest.host : null
318
+ if (host && host[key] !== undefined) return host[key]
319
+ return ASSUMED_CAPABILITIES[key]
320
+ }
321
+
322
+ /**
323
+ * Where the host publishes the identifier of the session a process is running
324
+ * under. A fact about Claude Code, not an Interlock threshold, so it is a named
325
+ * constant in the module that reads it — the convention every `INTERLOCK_*`
326
+ * reader already follows — and it appears in no driver: the no-policy sweep has
327
+ * no allowance, and D11 puts the acquisition in the CLI for exactly that reason.
328
+ *
329
+ * VERIFIED 2026-09-10, which D11 required before this could be recorded at all.
330
+ * The hazard was that on the Workflow host `interlock run start` executes inside
331
+ * a ping subagent, so the value in scope might be the subagent's rather than the
332
+ * parent session's — and a wrong identifier is worse than none, because it joins
333
+ * the trajectory to a transcript that is not the run's. Measured: a subagent's
334
+ * shell reports the SAME value as its parent conversation, and that value names
335
+ * the parent's transcript file (`~/.claude/projects/<slug>/<id>.jsonl`).
336
+ * Subagents inherit the session; they do not get one of their own. So the value
337
+ * observed on the Workflow host is the parent session's and is recordable.
338
+ *
339
+ * `CLAUDE_CODE_HOST_SESSION_ID` is a different identifier (`local_…`) that names
340
+ * no transcript, and is deliberately not what is read here.
341
+ */
342
+ export const HOST_SESSION_ID_ENV = 'CLAUDE_CODE_SESSION_ID'
343
+
344
+ /**
345
+ * The Workflow host's id, restated here rather than imported from
346
+ * `lib/host/registry.mjs` for the reason `ASSUMED_CAPABILITIES` is: importing
347
+ * from that module would load every vendor adapter, and the policy engine has no
348
+ * business loading a transport.
349
+ */
350
+ const WORKFLOW_HOST = 'workflow'
351
+
352
+ /**
353
+ * The host session this run executes under, or null.
354
+ *
355
+ * Read from the CLI's own environment — never carried or passed by a driver
356
+ * (D11): `workflows/ship.js` has no shell or filesystem, and `bin/interlock-run`
357
+ * is a plain Node process with no session at all.
358
+ *
359
+ * Recorded only on the host whose steps run INSIDE that session. On the runner
360
+ * the agents are separate vendor processes with sessions of their own, so a
361
+ * variable inherited from whatever shell launched `interlock-run` names a
362
+ * transcript that does not contain the run — the same wrong-identifier failure
363
+ * D11 guards against, arriving from the other direction. That is why this reads
364
+ * the host rather than the environment alone.
365
+ */
366
+ export function hostSessionId(manifest, env = process.env) {
367
+ if (hostId(manifest) !== WORKFLOW_HOST) return null
368
+ const raw = env && typeof env[HOST_SESSION_ID_ENV] === 'string' ? env[HOST_SESSION_ID_ENV].trim() : ''
369
+ return raw || null
370
+ }
371
+
372
+ /** The run's host id, or null for a run that named none. */
373
+ export function hostId(manifest) {
374
+ const host = manifest && manifest.host
375
+ if (host && typeof host === 'object' && typeof host.id === 'string') return host.id
376
+ return typeof host === 'string' && host ? host : null
377
+ }
378
+
379
+ // --- the step budget (design D9) --------------------------------------------
380
+
381
+ /**
382
+ * Count this call against the run's step budget.
383
+ *
384
+ * Returns a `halt` step once the published cap is exceeded and null otherwise.
385
+ * The cap is `LIMITS.maxRunSteps`, read here rather than restated: it was a
386
+ * literal in both drivers, which is exactly what `openspec/specs/ship/
387
+ * cap-authority` forbids for a loop bound.
388
+ */
389
+ export function countStep(root, manifest) {
390
+ manifest.steps = (Number.isInteger(manifest.steps) ? manifest.steps : 0) + 1
391
+ if (manifest.steps > LIMITS.maxRunSteps) {
392
+ manifest.halted = `the run exceeded the published run-step cap (maxRunSteps=${LIMITS.maxRunSteps}) without reaching a terminal state`
393
+ writeManifest(root, manifest)
394
+ return haltStep(manifest.halted)
395
+ }
396
+ return null
397
+ }
398
+
399
+ // --- usage (design D9) ------------------------------------------------------
400
+
401
+ /**
402
+ * Fold one step's spawn results into the run's token accounting.
403
+ *
404
+ * Every results-taking `run` subcommand calls this, because a run's spend is the
405
+ * sum over every spawn it made — the review fan-out and the verification agent
406
+ * cost tokens exactly as a wave does, and a total that counted only waves would
407
+ * understate every strict run.
408
+ *
409
+ * UNKNOWN IS NOT ZERO, and it is contagious by design: one spawn whose host
410
+ * reported nothing makes its whole group unknown, because the alternative is a
411
+ * number that looks like a measurement and is actually a lower bound. A host
412
+ * that never reports usage therefore records `null` throughout, which is what
413
+ * the trajectory already means by "no token accounting".
414
+ *
415
+ * @param {object} manifest
416
+ * @param {Array<object|null>} results
417
+ * @param {number|null} [wave] the wave these spawns belonged to, or null for a
418
+ * step outside the waves (verification, review, remediation, the commit)
419
+ */
420
+ export function recordUsage(manifest, results, wave = null) {
421
+ const list = Array.isArray(results) ? results : []
422
+ if (!list.length) return
423
+ if (!Array.isArray(manifest.usage)) manifest.usage = []
424
+
425
+ let outputTokens = 0
426
+ let known = true
427
+ const usages = []
428
+ for (const result of list) {
429
+ const usage = result && typeof result === 'object' ? result.usage : null
430
+ const out = usage && typeof usage === 'object' && Number.isFinite(usage.outputTokens)
431
+ ? usage.outputTokens
432
+ : null
433
+ if (out === null) known = false
434
+ else outputTokens += out
435
+ usages.push(usage && typeof usage === 'object' ? usage : null)
436
+ }
437
+ manifest.usage.push({
438
+ wave: Number.isInteger(wave) ? wave : null,
439
+ outputTokens: known ? outputTokens : null,
440
+ // The input side, under exactly the rule the output side already holds. A
441
+ // host with no cache accounting supplies neither field on any spawn, so both
442
+ // of these come out `null` — unknown, which is a different fact from a wave
443
+ // that measured and found no cache activity.
444
+ ...foldCacheUsage(usages)
445
+ })
446
+ }
447
+
448
+ /**
449
+ * Sum one step's cache figures across its spawns, unknown-contagious.
450
+ *
451
+ * The tiers are NOT enumerated here. They are whatever the host reported them
452
+ * under, unioned across the step's spawns, so a run records the decomposition
453
+ * its host actually supplies rather than a list this module guessed — and so a
454
+ * tier is never invented for a host that does not have one. Restating the
455
+ * adapter's field names would also mean importing an adapter, which the policy
456
+ * engine does not do.
457
+ *
458
+ * A tier one spawn reported and another omitted is `null` for the group: the
459
+ * second spawn's figure was not measured, and adding only the first would
460
+ * produce a lower bound that reads as a total. Same rule, one type down, as the
461
+ * output-token fold above.
462
+ */
463
+ function foldCacheUsage(usages) {
464
+ const list = usages.filter(u => u && typeof u === 'object')
465
+ if (!list.length) return { cacheReadInputTokens: null, cacheCreationInputTokens: null }
466
+
467
+ const read = list.reduce(
468
+ (sum, usage) =>
469
+ sum === null || !Number.isFinite(usage.cacheReadInputTokens) ? null : sum + usage.cacheReadInputTokens,
470
+ 0
471
+ )
472
+
473
+ const tiers = new Set()
474
+ for (const usage of list) {
475
+ const creation = usage.cacheCreationInputTokens
476
+ if (creation && typeof creation === 'object' && !Array.isArray(creation)) {
477
+ for (const tier of Object.keys(creation)) tiers.add(tier)
478
+ }
479
+ }
480
+ let creation = null
481
+ if (tiers.size) {
482
+ creation = {}
483
+ for (const tier of tiers) {
484
+ creation[tier] = list.reduce((sum, usage) => {
485
+ const block = usage.cacheCreationInputTokens
486
+ const value = block && typeof block === 'object' ? block[tier] : undefined
487
+ return sum === null || !Number.isFinite(value) ? null : sum + value
488
+ }, 0)
489
+ }
490
+ }
491
+ return { cacheReadInputTokens: read, cacheCreationInputTokens: creation }
492
+ }
493
+
494
+ /**
495
+ * Add one recorded entry's cache figures into a running group total, holding the
496
+ * unknown-contagious rule across entries the way `summarizeUsage` does for
497
+ * output tokens. `null` in, `null` out, permanently.
498
+ */
499
+ function addCacheEntry(total, entry) {
500
+ const read =
501
+ total.cacheReadInputTokens === null || !Number.isFinite(entry.cacheReadInputTokens)
502
+ ? null
503
+ : total.cacheReadInputTokens + entry.cacheReadInputTokens
504
+
505
+ const incoming =
506
+ entry.cacheCreationInputTokens && typeof entry.cacheCreationInputTokens === 'object'
507
+ ? entry.cacheCreationInputTokens
508
+ : null
509
+ let creation = total.cacheCreationInputTokens
510
+ if (creation === null && incoming === null) {
511
+ // Nothing reported on either side; stays absent.
512
+ } else if (incoming === null) {
513
+ // A contributing entry reported no decomposition at all, so every tier the
514
+ // group has so far becomes unknown rather than being carried as a total.
515
+ creation = Object.fromEntries(Object.keys(creation).map(tier => [tier, null]))
516
+ } else if (creation === null) {
517
+ creation = { ...incoming }
518
+ } else {
519
+ const merged = {}
520
+ for (const tier of new Set([...Object.keys(creation), ...Object.keys(incoming)])) {
521
+ const a = creation[tier]
522
+ const b = incoming[tier]
523
+ merged[tier] = Number.isFinite(a) && Number.isFinite(b) ? a + b : null
524
+ }
525
+ creation = merged
526
+ }
527
+ return { cacheReadInputTokens: read, cacheCreationInputTokens: creation }
528
+ }
529
+
530
+ const EMPTY_CACHE_TOTAL = Object.freeze({ cacheReadInputTokens: 0, cacheCreationInputTokens: null })
531
+
532
+ /**
533
+ * The receipt's `spend` rows and run total, summed from what was recorded.
534
+ *
535
+ * Per wave and per run, with `null` for either wherever a single contributing
536
+ * spawn went unmeasured. A wave that appears in several batches sums across
537
+ * them; a run with no entries at all reports nothing rather than zero.
538
+ *
539
+ * @param {object} manifest
540
+ * @returns {{spend: Array<object>, outputTokens: number|null|undefined,
541
+ * cacheReadInputTokens: number|null|undefined,
542
+ * cacheCreationInputTokens: object|null|undefined}}
543
+ */
544
+ export function summarizeUsage(manifest) {
545
+ const entries = Array.isArray(manifest && manifest.usage) ? manifest.usage : []
546
+ if (!entries.length) {
547
+ return {
548
+ spend: [],
549
+ outputTokens: undefined,
550
+ cacheReadInputTokens: undefined,
551
+ cacheCreationInputTokens: undefined
552
+ }
553
+ }
554
+
555
+ const byWave = new Map()
556
+ for (const entry of entries) {
557
+ if (!Number.isInteger(entry && entry.wave)) continue
558
+ const current = byWave.has(entry.wave)
559
+ ? byWave.get(entry.wave)
560
+ : { outputTokens: 0, ...EMPTY_CACHE_TOTAL }
561
+ byWave.set(entry.wave, {
562
+ outputTokens:
563
+ current.outputTokens === null || entry.outputTokens === null
564
+ ? null
565
+ : current.outputTokens + entry.outputTokens,
566
+ ...addCacheEntry(current, entry)
567
+ })
568
+ }
569
+
570
+ const runTotal = entries.reduce(
571
+ (sum, entry) => (sum === null || entry.outputTokens === null ? null : sum + entry.outputTokens),
572
+ 0
573
+ )
574
+ const runCache = entries.reduce((total, entry) => addCacheEntry(total, entry), { ...EMPTY_CACHE_TOTAL })
575
+
576
+ return {
577
+ spend: [...byWave.entries()]
578
+ .sort((a, b) => a[0] - b[0])
579
+ .map(([wave, totals]) => ({ wave, ...totals })),
580
+ outputTokens: runTotal,
581
+ ...runCache
582
+ }
583
+ }
584
+
585
+ // --- briefings (design D2) --------------------------------------------------
586
+
587
+ // --- lane worktrees (design D7) ---------------------------------------------
588
+
589
+ /**
590
+ * Where a driver-owned lane worktree goes.
591
+ *
592
+ * Under `.claude/ship/`, which is git-ignored, so a lane's checkout is invisible
593
+ * to the `git status` sweep that reads what the batch actually changed — a
594
+ * worktree that showed up there would report every file in the repository as
595
+ * touched by the wave that created it.
596
+ */
597
+ export const LANE_WORKTREES_DIR = join(WORK_DIR, 'worktrees')
598
+
599
+ /**
600
+ * The path one lane's worktree takes, derived rather than reported.
601
+ *
602
+ * On the Workflow host the runtime picks the directory and the lane reports its
603
+ * own `pwd`, because an orchestrator predicting a path the runtime did not use
604
+ * is a fold that silently finds nothing. On a runner host the DRIVER creates the
605
+ * worktree, so the prediction and the reality are the same act — and deriving it
606
+ * here means the step that names it and the fold that removes it read one
607
+ * function rather than two conventions.
608
+ *
609
+ * The label is sanitised, not hashed: a directory named after its lane is one a
610
+ * halted run's operator can find, and lane labels are already CLI-safe ids.
611
+ *
612
+ * @param {number|string} wave
613
+ * @param {string} label
614
+ * @returns {string} repo-relative
615
+ */
616
+ export function laneWorktreePath(wave, label) {
617
+ const safe = String(label || 'lane').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'lane'
618
+ return join(LANE_WORKTREES_DIR, `wave-${wave ?? 'x'}`, safe)
619
+ }
620
+
621
+ /** The hash a host reports back to prove it read the briefing it was handed. */
622
+ export function briefingHash(text) {
623
+ return createHash('sha256').update(text, 'utf8').digest('hex')
624
+ }
625
+
626
+ /**
627
+ * Write one briefing and return what a spawn carries: its path, its hash and
628
+ * its text.
629
+ *
630
+ * The Workflow host cannot hold a briefing — it has no filesystem, and asking a
631
+ * ping to copy several kilobytes into a result field would breach the published
632
+ * result-field cap — so it spawns the worker with a fixed bootstrap naming this
633
+ * path and requires the hash back. The ACP host reads `prompt` and sends it
634
+ * inline. One file serves both.
635
+ */
636
+ export function writeBriefing(root, label, text) {
637
+ const promptSha256 = briefingHash(text)
638
+ const promptPath = join(BRIEFINGS_DIR, `${label}.md`)
639
+ const dest = join(root, promptPath)
640
+ mkdirSync(dirname(dest), { recursive: true })
641
+ writeFileSync(dest, `${BRIEFING_HEADER(label, promptSha256)}\n${text}`)
642
+ return { promptPath, promptSha256, prompt: text }
643
+ }
644
+
645
+ // --- step records (design D3) -----------------------------------------------
646
+
647
+ /**
648
+ * The one thing every briefing says that is not about the work: there is nobody
649
+ * to ask. Both drivers appended it to every prompt they sent, in the same words;
650
+ * it is stated here so neither has to.
651
+ */
652
+ const AUTONOMY_LINE =
653
+ '\n\nYou are one step of an automated ship run. Do not ask questions — there is ' +
654
+ 'no one listening. If something is undecidable, put it in the result fields rather than ' +
655
+ 'guessing at product intent.'
656
+
657
+ /** One agent a step asks for. */
658
+ function spawn(root, { label, kind, model, effort, schema, prompt, isolation, worktree }) {
659
+ return {
660
+ label,
661
+ kind,
662
+ model: model || null,
663
+ effort: effort === undefined ? null : effort,
664
+ ...spawnPrefix(kind === 'ping' ? 'ping' : 'worker'),
665
+ schema,
666
+ isolation: isolation || null,
667
+ // Only meaningful under isolation, and null on the Workflow host even then:
668
+ // there the lane reports its own `pwd`, because the orchestrator predicting
669
+ // a worktree path and the runtime choosing a different one is a fold that
670
+ // silently finds nothing. On a runner host the driver creates the directory
671
+ // this names, so predicting it and using it are one act (design D7).
672
+ worktree: worktree || null,
673
+ ...writeBriefing(root, label, prompt + AUTONOMY_LINE)
674
+ }
675
+ }
676
+
677
+ /** Assemble a step record. Every step the program emits goes through here. */
678
+ export function makeStep(fields) {
679
+ const { action, then = null, spawns = [], ...rest } = fields
680
+ if (!ACTIONS.includes(action)) {
681
+ throw new Error(`unknown run action "${action}" — expected one of ${ACTIONS.join(', ')}`)
682
+ }
683
+ return { schema: STEP_SCHEMA, action, then, spawns, ...rest }
684
+ }
685
+
686
+ /** The terminal step of a run that cannot continue. */
687
+ export function haltStep(reason, extra = {}) {
688
+ return makeStep({
689
+ action: 'halt',
690
+ reason,
691
+ // A halt still closes: the receipt, the outcome record and the trajectory's
692
+ // terminal event are what a halted run is FOR. `then` is the close, not
693
+ // null — a run that stopped without recording why is the one record this
694
+ // corpus most needs and least often has.
695
+ then: { argv: ['run', 'close', '--halt', reason] },
696
+ ...extra
697
+ })
698
+ }
699
+
700
+ // --- the wave-state fields a step carries through ---------------------------
701
+
702
+ const PASS_THROUGH = Object.freeze([
703
+ 'wave',
704
+ 'waveIndex',
705
+ 'waveKind',
706
+ 'batchIndex',
707
+ 'batchCount',
708
+ 'remainingBatches',
709
+ 'previousHandoffs',
710
+ 'changed',
711
+ 'mode',
712
+ 'runMode',
713
+ 'reason',
714
+ 'fixAttempt',
715
+ 'fixAttemptsRemaining',
716
+ 'errors',
717
+ 'nextWave',
718
+ 'nextWaveKind',
719
+ 'revisableGroups',
720
+ 'replansUsed',
721
+ 'replansRemaining',
722
+ 'maxParallel',
723
+ 'summary',
724
+ 'kind',
725
+ 'failures',
726
+ 'unresolved'
727
+ ])
728
+
729
+ function passThrough(state) {
730
+ const out = {}
731
+ for (const field of PASS_THROUGH) {
732
+ if (state[field] !== undefined) out[field] = state[field]
733
+ }
734
+ return out
735
+ }
736
+
737
+ // --- decoration: a wave-state step becomes a run step -----------------------
738
+
739
+ /** Every lane in the batch a step dispatches, as the wave-state emitted them. */
740
+ function lanesOf(step) {
741
+ const batches =
742
+ Array.isArray(step.remainingBatches) && step.remainingBatches.length
743
+ ? step.remainingBatches
744
+ : [Array.isArray(step.tasks) ? step.tasks : []]
745
+ return Array.isArray(batches[0]) ? batches[0] : []
746
+ }
747
+
748
+ /**
749
+ * Turn the pure state machine's step into the run step a driver obeys.
750
+ *
751
+ * This is where every branch the loop used to take in two drivers is taken
752
+ * once: apply-only, no-commit, isolate-waves, a tail flag, a skipped verify, a
753
+ * replan. The driver sees the result, never the question.
754
+ *
755
+ * @param {object} ctx { root, deps }
756
+ * @param {object} step the wave-state step (`nextStep(state)`)
757
+ * @param {object} manifest
758
+ * @param {object} [extra] fields to carry onto the emitted step (banners, notes)
759
+ */
760
+ export function decorate(ctx, step, manifest, extra = {}) {
761
+ const { root } = ctx
762
+ const carried = { ...passThrough(step), ...extra }
763
+ const change = manifest.change
764
+
765
+ if (step.action === 'halt') {
766
+ return haltStep(step.reason || 'the run state halted', carried)
767
+ }
768
+
769
+ if (step.action === 'run-batch' || step.action === 'test-wave') {
770
+ const lanes = lanesOf(step)
771
+ if (!lanes.length || lanes.some(lane => !Array.isArray(lane) || !lane.length)) {
772
+ return haltStep('the state machine asked for a batch with no dispatchable lane', carried)
773
+ }
774
+ const isolate = manifest.flags.isolateWaves === true
775
+ // Who creates the lane's worktree (design D1, D7). The Workflow runtime does
776
+ // it from an `isolation` field on the spawn; a runner host has no runtime to
777
+ // ask, so the step names the directory and the driver runs `git worktree
778
+ // add` itself. Both fold through the same `run record-batch`.
779
+ const driverWorktrees = isolate && hostCapability(manifest, 'worktree') === 'driver'
780
+ const previousHandoffs = Array.isArray(step.previousHandoffs) ? step.previousHandoffs : []
781
+ // The stage line rides on the batch's briefings rather than on a step of its
782
+ // own: the agent doing the work is the one with a filesystem, and a marker
783
+ // written by anyone else would carry the wrong pid.
784
+ const stage = publishStageLine('implement', change, ++manifest.stageIndex)
785
+ const spawns = lanes.map(lane =>
786
+ spawn(root, {
787
+ label: laneLabel(lane),
788
+ kind: 'implementer',
789
+ model: laneModel(lane),
790
+ effort: laneEffort(lane),
791
+ schema: lane.length === 1 ? SINGLE_TASK_SCHEMA : LANE_SCHEMA,
792
+ isolation: isolate && !driverWorktrees ? 'worktree' : null,
793
+ worktree: driverWorktrees ? { path: laneWorktreePath(step.wave, laneLabel(lane)) } : null,
794
+ // `solo` comes off the STEP, not off the invocation flag: the planner
795
+ // decides the mode (a classifier recommendation inside the envelope
796
+ // reaches solo with no flag at all), the run state carries it, and
797
+ // `nextStep` echoes it. Reading the flag here would brief the agent for
798
+ // a shape the planner may not have built.
799
+ prompt:
800
+ stage +
801
+ '\n' +
802
+ assembleImplementerPrompt({
803
+ change,
804
+ lane,
805
+ previousHandoffs,
806
+ isolateWaves: isolate,
807
+ solo: step.mode === 'solo'
808
+ })
809
+ })
810
+ )
811
+ // The shared tree's HEAD before this batch's lanes fork off — read now, not
812
+ // after, so nothing else lands on the shared tree between the reading and
813
+ // the fold. Only under isolation; unset, a batch runs byte-for-byte as it
814
+ // did before worktree isolation existed.
815
+ let mergeBase = null
816
+ if (isolate) {
817
+ mergeBase = ctx.deps.headCommit(root)
818
+ if (!mergeBase) {
819
+ return haltStep(
820
+ 'could not capture the shared-tree base commit before an isolated batch — merge-lanes ' +
821
+ 'cannot fold lane worktrees back without one',
822
+ carried
823
+ )
824
+ }
825
+ // Onto the manifest as well as onto the step. The step goes to the driver
826
+ // and the driver returns only results; `record-batch` reads a pure
827
+ // wave-state step that carries no such field, so without this the fold
828
+ // would re-read HEAD *after* the lanes ran — a different commit whenever
829
+ // anything else landed on the shared tree in between.
830
+ manifest.mergeBase = mergeBase
831
+ }
832
+ return makeStep({
833
+ action: step.action,
834
+ ...carried,
835
+ lanes: lanes.map(lane => lane.map(t => ({ id: t.id }))),
836
+ mergeBase,
837
+ then: { argv: ['run', 'record-batch'] },
838
+ spawns
839
+ })
840
+ }
841
+
842
+ if (step.action === 'verify') {
843
+ return verifyStep(ctx, manifest, {
844
+ context: 'inter-wave',
845
+ label: `inter-wave-verify-${manifest.steps}`,
846
+ changed: Array.isArray(step.changed) ? step.changed : [],
847
+ carried
848
+ })
849
+ }
850
+
851
+ if (step.action === 'replan') {
852
+ return makeStep({
853
+ action: 'replan',
854
+ ...carried,
855
+ then: { argv: ['run', 'replan'] },
856
+ spawns: [
857
+ spawn(root, {
858
+ label: `replan-${manifest.steps}`,
859
+ kind: 'ping',
860
+ model: 'haiku',
861
+ effort: null,
862
+ schema: REPLAN_SCHEMA,
863
+ prompt: assembleReplanPrompt({ change, replanPath: REPLAN_PATH })
864
+ })
865
+ ]
866
+ })
867
+ }
868
+
869
+ if (step.action === 'done') {
870
+ // Three ways a run of waves ends, decided here rather than in a driver.
871
+ if (manifest.flags.applyOnly === true) {
872
+ manifest.notes.push('--apply-only: stopped after the waves')
873
+ manifest.commitSkipped = true
874
+ return makeStep({ action: 'close', ...carried, then: { argv: ['run', 'close'] } })
875
+ }
876
+ // The strict tail (design D1): `--review` (or `--strict`, which implies
877
+ // it) fans out the adversarial review here, in the CLI. A run carrying
878
+ // only `--handoff` and/or `--conformance` has nothing to review, so it
879
+ // walks straight to final verification exactly as a lean run does — the
880
+ // handoff step is decided after that verdict passes (design D5), not here.
881
+ if (manifest.flags.review === true) {
882
+ return reviewStep(ctx, manifest, carried)
883
+ }
884
+ return finalVerifyStep(ctx, manifest, carried)
885
+ }
886
+
887
+ return haltStep(`unrecognized step from the state machine: ${step.action}`, carried)
888
+ }
889
+
890
+ /** The final verification step: the same verify spawn, judged in the `final` context. */
891
+ export function finalVerifyStep(ctx, manifest, carried = {}) {
892
+ return verifyStep(ctx, manifest, {
893
+ context: 'final',
894
+ label: 'verify',
895
+ changed: [],
896
+ carried,
897
+ action: 'verify-final'
898
+ })
899
+ }
900
+
901
+ /**
902
+ * A verification step — a spawn that runs the planned checks, or a skipped
903
+ * marker carrying the planner's own reason.
904
+ *
905
+ * The plan is built HERE and the verdict is rendered by `run judge`. The agent
906
+ * between them runs commands and reports what happened; it is never asked
907
+ * whether the run may continue. A plan with no steps emits no spawn at all: the
908
+ * step still names its continuation, so the interpreter spawns nothing, calls
909
+ * the argv and the skip is recorded by the CLI that decided it.
910
+ */
911
+ function verifyStep(ctx, manifest, { context, label, changed, carried, action = 'verify' }) {
912
+ const { root } = ctx
913
+ const interWave = context === 'inter-wave'
914
+ const profile = readProfile(root)
915
+ const opts = { context, changed }
916
+ if (context === 'final') {
917
+ opts.e2e = manifest.flags.skipE2e !== true
918
+ opts.coverage = manifest.flags.skipCoverage !== true
919
+ } else {
920
+ // The budget's input (`interWaveVerifyBudgetMs`). Accumulated across
921
+ // invocations on the manifest, because this process runs BETWEEN agent turns
922
+ // and never sees the interval a verify agent spends; without it the
923
+ // comparison would be `0 >= budget` forever and the published cap would
924
+ // bound nothing.
925
+ opts.elapsedMs = numberOrUndefined(manifest.verifyElapsedMs) || 0
926
+ }
927
+ const plan = planVerification(profile, opts)
928
+ writeWork(root, `vplan-${context}.json`, plan)
929
+
930
+ if (!plan.steps.length) {
931
+ // A spent budget is the reason, not whichever kind happens to sit first in
932
+ // `skipped` — typecheck is skipped for its own missing command and would
933
+ // otherwise mask the budget as the cause of an empty plan.
934
+ const reason = plan.budgetExceeded
935
+ ? SKIP_REASONS.BUDGET_EXCEEDED
936
+ : (plan.skipped[0] && plan.skipped[0].reason) || 'no detectable commands'
937
+ return makeStep({
938
+ action,
939
+ ...carried,
940
+ context,
941
+ skipped: true,
942
+ reason,
943
+ banners: [...(carried.banners || []), `VERIFICATION SKIPPED: reason=${reason}`],
944
+ then: { argv: ['run', 'judge', '--context', context] },
945
+ spawns: []
946
+ })
947
+ }
948
+
949
+ // The interval starts now: the driver is about to spawn an agent that runs
950
+ // these commands, and `run judge` — the next process — folds the elapsed time
951
+ // in. Only inter-wave; the final check is not budgeted.
952
+ if (interWave) manifest.verifyStartedAt = new Date().toISOString()
953
+
954
+ return makeStep({
955
+ action,
956
+ ...carried,
957
+ context,
958
+ skipped: false,
959
+ then: { argv: ['run', 'judge', '--context', context] },
960
+ spawns: [
961
+ spawn(root, {
962
+ label,
963
+ kind: 'verify',
964
+ model: null,
965
+ effort: null,
966
+ schema: VERIFY_RESULT_SCHEMA,
967
+ prompt:
968
+ (context === 'final'
969
+ ? // `fix-tests`, not `verify`: the final step repairs a red suite by
970
+ // root cause, and that repair is exactly when weakening a test is
971
+ // the hazard — so the marker it publishes puts guard-tests into
972
+ // deny for test files.
973
+ publishStageLine('fix-tests', manifest.change, ++manifest.stageIndex) + '\n'
974
+ : '') +
975
+ assembleVerifyPrompt({
976
+ change: manifest.change,
977
+ context,
978
+ steps: plan.steps,
979
+ runId: manifest.runId,
980
+ statePath: STATE_PATH,
981
+ // A retry's own facts. The state machine computed which attempt this
982
+ // is and what failed last time; briefing the second attempt
983
+ // identically to the first buys an agent that re-runs the same
984
+ // commands with no knowledge that they were already red. The final
985
+ // check has no attempt counter, so it carries none.
986
+ ...(interWave
987
+ ? {
988
+ fixAttempt: carried.fixAttempt,
989
+ fixAttemptsRemaining: carried.fixAttemptsRemaining,
990
+ errors: carried.errors
991
+ }
992
+ : {})
993
+ })
994
+ })
995
+ ]
996
+ })
997
+ }
998
+
999
+ // --- the strict tail (design D1, D2, D3, D4, D8) ----------------------------
1000
+ //
1001
+ // Adversarial review, bounded remediation, and the verdict — the sections
1002
+ // `workflows/ship.js` used to run inline behind a `host-tail` step (design D6,
1003
+ // now retired). Every judgement that has a correct answer moves here: which
1004
+ // dimensions run, what their criteria say, what a diff's findings survive to,
1005
+ // what a round fixes versus defers, and when the budget is spent. A driver
1006
+ // obeys the step and asks nothing.
1007
+
1008
+ /** Where a dimension's written criteria live, relative to the plugin itself —
1009
+ * not the target repository `ctx.root` names, which may hold no such file at
1010
+ * all. Mirrors `lib/doctor.mjs`'s own `PLUGIN_ROOT` resolution. */
1011
+ const REVIEW_DIMENSIONS_DIR = join('skills', 'review-code', 'dimensions')
1012
+
1013
+ /**
1014
+ * A dimension's written criteria — the file every reviewer prompt used to be
1015
+ * told to open for itself (`RUBRIC_INSTRUCTIONS`, retired). Read here once so
1016
+ * the whole run states the degradation when it cannot, rather than a reviewer
1017
+ * silently proceeding on the dimension's name alone (design D3, rubric-
1018
+ * delivery). Never throws: an unreadable or absent file is `null`.
1019
+ */
1020
+ function readDimensionRubric(name) {
1021
+ try {
1022
+ const text = readFileSync(join(PLUGIN_ROOT, REVIEW_DIMENSIONS_DIR, `${name}.md`), 'utf8')
1023
+ return text.trim() || null
1024
+ } catch {
1025
+ return null
1026
+ }
1027
+ }
1028
+
1029
+ /** `{ name, rubric }` for every dimension name, in order — what both prompt
1030
+ * assemblers take (design D3). */
1031
+ function resolveDimensionRubrics(names) {
1032
+ return (Array.isArray(names) ? names : []).map(name => ({ name, rubric: readDimensionRubric(name) }))
1033
+ }
1034
+
1035
+ /**
1036
+ * The review step: one worker, every selected dimension fanned out inside it,
1037
+ * two skeptics per finding (design D1, D3, D4, D8).
1038
+ *
1039
+ * Dimensions are `selectDimensions`'s rule over the run's OBSERVED changed
1040
+ * paths — never a reviewer's judgement call — and a dimension whose criteria
1041
+ * could not be read is named on the step rather than dispatched silently on
1042
+ * its name alone.
1043
+ */
1044
+ function reviewStep(ctx, manifest, carried = {}) {
1045
+ const { root } = ctx
1046
+ const change = manifest.change
1047
+ const changedPaths = ctx.deps.observedChangedPaths(root)
1048
+ const { dimensions: names, reasons } = selectDimensions(changedPaths)
1049
+ const dimensions = resolveDimensionRubrics(names)
1050
+ const policy = readReviewPolicy(root)
1051
+
1052
+ const banners = [...(carried.banners || [])]
1053
+ for (const d of dimensions) {
1054
+ if (d.rubric === null) {
1055
+ banners.push(`REVIEW RUBRIC UNAVAILABLE: ${d.name} — that reviewer works from the dimension name alone`)
1056
+ }
1057
+ }
1058
+
1059
+ // Carried across every later `run reviewed`/`run remediated` call in this
1060
+ // run, each its own process: what the CLI selected, and why, so an agent-
1061
+ // added dimension can be told apart from one the CLI asked for (design D4).
1062
+ manifest.reviewDimensions = { selected: names, reasons }
1063
+
1064
+ const stageLine = publishStageLine('review', change, ++manifest.stageIndex)
1065
+ return makeStep({
1066
+ action: 'review',
1067
+ ...carried,
1068
+ banners,
1069
+ then: { argv: ['run', 'reviewed'] },
1070
+ spawns: [
1071
+ spawn(root, {
1072
+ label: 'review',
1073
+ kind: 'review',
1074
+ model: null,
1075
+ // The skeptics reason inside this one worker (it fans out dimensions
1076
+ // and two skeptics per finding in its own context), so pinning this
1077
+ // spawn's effort is what pins them — fixed regardless of any lane tier
1078
+ // (design D8).
1079
+ effort: EFFORT.skeptic,
1080
+ schema: REVIEW_RESULT_SCHEMA,
1081
+ prompt: assembleReviewPrompt({
1082
+ change,
1083
+ dimensions,
1084
+ policyProse: policy.prose,
1085
+ findingsPath: FINDINGS_PATH,
1086
+ verdictsPath: VERDICTS_PATH,
1087
+ stageLine
1088
+ })
1089
+ })
1090
+ ]
1091
+ })
1092
+ }
1093
+
1094
+ /**
1095
+ * One remediation-round step: a fixing round (`assembleRemediatePrompt`), or —
1096
+ * when the plan says this is the round after the cap — the verdict
1097
+ * (`assembleVerdictPrompt`), which fixes nothing and re-reviews nothing.
1098
+ */
1099
+ function remediateStep(ctx, manifest, plan, extra = {}) {
1100
+ const { root } = ctx
1101
+ const change = manifest.change
1102
+ const then = { argv: ['run', 'remediated', '--round', String(plan.round)] }
1103
+
1104
+ if (plan.isFinalRound) {
1105
+ return makeStep({
1106
+ action: 'verdict',
1107
+ ...extra,
1108
+ round: plan.round,
1109
+ then,
1110
+ spawns: [
1111
+ spawn(root, {
1112
+ label: `remediate-${plan.round}`,
1113
+ kind: 'remediate',
1114
+ model: null,
1115
+ effort: EFFORT.skeptic,
1116
+ schema: VERDICT_RESULT_SCHEMA,
1117
+ prompt: assembleVerdictPrompt({ change, round: plan.round })
1118
+ })
1119
+ ]
1120
+ })
1121
+ }
1122
+
1123
+ const dimensions = resolveDimensionRubrics(plan.reReviewDimensions)
1124
+ const policyProse = readReviewPolicy(root).prose
1125
+ const stageLine = publishStageLine('remediation', change, ++manifest.stageIndex)
1126
+ return makeStep({
1127
+ action: 'remediate',
1128
+ ...extra,
1129
+ round: plan.round,
1130
+ then,
1131
+ spawns: [
1132
+ spawn(root, {
1133
+ label: `remediate-${plan.round}`,
1134
+ kind: 'remediate',
1135
+ model: null,
1136
+ effort: EFFORT.skeptic,
1137
+ schema: REMEDIATE_RESULT_SCHEMA,
1138
+ prompt: assembleRemediatePrompt({ change, round: plan.round, plan, dimensions, policyProse, stageLine })
1139
+ })
1140
+ ]
1141
+ })
1142
+ }
1143
+
1144
+ /** Wrap a `lib/remediate.mjs` call: its own validation throws a user-facing
1145
+ * message on a round out of range, which is a halt here, never a crash. */
1146
+ function planRemediationRound(surviving, round) {
1147
+ try {
1148
+ return { plan: planRemediation(surviving, { round }), error: null }
1149
+ } catch (err) {
1150
+ return { plan: null, error: (err && err.message) || String(err) }
1151
+ }
1152
+ }
1153
+
1154
+ /**
1155
+ * A stage marker the agent could not publish, said out loud.
1156
+ *
1157
+ * The guards for that stage fail open — their documented default — so the run
1158
+ * continues, but a window in which `hooks/guard-tests.mjs` was not in force is
1159
+ * exactly the thing a reader of the trajectory must not have to infer. The
1160
+ * Workflow host used to raise this itself; the tail is the CLI's now, so the
1161
+ * CLI raises it.
1162
+ */
1163
+ function stageMarkerBanners(results) {
1164
+ const out = []
1165
+ for (const entry of Array.isArray(results) ? results : []) {
1166
+ const warning =
1167
+ entry && typeof entry.stageMarkerWarning === 'string' ? entry.stageMarkerWarning.trim() : ''
1168
+ if (warning) {
1169
+ out.push(`STAGE MARKER NOT PUBLISHED: ${warning} — the guards for this stage fail open`)
1170
+ }
1171
+ }
1172
+ return out
1173
+ }
1174
+
1175
+ /**
1176
+ * Re-adjudicate the work files a review or fixer just wrote, write `review.json`
1177
+ * and the metrics file exactly as `interlock review --metrics <change>` would,
1178
+ * and record the counts on the manifest (design D2, D7).
1179
+ */
1180
+ function adjudicateReview(ctx, manifest) {
1181
+ const { root } = ctx
1182
+ const findings = readJson(join(root, FINDINGS_PATH))
1183
+ const verdicts = readJson(join(root, VERDICTS_PATH))
1184
+ const changedPaths = ctx.deps.observedChangedPaths(root)
1185
+ const policy = readReviewPolicy(root)
1186
+
1187
+ const result = resolveReview(findings, verdicts, { changedPaths, excludePaths: policy.excludePaths })
1188
+ writeWork(root, 'review.json', result)
1189
+
1190
+ const banners = []
1191
+ const metrics = writeReviewMetrics(root, { change: manifest.change, counts: result.counts })
1192
+ if (!metrics.written) {
1193
+ banners.push(`REVIEW METRICS NOT WRITTEN: ${metrics.reason}`)
1194
+ }
1195
+ return { result, banners }
1196
+ }
1197
+
1198
+ /**
1199
+ * `run reviewed` — the continuation of the review step (design D2).
1200
+ *
1201
+ * Adjudicates `findings.json`/`verdicts.json` against the run's OWN observed
1202
+ * changed paths (never the agent's), writes `review.json` and the metrics
1203
+ * file, records any agent-added dimension, and asks `lib/remediate.mjs` for
1204
+ * round one: a fixing round, or straight to final verification when nothing
1205
+ * survived to fix.
1206
+ */
1207
+ export function runReviewed(ctx, { results }) {
1208
+ const { root } = ctx
1209
+ const manifest = readManifest(root)
1210
+ if (!manifest) return noManifest()
1211
+ const capped = countStep(root, manifest)
1212
+ if (capped) return recordThen(root, manifest, capped)
1213
+
1214
+ // Outside the waves, but not outside the run's spend (design D9).
1215
+ recordUsage(manifest, results)
1216
+
1217
+ const { result, banners } = adjudicateReview(ctx, manifest)
1218
+ banners.push(...stageMarkerBanners(results))
1219
+
1220
+ // A review step that reported nothing usable still adjudicates — the files
1221
+ // are read either way — but "the review did not complete" is a different
1222
+ // fact from "the review found nothing", and the summary must not blur them
1223
+ // (workflow-host spec, edge case: an agent that returns no findings file).
1224
+ const report = results && results[0] && typeof results[0] === 'object' ? results[0] : null
1225
+ if (!report || report.ok !== true) {
1226
+ manifest.notes.push(
1227
+ `review did not complete: ${(report && report.detail) || 'the step returned no usable result'}`
1228
+ )
1229
+ }
1230
+
1231
+ // A dimension the reviewer added beyond the CLI's selection (design D4).
1232
+ // The CLI cannot know why; the one-line reason is the agent's own report,
1233
+ // carried here rather than inferred.
1234
+ const selected = new Set((manifest.reviewDimensions && manifest.reviewDimensions.selected) || [])
1235
+ const added = Array.isArray(report && report.addedDimensions)
1236
+ ? report.addedDimensions.filter(d => d && typeof d.name === 'string' && !selected.has(d.name))
1237
+ : []
1238
+ if (added.length) {
1239
+ manifest.reviewDimensions = { ...(manifest.reviewDimensions || { selected: [], reasons: {} }), added }
1240
+ }
1241
+
1242
+ manifest.banners.push(...banners)
1243
+
1244
+ const { plan, error } = planRemediationRound(result.surviving, 1)
1245
+ if (!plan) {
1246
+ return finishStep(ctx, manifest, haltStep(`remediation planning failed: ${error}`, { banners }))
1247
+ }
1248
+ writeWork(root, 'remediate-1.json', plan)
1249
+
1250
+ // The first adjudication's counts, which is what the receipt reports as the
1251
+ // review. `blockers` is not one of `resolveReview`'s counts — severity is
1252
+ // resolved by the gate — so it comes from the round-one plan, computed from
1253
+ // the same surviving set and never from the agent's self-report.
1254
+ manifest.review = {
1255
+ counts: { ...result.counts, blockers: plan.counts.blockers },
1256
+ dimensions: manifest.reviewDimensions
1257
+ }
1258
+
1259
+ if (!plan.fix.byFile.length && !plan.fix.unscoped.length) {
1260
+ // Round one found nothing that needs a fixer. Spending a remediation round
1261
+ // — or the verdict round after it — on a clean review is pure token cost,
1262
+ // so this is the one place the tail skips straight to final verification
1263
+ // (design D2) rather than walking the rest of the budget as later rounds do.
1264
+ manifest.remediation = { fixed: 0, deferred: plan.deferred.length, blockersRemaining: plan.counts.blockers }
1265
+ return finishStep(ctx, manifest, finalVerifyStep(ctx, manifest, { banners }))
1266
+ }
1267
+
1268
+ manifest.remediation = { fixed: 0, deferred: plan.deferred.length, blockersRemaining: plan.counts.blockers }
1269
+ return finishStep(ctx, manifest, remediateStep(ctx, manifest, plan, { banners }))
1270
+ }
1271
+
1272
+ /**
1273
+ * `run remediated --round N` — the continuation of a fixing or verdict round
1274
+ * (design D2).
1275
+ *
1276
+ * Re-adjudicates the (possibly rewritten) findings/verdicts files, records
1277
+ * fixed/deferred and bumps `fixRoundsRun` for a completed FIX round, then asks
1278
+ * `lib/remediate.mjs` for what comes next. A round that already cleared every
1279
+ * blocker jumps straight to the verdict round rather than spending the rest of
1280
+ * the fix budget on it (moved from `workflows/ship.js`'s retired
1281
+ * `remediationBudget`) — only the verdict round itself may halt.
1282
+ */
1283
+ export function runRemediated(ctx, { round, results }) {
1284
+ const { root } = ctx
1285
+ const manifest = readManifest(root)
1286
+ if (!manifest) return noManifest()
1287
+ const capped = countStep(root, manifest)
1288
+ if (capped) return recordThen(root, manifest, capped)
1289
+
1290
+ // Outside the waves, but not outside the run's spend (design D9).
1291
+ recordUsage(manifest, results)
1292
+
1293
+ const roundCap = LIMITS.remediationRounds
1294
+ const completedRound = Number.isInteger(round) ? round : null
1295
+ if (completedRound === null || completedRound < 1 || completedRound > roundCap + 1) {
1296
+ return finishStep(ctx, manifest, haltStep(
1297
+ `run remediated called with an invalid --round (got ${JSON.stringify(round)}, expected 1-${roundCap + 1})`
1298
+ ))
1299
+ }
1300
+
1301
+ const { result, banners } = adjudicateReview(ctx, manifest)
1302
+ banners.push(...stageMarkerBanners(results))
1303
+
1304
+ if (completedRound === roundCap + 1) {
1305
+ // The verdict round just reported. It fixed and re-reviewed nothing, so
1306
+ // this re-adjudication reflects exactly what the last fix round left
1307
+ // standing — which is what decides the halt (design D2).
1308
+ const { plan, error } = planRemediationRound(result.surviving, completedRound)
1309
+ if (!plan) {
1310
+ return finishStep(ctx, manifest, haltStep(`remediation planning failed: ${error}`, { banners }))
1311
+ }
1312
+ writeWork(root, `remediate-${completedRound}.json`, plan)
1313
+ manifest.remediation = {
1314
+ ...(manifest.remediation || {}),
1315
+ deferred: (manifest.remediation && manifest.remediation.deferred) || 0,
1316
+ blockersRemaining: plan.counts.blockers
1317
+ }
1318
+ manifest.banners.push(...banners)
1319
+ if (plan.halt) {
1320
+ return finishStep(ctx, manifest, haltStep(plan.haltReason, { banners }))
1321
+ }
1322
+ return finishStep(ctx, manifest, finalVerifyStep(ctx, manifest, { banners }))
1323
+ }
1324
+
1325
+ // A completed FIX round. What it attempted is the plan that put it on the
1326
+ // wire in the first place — read back rather than re-derived, so "fixed" is
1327
+ // never a fresh guess about the same round.
1328
+ const attemptedPlan = readJson(join(root, WORK_DIR, `remediate-${completedRound}.json`))
1329
+ const attempted = attemptedPlan ? attemptedPlan.counts.fixing : 0
1330
+
1331
+ let { plan: nextPlan, error } = planRemediationRound(result.surviving, completedRound + 1)
1332
+ if (!nextPlan) {
1333
+ return finishStep(ctx, manifest, haltStep(`remediation planning failed: ${error}`, { banners }))
1334
+ }
1335
+
1336
+ // Every blocker cleared: nothing left justifies spending the rest of the fix
1337
+ // budget, so the next round IS the verdict regardless of what its number
1338
+ // would otherwise be.
1339
+ if (nextPlan.counts.blockers === 0 && !nextPlan.isFinalRound) {
1340
+ const jumped = planRemediationRound(result.surviving, roundCap + 1)
1341
+ if (!jumped.plan) {
1342
+ return finishStep(ctx, manifest, haltStep(`remediation planning failed: ${jumped.error}`, { banners }))
1343
+ }
1344
+ nextPlan = jumped.plan
1345
+ }
1346
+ writeWork(root, `remediate-${nextPlan.round}.json`, nextPlan)
1347
+
1348
+ // Still broken, under the SAME dimensions this round touched (re-review ran
1349
+ // only `attemptedPlan.reReviewDimensions`, so `nextPlan`'s findings reflect
1350
+ // exactly those and nothing else) — what did not get fixed.
1351
+ const stillBroken = nextPlan.counts.blockers + nextPlan.counts.warnings
1352
+ const fixed = Math.max(0, attempted - stillBroken)
1353
+
1354
+ manifest.fixRoundsRun = (manifest.fixRoundsRun || 0) + 1
1355
+ manifest.remediation = {
1356
+ fixed: ((manifest.remediation && manifest.remediation.fixed) || 0) + fixed,
1357
+ deferred: ((manifest.remediation && manifest.remediation.deferred) || 0) + nextPlan.deferred.length,
1358
+ blockersRemaining: nextPlan.counts.blockers
1359
+ }
1360
+ manifest.banners.push(...banners)
1361
+ return finishStep(ctx, manifest, remediateStep(ctx, manifest, nextPlan, { banners }))
1362
+ }
1363
+
1364
+ // --- small file helpers -----------------------------------------------------
1365
+
1366
+ function readProfile(root) {
1367
+ try {
1368
+ return JSON.parse(readFileSync(join(root, '.claude', 'testing', 'profile.json'), 'utf8'))
1369
+ } catch {
1370
+ // No profile is not a degradation to report here — `planVerification` says
1371
+ // so itself, in the skip reasons it returns.
1372
+ return null
1373
+ }
1374
+ }
1375
+
1376
+ function writeWork(root, name, value) {
1377
+ const path = join(root, WORK_DIR, name)
1378
+ mkdirSync(dirname(path), { recursive: true })
1379
+ writeFileSync(path, JSON.stringify(value, null, 2) + '\n')
1380
+ return join(WORK_DIR, name)
1381
+ }
1382
+
1383
+ function readJson(path) {
1384
+ try {
1385
+ return JSON.parse(readFileSync(path, 'utf8'))
1386
+ } catch {
1387
+ return null
1388
+ }
1389
+ }
1390
+
1391
+ export function readState(root) {
1392
+ return readJson(join(root, STATE_PATH))
1393
+ }
1394
+
1395
+ export function writeState(root, state) {
1396
+ const path = join(root, STATE_PATH)
1397
+ mkdirSync(dirname(path), { recursive: true })
1398
+ writeFileSync(path, JSON.stringify(state, null, 2) + '\n')
1399
+ return state
1400
+ }
1401
+
1402
+ // --- the sequence guard -----------------------------------------------------
1403
+
1404
+ /**
1405
+ * Refuse a `run` subcommand the program did not ask for next.
1406
+ *
1407
+ * The manifest records the argv the last step named as its continuation. A
1408
+ * driver that skipped a step, repeated one, or called a subcommand of its own
1409
+ * invention would otherwise advance the state machine twice or record a batch
1410
+ * against the wrong wave — both silent. Returns null when the call is in
1411
+ * sequence, and the reason it is not when it is not.
1412
+ */
1413
+ export function checkSequence(manifest, argv) {
1414
+ const expected = manifest.lastThen
1415
+ if (!Array.isArray(expected) || !expected.length) return null
1416
+ // Compare what the program asked for as a PREFIX of what was called: the
1417
+ // driver appends `--results <file>` and may append `--json`, and neither is
1418
+ // part of the ask. Everything the step named must be present, in order.
1419
+ const strip = list => list.filter(a => a !== '--json')
1420
+ const want = strip(expected)
1421
+ const got = strip(argv)
1422
+ const matches = want.every((token, i) => got[i] === token)
1423
+ if (matches) return null
1424
+ return (
1425
+ `out of sequence: the run program's last step named \`interlock ${want.join(' ')}\` as its ` +
1426
+ `continuation, and this call was \`interlock ${got.join(' ')}\``
1427
+ )
1428
+ }
1429
+
1430
+ /** Record the continuation this step named, so the next call can be checked against it. */
1431
+ export function recordThen(root, manifest, step) {
1432
+ manifest.lastThen = step && step.then && Array.isArray(step.then.argv) ? step.then.argv : null
1433
+ writeManifest(root, manifest)
1434
+ return step
1435
+ }
1436
+
1437
+ // --- trajectory -------------------------------------------------------------
1438
+
1439
+ /**
1440
+ * Log every agent this step asks for.
1441
+ *
1442
+ * `wave-state next` already logs the IMPLEMENTER spawns it names in its own
1443
+ * tasks[]; it has no way to know about the ping, the verifier or the committer
1444
+ * the program is about to ask for. Both drivers used to cover those with an
1445
+ * extra `run-log append` fused into each prompt — an instruction a model could
1446
+ * skip. The CLI that decided on the spawn records it.
1447
+ */
1448
+ function logSpawns(ctx, manifest, step) {
1449
+ // No run identity yet — the classify step runs before there is a wave state,
1450
+ // so nothing is owed. The same rule `logVerifyJudgement` follows: a call with
1451
+ // no runId is not part of a logged run, and logging is not what should fail it.
1452
+ if (!manifest.runId) return step
1453
+ for (const s of step.spawns || []) {
1454
+ if (s.kind === 'implementer') continue // logged by wave-state, at the lane
1455
+ const written = appendRunLogEvent(ctx.root, {
1456
+ runId: manifest.runId || undefined,
1457
+ change: manifest.change,
1458
+ type: 'agent-spawn',
1459
+ label: s.label,
1460
+ model: s.model || undefined,
1461
+ kind: s.kind
1462
+ })
1463
+ if (!written.written) ctx.warn(`agent-spawn not recorded: ${written.reason}`)
1464
+ }
1465
+ return step
1466
+ }
1467
+
1468
+ function logCliExit(ctx, manifest, command, exitCode) {
1469
+ if (!manifest.runId) return
1470
+ const written = appendRunLogEvent(ctx.root, {
1471
+ runId: manifest.runId,
1472
+ change: manifest.change,
1473
+ type: 'cli-exit',
1474
+ command,
1475
+ exitCode
1476
+ })
1477
+ if (!written.written) ctx.warn(`cli-exit not recorded: ${written.reason}`)
1478
+ }
1479
+
1480
+ /** The shared tree's HEAD. Never throws: an unreadable base is null, never a guess. */
1481
+ function headCommit(ctx) {
1482
+ return ctx.deps.headCommit(ctx.root)
1483
+ }
1484
+
1485
+ // --- adjudication (moved from the RECORDED_VERDICT block) -------------------
1486
+ //
1487
+ // One authority for "what did this task do": the outcome `record-batch`
1488
+ // recorded, not the outcome the implementing agent reported for itself. The
1489
+ // claim is the input the state machine adjudicated — an invalid or over-budget
1490
+ // handoff packet fails its task — so where the two differ the recorded outcome
1491
+ // is the one a tick and a tally are built from.
1492
+ //
1493
+ // Both hosts read this from one place now. A second copy of "which of two
1494
+ // disagreeing observations is the run's" would drift, and both hosts would
1495
+ // still look correct.
1496
+
1497
+ /** The three outcomes a batch can record. Anything else is not a verdict. */
1498
+ const RECORDED_OUTCOMES = ['ok', 'failed', 'not-attempted']
1499
+
1500
+ /**
1501
+ * The recorded verdicts `batchOutcomes` produced, validated.
1502
+ *
1503
+ * One malformed entry invalidates the whole payload rather than being skipped:
1504
+ * a partial verdict read as a complete one is the defect this exists to close,
1505
+ * one level down. `null` means "no verdict arrived" and puts the caller on the
1506
+ * loud claim-derived fallback.
1507
+ */
1508
+ export function recordedOutcomes(raw) {
1509
+ if (!Array.isArray(raw) || !raw.length) return null
1510
+ const out = []
1511
+ for (const entry of raw) {
1512
+ if (!entry || typeof entry !== 'object') return null
1513
+ if (typeof entry.id !== 'string' || !entry.id.trim()) return null
1514
+ if (!RECORDED_OUTCOMES.includes(entry.outcome)) return null
1515
+ out.push({
1516
+ id: entry.id.trim(),
1517
+ outcome: entry.outcome,
1518
+ reason: typeof entry.reason === 'string' && entry.reason.trim() ? entry.reason.trim() : ''
1519
+ })
1520
+ }
1521
+ return out
1522
+ }
1523
+
1524
+ /**
1525
+ * What to tick, what to tally, and which claims the run had to override.
1526
+ *
1527
+ * @param {Array<Array<{id: string, ok: boolean, error?: string}>>} batches the
1528
+ * claims sent to `record-batch`, one array per batch, in command order
1529
+ * @param {Array<{id: string, outcome: string, reason: string}>|null} recorded
1530
+ * the verdicts those commands reported, concatenated in the same order
1531
+ */
1532
+ export function adjudicateBatches(batches, recorded) {
1533
+ const claims = batches.flat()
1534
+ // Every claim must have a verdict, or there is no verdict: a payload covering
1535
+ // three of five tasks would otherwise tally the other two from the claim
1536
+ // while reading as fully recorded.
1537
+ const covered = Boolean(recorded) && claims.every(c => recorded.some(o => o.id === c.id))
1538
+ const verdicts = covered ? recorded : null
1539
+ const rowsOf = batch =>
1540
+ batch.map(claim => {
1541
+ const found = verdicts ? verdicts.find(o => o.id === claim.id) : null
1542
+ return {
1543
+ id: claim.id,
1544
+ outcome: found ? found.outcome : claim.ok ? 'ok' : 'failed',
1545
+ reason: found ? found.reason : typeof claim.error === 'string' ? claim.error : ''
1546
+ }
1547
+ })
1548
+
1549
+ const perBatch = batches.map(rowsOf)
1550
+ const rows = perBatch.flat()
1551
+ return {
1552
+ claimDerived: !verdicts,
1553
+ tickIds: rows.filter(r => r.outcome === 'ok').map(r => r.id),
1554
+ waves: perBatch.map(batchRows => ({
1555
+ ok: batchRows.filter(r => r.outcome === 'ok').length,
1556
+ failed: batchRows.filter(r => r.outcome === 'failed').length,
1557
+ failedIds: batchRows.filter(r => r.outcome === 'failed').map(r => r.id),
1558
+ recordedNotAttempted: batchRows.filter(r => r.outcome === 'not-attempted').map(r => r.id)
1559
+ })),
1560
+ overrides: rows.filter((row, i) => verdicts && (row.outcome === 'ok') !== claims[i].ok)
1561
+ }
1562
+ }
1563
+
1564
+ /**
1565
+ * An overridden claim is a finding, not a silent correction. A run that quietly
1566
+ * fixed up five bad handoffs is indistinguishable from a clean one; a run that
1567
+ * says it overrode five claims tells its reader the implementer prompt or the
1568
+ * handoff contract needs attention.
1569
+ */
1570
+ export function overrideBanner(overrides) {
1571
+ const detail = overrides
1572
+ .map(o => `${o.id}: ${o.outcome}${o.reason ? ` — ${o.reason}` : ''}`)
1573
+ .join('; ')
1574
+ return (
1575
+ `CLAIM OVERRIDDEN: ${overrides.map(o => o.id).join(', ')} — the implementing agent reported ` +
1576
+ `otherwise and the run acted on what it recorded (${detail})`
1577
+ )
1578
+ }
1579
+
1580
+ /**
1581
+ * The fallback said out loud. Silent fallback would reintroduce the defect in
1582
+ * the one case nobody would think to check, and a count that was never read
1583
+ * must not read as one that came back clean.
1584
+ */
1585
+ export function claimDerivedBanner() {
1586
+ return (
1587
+ 'CLAIM-DERIVED TALLIES: record-batch reported no usable per-task outcomes, so this wave was ' +
1588
+ 'ticked and counted from what the implementing agents claimed rather than from what the run recorded'
1589
+ )
1590
+ }
1591
+
1592
+ /**
1593
+ * Turn one lane's agent result into an outcome per task it was given.
1594
+ *
1595
+ * Fails CLOSED, in one direction only: a result that does not account for every
1596
+ * task in the lane fails all of them, because a lane that reported on two of its
1597
+ * three tasks has told us nothing about the third — and "nothing" read as
1598
+ * success is how a task silently ships unimplemented. `not-attempted` is the one
1599
+ * outcome that is neither: it is reported, ticked nowhere, and counted nowhere.
1600
+ */
1601
+ export function laneOutcomes(lane, result) {
1602
+ const failAll = error => lane.map(t => ({ id: t.id, outcome: 'failed', error, handoff: null }))
1603
+ if (!result) return failAll('agent returned no result')
1604
+
1605
+ if (lane.length === 1) {
1606
+ const [only] = lane
1607
+ return [
1608
+ {
1609
+ id: result.id || only.id,
1610
+ outcome: result.ok ? 'ok' : 'failed',
1611
+ error: result.error,
1612
+ filesChanged: result.filesChanged,
1613
+ handoff: result.handoff
1614
+ }
1615
+ ]
1616
+ }
1617
+
1618
+ const entries = Array.isArray(result.tasks) ? result.tasks : null
1619
+ if (!entries) return failAll('lane result carried no per-task outcomes')
1620
+
1621
+ const byId = new Map()
1622
+ for (const e of entries) {
1623
+ if (e && typeof e === 'object' && typeof e.id === 'string') byId.set(e.id, e)
1624
+ }
1625
+ const missing = lane.filter(t => !byId.has(t.id)).map(t => t.id)
1626
+ if (missing.length) return failAll(`lane result omitted an outcome for ${missing.join(', ')}`)
1627
+
1628
+ return lane.map(t => {
1629
+ const e = byId.get(t.id)
1630
+ if (e.outcome === 'ok') {
1631
+ return {
1632
+ id: t.id,
1633
+ outcome: 'ok',
1634
+ error: e.error,
1635
+ filesChanged: e.filesChanged,
1636
+ handoff: e.handoff
1637
+ }
1638
+ }
1639
+ if (e.outcome === 'not-attempted') return { id: t.id, outcome: 'not-attempted' }
1640
+ // Anything else — including a value the schema let through — is a failure.
1641
+ // Guessing which of three named outcomes an unnamed one meant is exactly the
1642
+ // inference the schema exists to remove.
1643
+ return {
1644
+ id: t.id,
1645
+ outcome: 'failed',
1646
+ error: e.error || `lane reported outcome ${JSON.stringify(e.outcome)}`,
1647
+ filesChanged: e.filesChanged,
1648
+ handoff: e.handoff
1649
+ }
1650
+ })
1651
+ }
1652
+
1653
+ // --- run start (design D4, D2) ----------------------------------------------
1654
+
1655
+ /**
1656
+ * Begin a run: write the manifest, validate the change, and either adopt a
1657
+ * reusable plan or ask for a classification.
1658
+ *
1659
+ * `plan-waves` is the most expensive fixed step of a run — it reads proposal.md,
1660
+ * design.md, tasks.md and every delta spec in full — so the program first asks
1661
+ * whether the stored plan still matches the inputs it was built from. Reuse is
1662
+ * never assumed: anything other than an affirmative match is a rebuild,
1663
+ * including an error while checking. Failing to prove reuse costs a classifier
1664
+ * run; wrongly reusing would run the wrong plan.
1665
+ *
1666
+ * @param {{root: string, deps: object, warn: (m: string) => void}} ctx
1667
+ * @param {{change: string, flags: object, host?: string, hostCapabilities?: object}} input
1668
+ */
1669
+ export function runStart(ctx, { change, flags, host, hostCapabilities }) {
1670
+ const { root } = ctx
1671
+ const manifest = newManifest({ change, flags, host, hostCapabilities })
1672
+ writeManifest(root, manifest)
1673
+
1674
+ const capped = countStep(root, manifest)
1675
+ if (capped) return recordThen(root, manifest, capped)
1676
+
1677
+ const inspection = inspectChange(root, change)
1678
+ if (!inspection.ready) {
1679
+ const problems = (inspection.problems || []).map(p => (typeof p === 'string' ? p : p.detail || p.id))
1680
+ return finishStep(ctx, manifest, haltStep(
1681
+ `validate failed: ${problems.join('; ') || 'the change is not implementable'}`
1682
+ ))
1683
+ }
1684
+
1685
+ // The marker is readable here; the structural fallback is not, because no
1686
+ // classification exists until the classify step runs. So a marker-shaped TDD
1687
+ // run — the ordinary one — matches its stored fingerprint and reuses its plan,
1688
+ // and a structurally-inferred one re-plans rather than reusing a plan built
1689
+ // for the other shape. `resolveRedWave` is called without `tasks` to say
1690
+ // exactly that, and stays silent about step 3 rather than claiming it failed.
1691
+ const reuseRed = resolveRedWave({
1692
+ tddMode: manifest.flags.tddMode || null,
1693
+ redSection: inspection.tasks.redSection
1694
+ })
1695
+ manifest.banners.push(...reuseRed.banners)
1696
+
1697
+ const reuse = resolvePlanReuse(root, change, {
1698
+ maxParallel: numberOrUndefined(manifest.flags.maxParallel),
1699
+ mode: manifest.flags.laneMode || undefined,
1700
+ redWave: reuseRed.group
1701
+ })
1702
+
1703
+ if (reuse.reuse && reuse.noRemainingWork) {
1704
+ // Every task in the stored plan is already ticked. That is not an empty run
1705
+ // to dispatch — it is a change with nothing left to do, and creating a
1706
+ // zero-batch run would report a clean ship that implemented nothing.
1707
+ manifest.plan = { reused: true, status: reuse.status, reason: reuse.reason }
1708
+ manifest.notes.push(
1709
+ 'NO REMAINING WORK: every task in the stored plan is already complete — nothing was dispatched'
1710
+ )
1711
+ manifest.commitSkipped = true
1712
+ return finishStep(ctx, manifest, makeStep({ action: 'close', then: { argv: ['run', 'close'] } }))
1713
+ }
1714
+
1715
+ if (reuse.reuse && reuse.narrowed) {
1716
+ manifest.plan = { reused: true, status: reuse.status, reason: reuse.reason }
1717
+ writeWork(root, 'plan-reuse.json', reuse.narrowed)
1718
+ return finishStep(ctx, manifest, adoptPlan(ctx, manifest, reuse.narrowed))
1719
+ }
1720
+
1721
+ manifest.plan = { reused: false, status: reuse.status, reason: reuse.reason }
1722
+ writeManifest(root, manifest)
1723
+
1724
+ // No reusable plan: one spawn, whose whole job is to write `classified.json`.
1725
+ // It never runs the CLI — `run classified` does coverage, the planner, the
1726
+ // plan file and the state creation afterwards (design D7).
1727
+ return finishStep(ctx, manifest, makeStep({
1728
+ action: 'classify',
1729
+ then: { argv: ['run', 'classified', '--classified', CLASSIFIED_PATH] },
1730
+ spawns: [
1731
+ spawn(root, {
1732
+ label: 'plan-waves',
1733
+ kind: 'planner',
1734
+ model: null,
1735
+ effort: null,
1736
+ schema: PLANNER_SCHEMA,
1737
+ prompt: assemblePlannerPrompt({ change, classifiedPath: CLASSIFIED_PATH })
1738
+ })
1739
+ ]
1740
+ }))
1741
+ }
1742
+
1743
+ function numberOrUndefined(value) {
1744
+ return Number.isInteger(value) ? value : undefined
1745
+ }
1746
+
1747
+ /** Turn a plan into a run state, log the run start, and emit the first step. */
1748
+ function adoptPlan(ctx, manifest, plan) {
1749
+ const { root } = ctx
1750
+ let state
1751
+ try {
1752
+ state = createRunState(plan, {
1753
+ maxParallel: numberOrUndefined(manifest.flags.maxParallel),
1754
+ change: manifest.change
1755
+ })
1756
+ } catch (err) {
1757
+ return haltStep(`wave-state create failed: ${(err && err.message) || String(err)}`)
1758
+ }
1759
+ writeState(root, state)
1760
+ manifest.runId = typeof state.runId === 'string' ? state.runId : null
1761
+
1762
+ const started = appendRunLogEvent(root, {
1763
+ runId: manifest.runId || undefined,
1764
+ change: manifest.change,
1765
+ type: 'run-start',
1766
+ mode: manifest.flags.mode || 'checkpoint',
1767
+ strict: manifest.flags.strict === true,
1768
+ host: hostId(manifest) || undefined,
1769
+ // `undefined`, not a placeholder: a host with no session records absent, and
1770
+ // the run is unaffected either way — nothing downstream reads this to decide
1771
+ // anything, and `run-log check` does not require it.
1772
+ sessionId: hostSessionId(manifest, ctx.env) || undefined
1773
+ })
1774
+ if (!started.written) ctx.warn(`run-start not recorded: ${started.reason}`)
1775
+
1776
+ const { step, ok } = ctx.deps.logWaveMutation(root, {
1777
+ source: 'create',
1778
+ change: manifest.change,
1779
+ state
1780
+ })
1781
+ if (!ok) ctx.warn('the wave-state trajectory append failed on run start')
1782
+ ctx.deps.logAgentSpawns(root, { state, change: manifest.change, step, source: 'next' })
1783
+ return decorate(ctx, step, manifest)
1784
+ }
1785
+
1786
+ // --- run classified (design D7) ---------------------------------------------
1787
+
1788
+ /**
1789
+ * Which section, if any, runs as the leading failing-test wave.
1790
+ *
1791
+ * The execution half of the TDD task shape `/interlock:spec` authors. It is
1792
+ * resolved HERE rather than in the planner because it comes from `tasks.md` —
1793
+ * `lib/waves.mjs` is pure and cannot read a file — and rather than from the
1794
+ * classifier because the shape is a fact the author wrote down, not a judgement
1795
+ * worth re-taking on every run. That is also why nothing in the classifier
1796
+ * prompt changed for this: a change nobody authored for TDD is classified today
1797
+ * exactly as it was yesterday.
1798
+ *
1799
+ * Precedence, and every branch says what it did:
1800
+ * 1. `--no-tdd` refuses the shape outright.
1801
+ * 2. The explicit `tasks.md` marker wins, with no flag needed.
1802
+ * 3. `--tdd` with no marker falls back to STRUCTURE: the lowest section whose
1803
+ * classified tasks are all test tasks. This is what makes the flag useful
1804
+ * on a change written test-first without the exact heading.
1805
+ * 4. Otherwise there is no red wave, which is the ordinary case.
1806
+ *
1807
+ * @param {{tddMode: string|null, redSection: number|null, tasks?: Array}} input
1808
+ * `tasks` is the classification. OMIT it where no classification exists yet —
1809
+ * the plan-reuse check at `runStart` runs before the classifier — and step 3
1810
+ * is skipped silently rather than reported as unavailable, because "we cannot
1811
+ * answer that yet" is not the same claim as "there is no red wave here". The
1812
+ * cost is that a structurally-inferred shape does not match a stored
1813
+ * fingerprint and re-plans; the alternative is reusing a plan built for the
1814
+ * other shape, which is the failure the fingerprint exists to prevent.
1815
+ * @returns {{group: number|undefined, banners: string[]}}
1816
+ */
1817
+ export function resolveRedWave({ tddMode, redSection, tasks }) {
1818
+ const banners = []
1819
+ const marker = Number.isInteger(redSection) ? redSection : null
1820
+
1821
+ if (tddMode === 'no-tdd') {
1822
+ if (marker !== null) {
1823
+ banners.push(
1824
+ `TDD SHAPE REFUSED: tasks.md section ${marker} is marked as a leading failing-test wave, ` +
1825
+ `and --no-tdd was passed — its tests defer to the trailing test wave like any other`
1826
+ )
1827
+ }
1828
+ return { group: undefined, banners }
1829
+ }
1830
+
1831
+ if (marker !== null) return { group: marker, banners }
1832
+
1833
+ if (tddMode === 'tdd' && Array.isArray(tasks)) {
1834
+ const groups = [...new Set(tasks.map(t => t && t.group).filter(Number.isInteger))].sort(
1835
+ (a, b) => a - b
1836
+ )
1837
+ const lowest = groups[0]
1838
+ const inLowest = tasks.filter(t => t && t.group === lowest)
1839
+ if (lowest !== undefined && inLowest.length && inLowest.every(t => t.isTestTask === true)) {
1840
+ banners.push(
1841
+ `TDD SHAPE INFERRED: tasks.md carries no "${RED_SECTION_MARKER}" heading, but every task ` +
1842
+ `classified in section ${lowest} is a test task, so --tdd is honoured against that section`
1843
+ )
1844
+ return { group: lowest, banners }
1845
+ }
1846
+ banners.push(
1847
+ `TDD SHAPE UNAVAILABLE: --tdd was passed, but tasks.md has no "${RED_SECTION_MARKER}" ` +
1848
+ `heading and the first section is not all test tasks — there is no failing suite to run ` +
1849
+ `first, so this run is planned in the ordinary shape`
1850
+ )
1851
+ }
1852
+
1853
+ return { group: undefined, banners }
1854
+ }
1855
+
1856
+ /**
1857
+ * The mechanical work after the classifier: coverage, the planner, the plan
1858
+ * file, the run state and the first step.
1859
+ *
1860
+ * The Workflow driver used to ask the classifying agent to run all four commands
1861
+ * itself. Four CLI calls made by the party whose output they check is four
1862
+ * chances for a coverage gap to be reported as clean.
1863
+ */
1864
+ export function runClassified(ctx, { classifiedPath }) {
1865
+ const { root } = ctx
1866
+ const manifest = readManifest(root)
1867
+ if (!manifest) return noManifest()
1868
+ const capped = countStep(root, manifest)
1869
+ if (capped) return recordThen(root, manifest, capped)
1870
+
1871
+ const classified = readJson(join(root, classifiedPath)) || readJson(classifiedPath)
1872
+ if (!classified) {
1873
+ return finishStep(ctx, manifest, haltStep(
1874
+ `wave planning failed: no readable ${classifiedPath} from the classify step`
1875
+ ))
1876
+ }
1877
+
1878
+ const listed = Array.isArray(classified)
1879
+ ? classified
1880
+ : Array.isArray(classified.tasks)
1881
+ ? classified.tasks
1882
+ : []
1883
+ const inspection = inspectChange(root, manifest.change)
1884
+ const coverage = planCoverage(inspection.tasks.items, listed)
1885
+ if (!coverage.ok) {
1886
+ return finishStep(ctx, manifest, haltStep(
1887
+ `plan omitted unchecked tasks: ${(coverage.omitted || []).join(', ') || 'classified.json does not cover remaining checkboxes'}`
1888
+ ))
1889
+ }
1890
+
1891
+ // Resolved before the planner and reused by the fingerprint, so a cached plan
1892
+ // is never reused across a change of shape. Both readers must be given the
1893
+ // SAME value — a plan built with a red wave and fingerprinted without one is a
1894
+ // plan the next run would reuse for the wrong shape.
1895
+ const red = resolveRedWave({
1896
+ tddMode: manifest.flags.tddMode || null,
1897
+ redSection: inspection.tasks.redSection,
1898
+ tasks: listed
1899
+ })
1900
+ // Deduplicated against what `runStart` already said. The marker and --no-tdd
1901
+ // branches are reachable from both sites and produce the same string, so
1902
+ // pushing blind would print the same banner twice in one run's close.
1903
+ for (const banner of red.banners) {
1904
+ if (!manifest.banners.includes(banner)) manifest.banners.push(banner)
1905
+ }
1906
+
1907
+ let plan
1908
+ try {
1909
+ plan = planWaves(classified, {
1910
+ maxParallel: numberOrUndefined(manifest.flags.maxParallel),
1911
+ mode: manifest.flags.laneMode || undefined,
1912
+ redWave: red.group
1913
+ })
1914
+ } catch (err) {
1915
+ return finishStep(ctx, manifest, haltStep(`waves failed: ${(err && err.message) || String(err)}`))
1916
+ }
1917
+ // The planner adjudicates the claim against the classification and can refuse
1918
+ // it (a section with no test task, a section that is not first). Its refusal
1919
+ // is in `plan.warnings`; surfaced as a banner too, because a shape the
1920
+ // operator asked for and did not get must not be one line in a plan file.
1921
+ if (red.group !== undefined && plan.redWave === null) {
1922
+ for (const warning of plan.warnings) {
1923
+ if (warning.startsWith('red wave refused:')) {
1924
+ manifest.banners.push(`TDD SHAPE REFUSED: ${warning.slice('red wave refused: '.length)}`)
1925
+ }
1926
+ }
1927
+ }
1928
+ writeWork(root, 'plan.json', plan)
1929
+
1930
+ // Store the fingerprint of the artifacts this plan was derived from, so a
1931
+ // later run of the same unedited change reuses this plan instead of re-reading
1932
+ // everything. It never fails the run: a fingerprint that could not be written
1933
+ // costs the NEXT run a classifier pass, which is what every run used to pay —
1934
+ // and the failure is said out loud rather than discovered as a slow run.
1935
+ try {
1936
+ const written = writeFingerprint(
1937
+ root,
1938
+ computeFingerprint(root, manifest.change, {
1939
+ maxParallel: numberOrUndefined(manifest.flags.maxParallel),
1940
+ mode: manifest.flags.laneMode || undefined,
1941
+ // The RESOLVED wave, not the requested one: what this plan was actually
1942
+ // built under is what a later run has to match to reuse it.
1943
+ redWave: plan.redWave === null ? undefined : plan.redWave
1944
+ })
1945
+ )
1946
+ if (!written.written) {
1947
+ manifest.banners.push(
1948
+ 'PLAN FINGERPRINT NOT STORED: this plan cannot be proven current later, so the next run ' +
1949
+ `will re-classify every artifact from scratch (${written.reason})`
1950
+ )
1951
+ }
1952
+ } catch (err) {
1953
+ manifest.banners.push(
1954
+ 'PLAN FINGERPRINT NOT STORED: this plan cannot be proven current later, so the next run ' +
1955
+ `will re-classify every artifact from scratch (${(err && err.message) || String(err)})`
1956
+ )
1957
+ }
1958
+
1959
+ return finishStep(ctx, manifest, adoptPlan(ctx, manifest, plan))
1960
+ }
1961
+
1962
+ // --- run record-batch (design D5) -------------------------------------------
1963
+
1964
+ /**
1965
+ * Fold, adjudicate, record and tick — one CLI call where the loop used to spend
1966
+ * three agent turns.
1967
+ *
1968
+ * A lane with any failed task is neither folded nor ticked, and its worktree is
1969
+ * named rather than removed: folding a partial write from a lane that stopped
1970
+ * mid-way is exactly the silent-overwrite defect the merge policy refuses.
1971
+ */
1972
+ export function runRecordBatch(ctx, { results }) {
1973
+ const { root } = ctx
1974
+ const manifest = readManifest(root)
1975
+ if (!manifest) return noManifest()
1976
+ const capped = countStep(root, manifest)
1977
+ if (capped) return recordThen(root, manifest, capped)
1978
+
1979
+ const state = readState(root)
1980
+ if (!state) return finishStep(ctx, manifest, haltStep('the run state could not be read'))
1981
+ const pending = nextStep(state)
1982
+ const lanes = lanesOf(pending)
1983
+ if (!lanes.length) {
1984
+ return finishStep(ctx, manifest, haltStep(
1985
+ `record-batch was called while the run state was at "${pending.action}", which dispatches no lanes`
1986
+ ))
1987
+ }
1988
+
1989
+ const banners = []
1990
+ const reported = []
1991
+ const unattempted = []
1992
+ const laneFoldCandidates = []
1993
+ const laneWorktreesPreserved = []
1994
+ const isolate = manifest.flags.isolateWaves === true
1995
+ const driverWorktrees = isolate && hostCapability(manifest, 'worktree') === 'driver'
1996
+
1997
+ recordUsage(manifest, results, pending.wave)
1998
+
1999
+ lanes.forEach((lane, i) => {
2000
+ const outcomes = laneOutcomes(lane, results[i])
2001
+ for (const o of outcomes) {
2002
+ if (o.outcome === 'not-attempted') unattempted.push(o.id)
2003
+ else {
2004
+ reported.push({
2005
+ id: o.id,
2006
+ ok: o.outcome === 'ok',
2007
+ error: o.error,
2008
+ handoff: o.handoff,
2009
+ filesChanged: o.filesChanged
2010
+ })
2011
+ }
2012
+ }
2013
+ if (!isolate) return
2014
+ const label = laneLabel(lane)
2015
+ // On a runner host the path is DERIVED, not read off the result: the driver
2016
+ // created that exact directory from the step that named it, so asking the
2017
+ // agent where it ran would put the fold at the mercy of a field a vendor CLI
2018
+ // has no reason to fill in. The Workflow host still reports its own `pwd`.
2019
+ const worktreePath = driverWorktrees
2020
+ ? join(root, laneWorktreePath(pending.wave, label))
2021
+ : results[i] && typeof results[i].worktreePath === 'string'
2022
+ ? results[i].worktreePath.trim()
2023
+ : ''
2024
+ if (outcomes.some(o => o.outcome === 'failed')) {
2025
+ if (worktreePath) laneWorktreesPreserved.push({ label, worktreePath })
2026
+ return
2027
+ }
2028
+ laneFoldCandidates.push({
2029
+ label,
2030
+ worktreePath: worktreePath || undefined,
2031
+ reportedFiles: outcomes.flatMap(o => (Array.isArray(o.filesChanged) ? o.filesChanged : []))
2032
+ })
2033
+ })
2034
+
2035
+ // A task nobody ran is not a task that failed. It is left unticked and
2036
+ // uncounted on purpose — spending the failure budget on the three tasks
2037
+ // sitting behind one real blocker would halt a run that has one problem.
2038
+ if (unattempted.length) {
2039
+ banners.push(
2040
+ `LANE STOPPED EARLY: ${unattempted.join(', ')} not attempted after an earlier task in ` +
2041
+ `the same lane failed — not counted as failures, and still unchecked in tasks.md`
2042
+ )
2043
+ }
2044
+
2045
+ // Fold this batch's successful lanes back into the shared tree before moving
2046
+ // on — the NEXT batch's merge base is read off this tree.
2047
+ if (isolate && laneFoldCandidates.length) {
2048
+ // The base the lanes actually forked from, captured at dispatch and carried
2049
+ // on the manifest across the driver round trip. `pending` is a pure
2050
+ // wave-state step and never carries it. Re-reading HEAD here is the
2051
+ // fallback, and it is a DIFFERENT commit whenever anything landed on the
2052
+ // shared tree while the batch ran — so it is spoken rather than taken
2053
+ // silently.
2054
+ const stored = typeof manifest.mergeBase === 'string' && manifest.mergeBase ? manifest.mergeBase : null
2055
+ const base = stored || headCommit(ctx)
2056
+ if (!stored) {
2057
+ banners.push(
2058
+ `MERGE BASE RE-READ AFTER THE BATCH: the base captured at dispatch was not carried to the ` +
2059
+ `fold, so lane diffs are taken against the post-batch HEAD (${base || 'unreadable'})`
2060
+ )
2061
+ }
2062
+ const merged = base ? ctx.deps.runMergeLanes(root, laneFoldCandidates, base) : null
2063
+ if (Array.isArray(merged && merged.cleanupWarnings) && merged.cleanupWarnings.length) {
2064
+ banners.push(
2065
+ `LANE WORKTREE CLEANUP WARNING: ${JSON.stringify(merged.cleanupWarnings)} — the fold applied, ` +
2066
+ `removal of the worktree itself did not`
2067
+ )
2068
+ }
2069
+ if (!merged || merged.status !== 'clean') {
2070
+ const survivors = [
2071
+ ...(merged && Array.isArray(merged.survivingWorktrees) ? merged.survivingWorktrees : []),
2072
+ ...laneWorktreesPreserved
2073
+ ]
2074
+ .map(w => `${w.label}: ${w.worktreePath}`)
2075
+ .join('; ')
2076
+ return finishStep(ctx, manifest, haltStep(
2077
+ `merge-lanes halted on wave ${pending.wave}: ` +
2078
+ (merged
2079
+ ? merged.status === 'collision'
2080
+ ? `real collision on ${JSON.stringify(merged.collisions)}`
2081
+ : `unresolved lane(s) ${JSON.stringify(merged.unresolved)}`
2082
+ : 'the shared-tree base commit could not be read, so no fold was attempted') +
2083
+ ` — surviving worktrees: ${survivors || '(none)'}`,
2084
+ { banners }
2085
+ ))
2086
+ }
2087
+ }
2088
+ if (isolate && laneWorktreesPreserved.length) {
2089
+ banners.push(
2090
+ `LANE WORKTREE PRESERVED: ${laneWorktreesPreserved.map(w => `${w.label} at ${w.worktreePath}`).join('; ')} ` +
2091
+ `— the lane failed and its writes were not folded into the shared tree`
2092
+ )
2093
+ }
2094
+ // Spent. The next isolated batch captures its own base at dispatch, and a base
2095
+ // left behind would be the previous batch's — older than the tree the next
2096
+ // batch's lanes fork from.
2097
+ manifest.mergeBase = null
2098
+
2099
+ // Record. The observed path set is read here, never taken from the results
2100
+ // file: that file is written by the agent being audited, and an audit whose
2101
+ // input its subject supplies is not an audit.
2102
+ const after = recordBatchResult(state, { tasks: reported }, {
2103
+ changedPaths: ctx.deps.observedChangedPaths(root)
2104
+ })
2105
+ writeState(root, after)
2106
+ const { step: nextRaw, ok: logOk } = ctx.deps.logWaveMutation(root, {
2107
+ source: 'record-batch',
2108
+ change: manifest.change,
2109
+ state: after
2110
+ })
2111
+ if (!logOk) ctx.warn('the wave-state trajectory append failed on record-batch')
2112
+ ctx.deps.logAgentSpawns(root, {
2113
+ state: after,
2114
+ change: manifest.change,
2115
+ step: nextRaw,
2116
+ source: 'record-batch'
2117
+ })
2118
+
2119
+ // One authority, read once, for the tick and for the tallies alike.
2120
+ const verdict = adjudicateBatches([reported], recordedOutcomes(batchOutcomes(state, after)))
2121
+ if (verdict.claimDerived) banners.push(claimDerivedBanner())
2122
+ if (verdict.overrides.length) banners.push(overrideBanner(verdict.overrides))
2123
+
2124
+ verdict.waves.forEach(counts => {
2125
+ manifest.waves.push({
2126
+ wave: pending.wave,
2127
+ kind: pending.action,
2128
+ lanes: lanes.length,
2129
+ ok: counts.ok,
2130
+ failed: counts.failed,
2131
+ failedIds: counts.failedIds,
2132
+ // The lane that stopped early, plus anything the recorder found no result
2133
+ // for at all. Neither is ticked and neither is a failure.
2134
+ notAttempted: [
2135
+ ...unattempted,
2136
+ ...counts.recordedNotAttempted.filter(id => !unattempted.includes(id))
2137
+ ]
2138
+ })
2139
+ })
2140
+
2141
+ // Ticked from what the run RECORDED, never from what an agent claimed — a box
2142
+ // ticked from a claim is how unimplemented work ships behind a "[x]".
2143
+ if (verdict.tickIds.length) {
2144
+ let ticked
2145
+ try {
2146
+ ticked = tickTasks(root, manifest.change, verdict.tickIds)
2147
+ } catch (err) {
2148
+ ticked = { missing: verdict.tickIds, error: (err && err.message) || String(err) }
2149
+ }
2150
+ if (ticked.missing && ticked.missing.length) {
2151
+ banners.push(
2152
+ `TASK TICK FAILED: could not mark ${ticked.missing.join(', ')} ` +
2153
+ `complete — the work was done, the checkbox was not`
2154
+ )
2155
+ }
2156
+ }
2157
+
2158
+ manifest.banners.push(...banners)
2159
+ return finishStep(ctx, manifest, decorate(ctx, nextRaw, manifest, { banners }))
2160
+ }
2161
+
2162
+ // --- run judge --------------------------------------------------------------
2163
+
2164
+ /**
2165
+ * Judge a verification's reported results and take the branch its verdict
2166
+ * implies.
2167
+ *
2168
+ * The verdict is rendered here and nowhere else. The agent that ran the suites
2169
+ * reports exit codes and counts; whether the run may continue is not its
2170
+ * question, and both drivers used to let it answer.
2171
+ */
2172
+ export function runJudge(ctx, { context, results }) {
2173
+ const { root } = ctx
2174
+ const manifest = readManifest(root)
2175
+ if (!manifest) return noManifest()
2176
+ const capped = countStep(root, manifest)
2177
+ if (capped) return recordThen(root, manifest, capped)
2178
+
2179
+ // Outside the waves, but not outside the run's spend (design D9).
2180
+ recordUsage(manifest, results)
2181
+
2182
+ const state = readState(root)
2183
+ const plan = readJson(join(root, WORK_DIR, `vplan-${context}.json`))
2184
+ const banners = []
2185
+
2186
+ // Close the interval the verify step opened. This is the other half of the
2187
+ // inter-wave verify budget: the step marked when it dispatched, and only this
2188
+ // process — the next one — can see how long the agent between them took. An
2189
+ // unparseable or negative mark contributes nothing rather than a wrong number,
2190
+ // and the mark is cleared either way so it can never be counted twice.
2191
+ if (manifest.verifyStartedAt) {
2192
+ const spent = Date.now() - Date.parse(manifest.verifyStartedAt)
2193
+ if (Number.isFinite(spent) && spent >= 0) {
2194
+ manifest.verifyElapsedMs = (numberOrUndefined(manifest.verifyElapsedMs) || 0) + Math.round(spent)
2195
+ }
2196
+ manifest.verifyStartedAt = null
2197
+ }
2198
+
2199
+ // A verification the CLI skipped: no agent ran, so there is nothing to judge.
2200
+ // The skip is recorded as the run's own, with the reason the planner gave — a
2201
+ // spent budget ahead of whichever kind happens to sit first in `skipped`, so
2202
+ // the recorded reason matches the one the step announced.
2203
+ const skipped = !plan || !Array.isArray(plan.steps) || !plan.steps.length
2204
+ const reason = skipped
2205
+ ? (plan && plan.budgetExceeded ? SKIP_REASONS.BUDGET_EXCEEDED : null) ||
2206
+ (plan && plan.skipped && plan.skipped[0] && plan.skipped[0].reason) ||
2207
+ 'no detectable commands'
2208
+ : null
2209
+
2210
+ let verdict = null
2211
+ if (!skipped) {
2212
+ const reportedSteps = flattenVerifyResults(results)
2213
+ // A planned step nobody reported on is NOT VERIFIED, and not verified is
2214
+ // not a pass. The judge scores what it is given, so an empty or partial
2215
+ // report would otherwise come back clean — which is the completion gate's
2216
+ // one failure direction: an absent verdict read as a passing one.
2217
+ const unreported = plan.steps
2218
+ .map(s => s.kind)
2219
+ .filter(kind => !reportedSteps.some(r => r && r.kind === kind))
2220
+ if (unreported.length) {
2221
+ const reason =
2222
+ `verification reported no result for ${unreported.join(', ')} — ` +
2223
+ `an unverified step is not a passing one, and an unverified run does not commit`
2224
+ banners.push(reason)
2225
+ manifest.banners.push(...banners)
2226
+ return finishStep(ctx, manifest, haltStep(reason, { banners }))
2227
+ }
2228
+ verdict = judgeVerification(plan, reportedSteps, { context })
2229
+ for (const banner of verdict.banners || []) banners.push(banner)
2230
+ logCliExit(ctx, manifest, 'verify judge', verdict.halt ? 1 : 0)
2231
+ } else {
2232
+ banners.push(`VERIFICATION SKIPPED: reason=${reason}`)
2233
+ }
2234
+
2235
+ if (context === 'inter-wave') {
2236
+ if (!state) return finishStep(ctx, manifest, haltStep('the run state could not be read', { banners }))
2237
+ const record = skipped
2238
+ ? { skipped: true, reason }
2239
+ : {
2240
+ ok: !verdict.halt,
2241
+ errors: (verdict.signals || []).filter(s => s.level === 'halt').map(s => s.message),
2242
+ blocksNextWave: verdict.halt === true
2243
+ }
2244
+ const after = recordVerifyResult(state, record)
2245
+ writeState(root, after)
2246
+ const { step: nextRaw, ok } = ctx.deps.logWaveMutation(root, {
2247
+ source: 'record-verify',
2248
+ change: manifest.change,
2249
+ state: after
2250
+ })
2251
+ if (!ok) ctx.warn('the wave-state trajectory append failed on record-verify')
2252
+ manifest.banners.push(...banners)
2253
+ return finishStep(ctx, manifest, decorate(ctx, nextRaw, manifest, { banners }))
2254
+ }
2255
+
2256
+ // Final. The completion gate: an unverified run does not commit, and an absent
2257
+ // verdict is "not verified" rather than a passing one.
2258
+ if (verdict && verdict.e2e && verdict.e2e.status === 'failed') {
2259
+ banners.push(
2260
+ `E2E FAILED (non-blocking by policy): ${verdict.e2e.detail || 'see the run log'}`
2261
+ )
2262
+ }
2263
+ manifest.banners.push(...banners)
2264
+ if (verdict && verdict.halt) {
2265
+ return finishStep(ctx, manifest, haltStep(
2266
+ verdict.reason || 'final verification halted the run',
2267
+ { banners }
2268
+ ))
2269
+ }
2270
+
2271
+ // The handoff artifacts (design D5). They come AFTER final verification and
2272
+ // before the commit, exactly where the retired inline tail ran them, and the
2273
+ // two decisions they used to ask the agent to make for itself are made here.
2274
+ if (manifest.flags.handoff === true || manifest.flags.conformance === true) {
2275
+ return finishStep(ctx, manifest, handoffStep(ctx, manifest, banners))
2276
+ }
2277
+
2278
+ return finishStep(ctx, manifest, commitStep(ctx, manifest, banners))
2279
+ }
2280
+
2281
+ /**
2282
+ * The handoff step: the manual-test-plan decision and the conformance
2283
+ * checklist, computed here and inlined (design D5).
2284
+ *
2285
+ * `interlock surface` and `interlock conformance` used to be commands the
2286
+ * handoff agent ran with `--changed <files changed by this run>` — a path list
2287
+ * the agent supplied about a diff it was writing artifacts for. Both engines
2288
+ * are called in-process now, over the run's OWN observed changed paths, and
2289
+ * the agent receives their answers rather than the questions.
2290
+ */
2291
+ function handoffStep(ctx, manifest, banners = []) {
2292
+ const { root } = ctx
2293
+ const change = manifest.change
2294
+ const changedPaths = ctx.deps.observedChangedPaths(root)
2295
+
2296
+ // `null` when `--handoff` is off: the manual test plan, the code explanation
2297
+ // and the learnings capture belong to `handoff`, and a conformance-only run
2298
+ // asks for none of them (see `assembleHandoffPrompt`).
2299
+ let needsManualTestPlan = null
2300
+ let testPlanReason
2301
+ if (manifest.flags.handoff === true) {
2302
+ const surface = classifySurface(changedPaths)
2303
+ needsManualTestPlan = surface.needsManualTestPlan
2304
+ if (!needsManualTestPlan) {
2305
+ testPlanReason = changedPaths.length
2306
+ ? `none of the ${changedPaths.length} changed path(s) is a UI-testable surface`
2307
+ : 'this run observed no changed paths, so there is no UI surface to test'
2308
+ }
2309
+ }
2310
+
2311
+ let scenarios = null
2312
+ const extra = [...banners]
2313
+ if (manifest.flags.conformance === true) {
2314
+ const checklist = buildChecklist(root, change, { changed: changedPaths })
2315
+ if (checklist.skipped) {
2316
+ // Said out loud rather than handed over as an empty checklist: "no
2317
+ // scenarios" and "the checklist could not be built" are different facts,
2318
+ // and only one of them is about the change.
2319
+ extra.push(`CONFORMANCE CHECKLIST UNAVAILABLE: ${checklist.reason}`)
2320
+ scenarios = []
2321
+ } else {
2322
+ scenarios = checklist.scenarios
2323
+ }
2324
+ }
2325
+
2326
+ return makeStep({
2327
+ action: 'handoff',
2328
+ banners: extra,
2329
+ needsManualTestPlan,
2330
+ scenarios: scenarios ? scenarios.length : null,
2331
+ then: { argv: ['run', 'commit'] },
2332
+ spawns: [
2333
+ spawn(root, {
2334
+ label: 'handoff',
2335
+ kind: 'handoff',
2336
+ model: null,
2337
+ effort: null,
2338
+ schema: HANDOFF_RESULT_SCHEMA,
2339
+ prompt: assembleHandoffPrompt({
2340
+ change,
2341
+ needsManualTestPlan,
2342
+ testPlanReason,
2343
+ conformance: scenarios,
2344
+ learnings: manifest.flags.handoff === true
2345
+ })
2346
+ })
2347
+ ]
2348
+ })
2349
+ }
2350
+
2351
+ /** The commit step, or the close a run told not to commit takes instead. */
2352
+ function commitStep(ctx, manifest, banners = []) {
2353
+ const { root } = ctx
2354
+ if (manifest.flags.noCommit === true) {
2355
+ manifest.notes.push('--no-commit: everything ran, the commit is yours')
2356
+ manifest.commitSkipped = true
2357
+ return makeStep({
2358
+ action: 'close',
2359
+ banners,
2360
+ then: { argv: ['run', 'close'] }
2361
+ })
2362
+ }
2363
+
2364
+ return makeStep({
2365
+ action: 'commit',
2366
+ banners,
2367
+ then: { argv: ['run', 'close'] },
2368
+ spawns: [
2369
+ spawn(root, {
2370
+ label: 'commit',
2371
+ kind: 'commit',
2372
+ model: null,
2373
+ effort: null,
2374
+ schema: COMMIT_SCHEMA,
2375
+ prompt: assembleCommitPrompt({ change: manifest.change, stageIndex: ++manifest.stageIndex })
2376
+ })
2377
+ ]
2378
+ })
2379
+ }
2380
+
2381
+ /**
2382
+ * `run commit` — the continuation of the handoff step.
2383
+ *
2384
+ * It records what the handoff writer reported about the artifacts it authored
2385
+ * (the one tail figure the CLI cannot re-derive) and emits the commit. A run
2386
+ * with no handoff step never calls it: `run judge --context final` emits the
2387
+ * commit directly.
2388
+ */
2389
+ export function runCommit(ctx, { results }) {
2390
+ const { root } = ctx
2391
+ const manifest = readManifest(root)
2392
+ if (!manifest) return noManifest()
2393
+ const capped = countStep(root, manifest)
2394
+ if (capped) return recordThen(root, manifest, capped)
2395
+
2396
+ // Outside the waves, but not outside the run's spend (design D9).
2397
+ recordUsage(manifest, results)
2398
+
2399
+ const reported = results && results[0] && typeof results[0] === 'object' ? results[0] : null
2400
+ const count = value => (Number.isInteger(value) ? value : undefined)
2401
+ manifest.handoff = reported
2402
+ ? {
2403
+ ok: reported.ok === true,
2404
+ manualTestPlan: reported.manualTestPlan === true,
2405
+ skipReason: typeof reported.skipReason === 'string' ? reported.skipReason : undefined,
2406
+ learnings: count(reported.learnings),
2407
+ scenariosChecked: count(reported.scenariosChecked),
2408
+ scenariosUnconfirmed: count(reported.scenariosUnconfirmed)
2409
+ }
2410
+ : null
2411
+ if (!manifest.handoff) {
2412
+ // The step ran and reported nothing usable. Named, because a summary with
2413
+ // no handoff line is indistinguishable from a run that was never asked for
2414
+ // one.
2415
+ manifest.notes.push('handoff did not complete: the step returned no usable result')
2416
+ }
2417
+
2418
+ return finishStep(ctx, manifest, commitStep(ctx, manifest))
2419
+ }
2420
+
2421
+ /** The `results` entries a verify spawn reports, flattened to the judge's shape. */
2422
+ function flattenVerifyResults(results) {
2423
+ const out = []
2424
+ for (const entry of Array.isArray(results) ? results : []) {
2425
+ if (!entry) continue
2426
+ if (Array.isArray(entry.results)) out.push(...entry.results.filter(Boolean))
2427
+ else if (typeof entry.kind === 'string') out.push(entry)
2428
+ }
2429
+ return out
2430
+ }
2431
+
2432
+ // --- run verify-final --------------------------------------------------------
2433
+
2434
+ /**
2435
+ * Emit final verification directly, without re-reading the wave state.
2436
+ *
2437
+ * Used to be the continuation a host called after running its own inline tail
2438
+ * (design D6, `host-tail`, retired by `emit-strict-tail-from-cli`) — the tail
2439
+ * now calls `finalVerifyStep` in-process from `runReviewed`/`runRemediated`
2440
+ * instead, so nothing in this module emits `run verify-final` as a
2441
+ * continuation any more. Left as a directly-callable entry point rather than
2442
+ * removed: `interlock run verify-final` still does exactly what it says.
2443
+ */
2444
+ export function runVerifyFinal(ctx) {
2445
+ const { root } = ctx
2446
+ const manifest = readManifest(root)
2447
+ if (!manifest) return noManifest()
2448
+ const capped = countStep(root, manifest)
2449
+ if (capped) return recordThen(root, manifest, capped)
2450
+ return finishStep(ctx, manifest, finalVerifyStep(ctx, manifest))
2451
+ }
2452
+
2453
+ // --- run replan -------------------------------------------------------------
2454
+
2455
+ /** Apply a revision the replan step produced, or carry on with the next step. */
2456
+ export function runReplan(ctx, { results }) {
2457
+ const { root } = ctx
2458
+ const manifest = readManifest(root)
2459
+ if (!manifest) return noManifest()
2460
+ const capped = countStep(root, manifest)
2461
+ if (capped) return recordThen(root, manifest, capped)
2462
+
2463
+ // Outside the waves, but not outside the run's spend (design D9).
2464
+ recordUsage(manifest, results)
2465
+
2466
+ const state = readState(root)
2467
+ if (!state) return finishStep(ctx, manifest, haltStep('the run state could not be read'))
2468
+
2469
+ const revised = results && results[0] && results[0].revised === true
2470
+ const groups = revised ? readJson(join(root, REPLAN_PATH)) : null
2471
+ if (!revised || !Array.isArray(groups)) {
2472
+ // Nothing to revise is the ordinary outcome, and re-reading the state is
2473
+ // how the loop moves past a replan offer it declined.
2474
+ return finishStep(ctx, manifest, decorate(ctx, nextStep(state), manifest))
2475
+ }
2476
+
2477
+ let after
2478
+ try {
2479
+ after = applyReplan(state, groups)
2480
+ } catch (err) {
2481
+ return finishStep(ctx, manifest, haltStep(`replan rejected: ${(err && err.message) || String(err)}`))
2482
+ }
2483
+ writeState(root, after)
2484
+ const { step: nextRaw, ok } = ctx.deps.logWaveMutation(root, {
2485
+ source: 'replan',
2486
+ change: manifest.change,
2487
+ state: after
2488
+ })
2489
+ if (!ok) ctx.warn('the wave-state trajectory append failed on replan')
2490
+ return finishStep(ctx, manifest, decorate(ctx, nextRaw, manifest))
2491
+ }
2492
+
2493
+ // --- run next ---------------------------------------------------------------
2494
+
2495
+ /** Re-read the state and re-emit its step. A pure re-read; it decides nothing. */
2496
+ export function runNext(ctx) {
2497
+ const { root } = ctx
2498
+ const manifest = readManifest(root)
2499
+ if (!manifest) return noManifest()
2500
+ const capped = countStep(root, manifest)
2501
+ if (capped) return recordThen(root, manifest, capped)
2502
+ const state = readState(root)
2503
+ if (!state) return finishStep(ctx, manifest, haltStep('the run state could not be read'))
2504
+ return finishStep(ctx, manifest, decorate(ctx, nextStep(state), manifest))
2505
+ }
2506
+
2507
+ // --- helpers used by every subcommand ---------------------------------------
2508
+
2509
+ function noManifest() {
2510
+ return haltStep(
2511
+ `no run manifest at ${MANIFEST_PATH} — a run subcommand was reached without \`interlock run start\``
2512
+ )
2513
+ }
2514
+
2515
+ /**
2516
+ * Persist what this step observed and record the continuation it names.
2517
+ *
2518
+ * Every step-yielding call goes through here, so a field an earlier step
2519
+ * accumulated (a banner, a wave row, the stage index) survives into the next
2520
+ * process — each `run` subcommand is its own.
2521
+ */
2522
+ function finishStep(ctx, manifest, step) {
2523
+ logSpawns(ctx, manifest, step)
2524
+ return recordThen(ctx.root, manifest, step)
2525
+ }
2526
+
2527
+ // --- run close (design D10) -------------------------------------------------
2528
+
2529
+ /**
2530
+ * Close the run: build the receipt, append the outcome, write the terminal
2531
+ * trajectory event, check that the run is reconstructable, and return the
2532
+ * summary both hosts print.
2533
+ *
2534
+ * A halt closes the same way a completion does. The receipt, the outcome record
2535
+ * and the terminal event are what a halted run is FOR — it is the most
2536
+ * informative record this corpus holds, and a run that stopped without writing
2537
+ * one leaves nothing behind to explain why.
2538
+ *
2539
+ * @returns {Promise<{action: 'complete'|'halt', exitCode: number, summary: string, banners: string[]}>}
2540
+ */
2541
+ export async function runClose(
2542
+ ctx,
2543
+ { results = [], halt = null, hostBanners = [], hostObserved = null, notify = false, env = process.env } = {}
2544
+ ) {
2545
+ const { root } = ctx
2546
+ const absRoot = resolve(root)
2547
+ const slug = projectSlug(absRoot)
2548
+ const manifest = readManifest(root)
2549
+ if (!manifest) {
2550
+ // A halt BEFORE `run start` — a host that refused its own invocation, or a
2551
+ // relay that never reached the first call. There is no run to record, but
2552
+ // the reason is the whole content of this close and must not be dropped for
2553
+ // the absence of a manifest to file it against.
2554
+ const reason = typeof halt === 'string' && halt.trim() ? halt.trim() : null
2555
+ const headline = `SHIP HALTED — ${reason || 'the run ended before it started'}`
2556
+ // The reader who walked away needs THIS outcome most of all: the run never
2557
+ // started, so no trajectory, receipt or outcome record will ever explain it.
2558
+ // The change name is unknown here — there is no manifest to read it from —
2559
+ // so the message carries the headline and the absent run id alone.
2560
+ const push = await attemptPush(notify, env, { headline, change: null, runId: null })
2561
+ const rows = [
2562
+ ' run: none — the run halted before a plan was adopted',
2563
+ ` project: ${slug}`,
2564
+ ` cwd: ${absRoot}`
2565
+ ]
2566
+ if (push && push.sent !== undefined) {
2567
+ rows.push(push.sent ? ' push: sent (ntfy)' : ` push: failed — ${push.reason}`)
2568
+ }
2569
+ return {
2570
+ schema: STEP_SCHEMA,
2571
+ action: 'halt',
2572
+ then: null,
2573
+ spawns: [],
2574
+ exitCode: 1,
2575
+ banners: Array.isArray(hostBanners) ? hostBanners : [],
2576
+ summary:
2577
+ `${headline}\n\n` +
2578
+ `There is no run manifest at ${MANIFEST_PATH}, so nothing was recorded for this run.\n` +
2579
+ rows.join('\n')
2580
+ }
2581
+ }
2582
+
2583
+ const haltReason = typeof halt === 'string' && halt.trim() ? halt.trim() : manifest.halted || null
2584
+ if (haltReason) manifest.halted = haltReason
2585
+
2586
+ // The commit result, when the close is the continuation of a commit step.
2587
+ const committed = results && results[0] && typeof results[0] === 'object' ? results[0] : null
2588
+ if (committed && typeof committed.ok === 'boolean') manifest.commit = committed
2589
+
2590
+ const state = readState(root)
2591
+ const closing = closingFromWaveState(state)
2592
+ const leftoverTaskIds = readLeftoverIds(root, manifest)
2593
+
2594
+ // What only the host could observe, carried in rather than recorded as
2595
+ // unknown: token spend, which comes off the runtime's own counter and which
2596
+ // this process — running BETWEEN agent turns — never sees.
2597
+ //
2598
+ // It used to carry the review, remediation and handoff figures too, because
2599
+ // the strict tail ran inside the Workflow host behind the `host-tail` seam.
2600
+ // The tail is a CLI-emitted program now (`emit-strict-tail-from-cli`), so
2601
+ // those are read off the manifest by `tailFromManifest` and this channel has
2602
+ // narrowed back to spend alone.
2603
+ const observed = hostObserved && typeof hostObserved === 'object' ? hostObserved : {}
2604
+
2605
+ // The tail's own figures, every one of them adjudicated or counted by this
2606
+ // CLI. An absent field stays absent — never zero. `buildReceipt` turns an
2607
+ // absent field into `null`, and `null` reads as unknown, which is a different
2608
+ // fact from a review that found nothing.
2609
+ const tail = tailFromManifest(manifest)
2610
+
2611
+ // Spend, from whichever party actually measured it (design D9). The Workflow
2612
+ // runtime counts tokens itself and carries them in through `--host-observed`;
2613
+ // a runner host reads them off each vendor CLI's envelope, and the run program
2614
+ // summed them as the results arrived. The host's own figures win where it has
2615
+ // any, because the runtime's counter sees turns this process never does.
2616
+ const counted = summarizeUsage(manifest)
2617
+ const hostSpend = Array.isArray(observed.spend) && observed.spend.length ? observed.spend : null
2618
+ const hostTotal =
2619
+ typeof observed.outputTokens === 'number' || observed.outputTokens === null
2620
+ ? observed.outputTokens
2621
+ : undefined
2622
+
2623
+ // A host that reports no usage at all records `unknown` everywhere, and says
2624
+ // that once rather than leaving a reader to infer it from empty figures — the
2625
+ // same reason the degradation block is printed on a clean run.
2626
+ if (!hostSpend && hostTotal === undefined && hostCapability(manifest, 'usage') === false) {
2627
+ manifest.notes.push(
2628
+ `TOKEN USAGE NOT REPORTED: the ${hostId(manifest) || 'run'} host's CLI returns no token ` +
2629
+ `accounting, so every wave and the run total are recorded as unknown`
2630
+ )
2631
+ }
2632
+
2633
+ // The cache figures, folded from the same two sources on the same terms. A
2634
+ // host's own numbers win where it has any, because a runtime that counts for
2635
+ // itself sees turns this process never does.
2636
+ const hostCacheRead =
2637
+ typeof observed.cacheReadInputTokens === 'number' || observed.cacheReadInputTokens === null
2638
+ ? observed.cacheReadInputTokens
2639
+ : undefined
2640
+ const hostCacheCreation =
2641
+ (observed.cacheCreationInputTokens && typeof observed.cacheCreationInputTokens === 'object') ||
2642
+ observed.cacheCreationInputTokens === null
2643
+ ? observed.cacheCreationInputTokens
2644
+ : undefined
2645
+
2646
+ // Bannered on its own declaration, never on the host's id or name (design D6).
2647
+ // An absent metric nobody speaks reads as a measured zero, and the corpus
2648
+ // becomes quietly incomparable across hosts — which is the whole reason cache
2649
+ // accounting is declared separately from usage at all.
2650
+ if (
2651
+ hostCacheRead === undefined &&
2652
+ hostCacheCreation === undefined &&
2653
+ hostCapability(manifest, 'cacheAccounting') === false
2654
+ ) {
2655
+ manifest.notes.push(
2656
+ `CACHE ACCOUNTING NOT REPORTED: the ${hostId(manifest) || 'run'} host's runtime exposes no ` +
2657
+ `cache decomposition, so every wave's cache-read and cache-creation figures are recorded ` +
2658
+ `as unknown rather than as a measured zero`
2659
+ )
2660
+ }
2661
+
2662
+ const summary = {
2663
+ waves: manifest.waves || [],
2664
+ spend: hostSpend || counted.spend,
2665
+ outputTokens: hostTotal !== undefined ? hostTotal : counted.outputTokens,
2666
+ cacheReadInputTokens: hostCacheRead !== undefined ? hostCacheRead : counted.cacheReadInputTokens,
2667
+ cacheCreationInputTokens:
2668
+ hostCacheCreation !== undefined ? hostCacheCreation : counted.cacheCreationInputTokens,
2669
+ review: tail.review,
2670
+ remediation: tail.remediation,
2671
+ remediationRounds: tail.remediationRounds,
2672
+ handoff: tail.handoff,
2673
+ halted: haltReason,
2674
+ notes: [...(manifest.notes || []), ...(Array.isArray(observed.notes) ? observed.notes : [])],
2675
+ closing,
2676
+ plan: manifest.plan,
2677
+ commit: manifest.commit,
2678
+ commitSkipped: manifest.commitSkipped === true,
2679
+ paths: readPathSets(root, manifest)
2680
+ }
2681
+
2682
+ const degradations = degradationLines({
2683
+ banners: manifest.banners || [],
2684
+ closing,
2685
+ hostBanners: Array.isArray(hostBanners) ? hostBanners : []
2686
+ })
2687
+
2688
+ // The stage marker, on every terminal path: a completion leaves `commit` on
2689
+ // disk and a halt leaves `remediation` or `fix-tests`, and the marker's pid is
2690
+ // the long-lived session's — so a marker nobody cleared keeps the PreToolUse
2691
+ // guards armed for the rest of that session, denying tasks.md checkbox edits
2692
+ // and test-file edits in ordinary work after the run is over. A clear that
2693
+ // fails is spoken, never an exit code: the marker is a guard input, not a gate.
2694
+ const cleared = clearStage(manifest.change, { root })
2695
+ if (!cleared.ok) degradations.push(`STAGE MARKER NOT CLEARED: ${cleared.warning}`)
2696
+
2697
+ // The archive reminder's input (design D7, D8). `detectUnarchived` is called
2698
+ // directly rather than by re-execing `interlock drift`, which would also run
2699
+ // the inferred stale-spec signal and one `git log` per spec for a fact this
2700
+ // close already has the reader for.
2701
+ //
2702
+ // Wrapped exactly as `readLeftoverIds` wraps the same reader: `inspectChange`
2703
+ // reads and stats unguarded, so ONE unreadable sibling change must not turn a
2704
+ // clean close into exit 1. On failure the reminder is dropped and the reason
2705
+ // is spoken as a note — never swallowed.
2706
+ let unarchived = null
2707
+ try {
2708
+ const found = detectUnarchived(root)
2709
+ unarchived = {
2710
+ thisChange: found.some(u => u.change === manifest.change),
2711
+ others: found.filter(u => u.change !== manifest.change).length
2712
+ }
2713
+ } catch (err) {
2714
+ unarchived = null
2715
+ const note = `archive check skipped: ${(err && err.message) || String(err)}`
2716
+ manifest.notes.push(note)
2717
+ // `summary.notes` was snapshotted from `manifest.notes` above; the note has
2718
+ // to reach both or it is recorded in the manifest and printed nowhere.
2719
+ summary.notes.push(note)
2720
+ }
2721
+
2722
+ // The push (design D1, D5). Requested by `--notify` from both drivers; the
2723
+ // title is the summary's OWN first line, so the notification and the printed
2724
+ // report cannot disagree. A failed push is a degradation banner and never an
2725
+ // exit code: the run happened either way.
2726
+ const push = await attemptPush(notify, env, {
2727
+ headline: summaryHeadline({ change: manifest.change, halted: haltReason, leftoverTaskIds }),
2728
+ change: manifest.change,
2729
+ runId: manifest.runId
2730
+ })
2731
+ if (push && push.sent === false) degradations.push(`PUSH FAILED: ${push.reason}`)
2732
+
2733
+ // The plan fingerprint this run adopted, read back from where `run classified`
2734
+ // wrote it. Attributed only when the file names THIS change: a fingerprint
2735
+ // left by another change's run would otherwise be recorded as this run's, and
2736
+ // a wrong hash is worse than an absent one — the whole point of the field is
2737
+ // that a reader can check the plan-reuse story off a trajectory.
2738
+ const fingerprint = readJson(join(root, FINGERPRINT_PATH))
2739
+ const planFingerprint =
2740
+ fingerprint && typeof fingerprint.hash === 'string' && fingerprint.change === manifest.change
2741
+ ? fingerprint.hash
2742
+ : undefined
2743
+
2744
+ const receipt = buildReceipt({
2745
+ change: manifest.change,
2746
+ summary,
2747
+ degradations,
2748
+ planFingerprint,
2749
+ leftoverTaskIds,
2750
+ host: manifest.host
2751
+ ? {
2752
+ id: hostId(manifest),
2753
+ billing: hostCapability(manifest, 'billing'),
2754
+ hooks: hostCapability(manifest, 'hooks'),
2755
+ usage: hostCapability(manifest, 'usage'),
2756
+ cacheAccounting: hostCapability(manifest, 'cacheAccounting')
2757
+ }
2758
+ : undefined
2759
+ })
2760
+
2761
+ if (manifest.runId) {
2762
+ const written = appendRunLogEvent(root, {
2763
+ ...receipt,
2764
+ runId: manifest.runId,
2765
+ change: manifest.change
2766
+ })
2767
+ if (!written.written) ctx.warn(`run-receipt not recorded: ${written.reason}`)
2768
+
2769
+ const terminal = appendRunLogEvent(root, {
2770
+ runId: manifest.runId,
2771
+ change: manifest.change,
2772
+ type: haltReason ? 'run-halt' : 'run-complete',
2773
+ reason: haltReason || undefined
2774
+ })
2775
+ if (!terminal.written) ctx.warn(`${haltReason ? 'run-halt' : 'run-complete'} not recorded: ${terminal.reason}`)
2776
+ }
2777
+
2778
+ // The outcome corpus. Its own write failure is reported and never touches the
2779
+ // exit code: losing a corpus line must not fail the run that produced it.
2780
+ const outcome = appendOutcome(root, {
2781
+ change: manifest.change,
2782
+ mode: manifest.flags.mode || 'checkpoint',
2783
+ observed: observedFromReceipt(receipt)
2784
+ })
2785
+ if (!outcome.written) ctx.warn(`outcome not recorded: ${outcome.reason}`)
2786
+
2787
+ // Reconstructability. A run nobody can reconstruct defeats the reason the
2788
+ // trajectory file exists, so this one IS fatal — the corpus-loss semantics
2789
+ // this repository deliberately keeps different per corpus.
2790
+ let reconstructable = true
2791
+ if (manifest.runId) {
2792
+ const check = checkRunLog(root, manifest.runId)
2793
+ reconstructable = check.ok !== false
2794
+ if (!reconstructable) {
2795
+ degradations.push(
2796
+ `RUN NOT RECONSTRUCTABLE: ${(check.problems || []).join('; ') || 'the trajectory is incomplete'}`
2797
+ )
2798
+ }
2799
+ }
2800
+
2801
+ // The autonomy ladder's record for this run's review (design D6). It used to
2802
+ // ride on the commit prompt, which asked the party being assessed to write
2803
+ // the assessment's input — the exact provenance rule
2804
+ // `openspec/specs/ship/outcome-provenance` states. The count is the CLI's own
2805
+ // last adjudication, and no agent is in its path.
2806
+ //
2807
+ // Only when an adjudication actually happened: a strict run that halted in
2808
+ // the waves never reviewed, and recording zero blockers for it would credit
2809
+ // the review path with a clean run it never had. Storage only — nothing here
2810
+ // or downstream prints a level.
2811
+ if (manifest.flags.strict === true && manifest.review) {
2812
+ const blockers = survivingBlockers(manifest)
2813
+ try {
2814
+ recordAutonomy('review-code', { blockers }, { root })
2815
+ manifest.autonomy = { path: 'review-code', blockers }
2816
+ } catch (err) {
2817
+ degradations.push(
2818
+ `AUTONOMY RECORD NOT WRITTEN: ${(err && err.message) || String(err)} — the ladder is ` +
2819
+ `experimental and gates nothing, so the run is unaffected`
2820
+ )
2821
+ }
2822
+ }
2823
+
2824
+ manifest.lastThen = null
2825
+ writeManifest(root, manifest)
2826
+
2827
+ return {
2828
+ schema: STEP_SCHEMA,
2829
+ action: haltReason ? 'halt' : 'complete',
2830
+ then: null,
2831
+ spawns: [],
2832
+ exitCode: haltReason || !reconstructable ? 1 : 0,
2833
+ banners: degradations,
2834
+ summary: formatRunSummary({
2835
+ change: manifest.change,
2836
+ summary,
2837
+ flags: manifest.flags,
2838
+ leftoverTaskIds,
2839
+ degradations,
2840
+ runId: manifest.runId,
2841
+ root: absRoot,
2842
+ projectSlug: slug,
2843
+ push,
2844
+ unarchived
2845
+ })
2846
+ }
2847
+ }
2848
+
2849
+ /**
2850
+ * Attempt the close's push, or decide not to. Returns what `formatRunSummary`
2851
+ * takes as its `push` input: `null` when no push was attempted at all, or
2852
+ * `{sent}` when one was.
2853
+ *
2854
+ * The three outcomes are deliberately different facts (design D5):
2855
+ *
2856
+ * - `--notify` not passed, or no topic configured → `null`. An optional
2857
+ * feature left off is not a degraded run: no summary row, no banner.
2858
+ * - Configured but malformed → a failed push naming the VARIABLE, never its
2859
+ * value; the topic is the only auth a public relay has.
2860
+ * - Configured and attempted → whatever `postNtfy` resolves to, bounded by
2861
+ * `LIMITS.notifyTimeoutMs` and never throwing.
2862
+ */
2863
+ async function attemptPush(notify, env, { headline, change, runId }) {
2864
+ if (notify !== true) return null
2865
+ const config = readNotifyConfig(env)
2866
+ if (config.invalid) return { sent: false, reason: `invalid configuration: ${config.invalid}` }
2867
+ if (!config.topic) return null
2868
+ return postNtfy({
2869
+ url: config.url,
2870
+ topic: config.topic,
2871
+ ...composeCloseMessage({ headline, change, runId })
2872
+ })
2873
+ }
2874
+
2875
+ /**
2876
+ * The surviving-blocker count from the LAST adjudication the CLI performed.
2877
+ *
2878
+ * `remediation.blockersRemaining` is rewritten by every `run remediated`, so it
2879
+ * is the latest; the round-one review's count stands in when no remediation
2880
+ * round ever ran. Never the agent's report — both figures are `resolveReview`'s
2881
+ * own output over files the CLI read.
2882
+ */
2883
+ function survivingBlockers(manifest) {
2884
+ const remediation = manifest.remediation
2885
+ if (remediation && Number.isInteger(remediation.blockersRemaining)) {
2886
+ return remediation.blockersRemaining
2887
+ }
2888
+ const counts = manifest.review && manifest.review.counts
2889
+ return counts && Number.isInteger(counts.blockers) ? counts.blockers : 0
2890
+ }
2891
+
2892
+ /**
2893
+ * Every task id still unticked in tasks.md, read at close.
2894
+ *
2895
+ * "Left behind" is not "failed": a halt at verification leaves whole waves that
2896
+ * never ran, and a receipt built from the failure list reported none of them. The
2897
+ * failure list stays as the fallback for a run whose change cannot be read.
2898
+ */
2899
+ function readLeftoverIds(root, manifest) {
2900
+ try {
2901
+ const inspection = inspectChange(root, manifest.change)
2902
+ const items = (inspection.tasks && inspection.tasks.items) || []
2903
+ return items.filter(t => t && t.done === false).map(t => t.id).filter(Boolean)
2904
+ } catch {
2905
+ return (manifest.waves || []).flatMap(w => w.failedIds || [])
2906
+ }
2907
+ }
2908
+
2909
+ /** The two path sets the receipt records, read here rather than reported by an agent. */
2910
+ function readPathSets(root, manifest) {
2911
+ const sha = manifest.commit && manifest.commit.ok === true ? manifest.commit.sha : null
2912
+ const touched = sha ? readTouchedPaths(root, sha) : null
2913
+ const predicted = readPredictedPaths(root)
2914
+ return {
2915
+ touchedPaths: touched && touched.observed ? touched.paths : undefined,
2916
+ touchedPathsReason: touched && !touched.observed ? touched.reason : undefined,
2917
+ predictedPaths: predicted && predicted.observed ? predicted.paths : undefined,
2918
+ predictedPathsComplete: predicted ? predicted.complete === true : undefined,
2919
+ predictedPathsReason: predicted && !predicted.observed ? predicted.reason : undefined
2920
+ }
2921
+ }