@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/bin/interlock ADDED
@@ -0,0 +1,2655 @@
1
+ #!/usr/bin/env node
2
+ // interlock — the deterministic half of the workflow.
3
+ //
4
+ // Every decision a skill could get wrong by re-deriving it in prose lives here
5
+ // instead: which tasks run in which wave, whether a change touches UI, whether a
6
+ // gate blocks, whether a path has earned autonomy. Skills call these commands
7
+ // and act on the JSON; they do not recompute the answers.
8
+ //
9
+ // Claude Code puts a plugin's bin/ on the Bash tool's PATH, so skills invoke
10
+ // this as a bare `interlock` command.
11
+ //
12
+ // This file is also the *only* way the dynamic-workflow runtime reaches any of
13
+ // `lib/`. That runtime cannot `import()` and has no filesystem or shell of its
14
+ // own — only the agents it spawns run commands. So a policy module that is not
15
+ // wired to a subcommand here is a policy the model will improvise in prose.
16
+
17
+ import { execFileSync } from 'node:child_process'
18
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
19
+ import { dirname, join } from 'node:path'
20
+ import { performance } from 'node:perf_hooks'
21
+ import {
22
+ planWaves,
23
+ formatPlan,
24
+ createRunState,
25
+ nextStep,
26
+ recordBatchResult,
27
+ recordVerifyResult,
28
+ applyReplan,
29
+ formatRunState,
30
+ batchOutcomes
31
+ } from '../lib/waves.mjs'
32
+ import {
33
+ CLASSIFIED_PATH,
34
+ checkSequence,
35
+ readManifest,
36
+ runClassified,
37
+ runClose,
38
+ runCommit,
39
+ runJudge,
40
+ runNext,
41
+ runRecordBatch,
42
+ runRemediated,
43
+ runReplan,
44
+ runReviewed,
45
+ runStart,
46
+ runVerifyFinal
47
+ } from '../lib/run.mjs'
48
+ import { haltStep } from '../lib/run.mjs'
49
+ import { composeCheckpointMessage, postNtfy, readNotifyConfig, NOTIFY_TOPIC_ENV } from '../lib/notify.mjs'
50
+ import { classifySurface } from '../lib/surface.mjs'
51
+ import { evaluateGate, formatGate, TOLERANCE_BAND } from '../lib/findings.mjs'
52
+ import { inspectChange, resolveChange, listChanges, formatInspection, tickTasks, planCoverage } from '../lib/artifacts.mjs'
53
+ import { record, recordCleanFlow, getLevel, getState } from '../lib/autonomy.mjs'
54
+ import {
55
+ LIMITS,
56
+ RUNTIME,
57
+ EVAL_CAPS,
58
+ MODEL_PRICES,
59
+ REPORT_CAPS,
60
+ EFFORT,
61
+ LANE_CAPS,
62
+ SOLO,
63
+ formatLimits
64
+ } from '../lib/limits.mjs'
65
+ import { planRemediation, formatRemediationPlan } from '../lib/remediate.mjs'
66
+ import {
67
+ resolveReview,
68
+ formatReview,
69
+ readReviewPolicy,
70
+ formatReviewPolicy
71
+ } from '../lib/review-core.mjs'
72
+ import { writeReviewMetrics } from '../lib/metrics.mjs'
73
+ import {
74
+ planVerification,
75
+ judgeVerification,
76
+ judgeUnitResult,
77
+ clusterFailures,
78
+ nextRepairAction,
79
+ formatVerification,
80
+ checkResultFieldSizes,
81
+ VERIFY_CONTEXTS
82
+ } from '../lib/verify.mjs'
83
+ import { spillBytes } from '../lib/spill.mjs'
84
+ import { classifyRisk, formatRisk } from '../lib/risk.mjs'
85
+ import { mergeDecision } from '../lib/merge-lanes.mjs'
86
+ import { analyzeDrift, formatDrift } from '../lib/drift.mjs'
87
+ import { buildChecklist, formatChecklist } from '../lib/conformance.mjs'
88
+ import { readLedger, summarize as summarizeLedger, formatLedger } from '../lib/ledger.mjs'
89
+ import {
90
+ computeFingerprint,
91
+ writeFingerprint,
92
+ resolvePlanReuse,
93
+ formatFingerprint,
94
+ formatPlanReuse,
95
+ PLAN_PATH,
96
+ FINGERPRINT_PATH,
97
+ NARROWED_PLAN_PATH
98
+ } from '../lib/plan-fingerprint.mjs'
99
+
100
+ import { assessReadiness, formatReadiness } from '../lib/ready.mjs'
101
+ import { buildReport, formatReport } from '../lib/report.mjs'
102
+ import { renderReportHtml } from '../lib/report-html.mjs'
103
+ import { diagnose, formatDoctor, TEST_PROFILE_PATH } from '../lib/doctor.mjs'
104
+ import { triage, formatTriage } from '../lib/evals-triage.mjs'
105
+ import { captureCase, formatCapture } from '../lib/evals-capture.mjs'
106
+ import { calibrate, readLabels, formatCalibration } from '../lib/evals-calibrate.mjs'
107
+ import { promote, formatPromotion } from '../lib/evals-promote.mjs'
108
+ import { readTouchedPaths, readPredictedPaths, formatPathSet } from '../lib/run-paths.mjs'
109
+ import { appendOutcome, observedFromReceipt, readOutcomes, formatOutcomes } from '../lib/outcomes.mjs'
110
+ import {
111
+ appendRunLogEvent,
112
+ readRunLog,
113
+ listRunLogs,
114
+ queryRunLog,
115
+ checkRunLog,
116
+ formatRunLog,
117
+ formatRunLogList,
118
+ formatRunLogCheck
119
+ } from '../lib/run-log.mjs'
120
+
121
+ const USAGE = `interlock — deterministic decisions for the spec-driven flow
122
+
123
+ Usage:
124
+ interlock waves --classified <file> Plan execution waves from classified tasks
125
+ [--mode solo|waves] (--mode forces the plan shape;
126
+ [--red-wave <n>] --red-wave/--tdd run a leading
127
+ [--tdd|--no-tdd] failing-test wave first)
128
+ interlock surface --changed <files...> Classify UI testability of changed files
129
+ interlock gate --findings <file> Decide whether a review gate blocks
130
+ interlock validate [change] Inspect OpenSpec change artifacts
131
+ (--change <name> is the same, as a flag)
132
+ interlock changes List active OpenSpec changes
133
+ interlock doctor Preflight the host before a run:
134
+ Node, plugin install, PATH, the
135
+ openspec CLI, git, the test profile,
136
+ the permission allowlist, and the
137
+ writability of the run-state dirs
138
+ interlock drift [--changed <files>] Report unarchived changes, broken spec
139
+ references, aging specs, and orphan code
140
+ interlock conformance [change] List the spec scenarios a change must satisfy
141
+ interlock report [--since <iso>] Indicators over the three recorded
142
+ [--change <name>] corpora — outcome records, run
143
+ trajectories, review metrics. Every
144
+ value carries its denominator, and
145
+ an unobserved value stays unobserved.
146
+ Gates nothing and always exits 0
147
+ interlock limits Print every cap the loop obeys
148
+ interlock notify --title <t> [--body <b>] Push one message so a run or a
149
+ [--priority high|default] checkpoint reached while nobody was
150
+ interlock notify checkpoint <change> watching still reaches someone. The
151
+ only outbound request this CLI makes.
152
+ Topic from INTERLOCK_NTFY_TOPIC,
153
+ server from INTERLOCK_NTFY_URL
154
+ (default https://ntfy.sh). Unset
155
+ topic prints "not configured" and
156
+ exits 0; a failed push exits 1.
157
+ --json emits { sent, reason }
158
+ interlock run start --change <name> The ship loop, as a program. Each
159
+ [--tdd|--no-tdd] (--tdd/--no-tdd override the task
160
+ shape read from tasks.md)
161
+ interlock run classified --classified <f> subcommand returns one step record
162
+ interlock run record-batch --results <f> (interlock.run-step/1): the agents
163
+ interlock run judge --context <ctx> --results <f>
164
+ interlock run verify-final to spawn — label, model, effort,
165
+ interlock run reviewed --results <f> agent type, tools, result schema and
166
+ interlock run remediated --round <n> --results <f>
167
+ interlock run commit --results <f> a briefing file with its sha256 —
168
+ interlock run replan --results <f> and the exact argv to call once they
169
+ interlock run next return. A host spawns what a step
170
+ interlock run close [--halt <reason>] names, writes the results, calls
171
+ [--host-banners <f>]
172
+ [--host-observed <f>]
173
+ \`then.argv\`, and repeats until
174
+ \`then\` is null. It decides nothing:
175
+ every branch the loop has (apply-only,
176
+ no-commit, skip-e2e, isolate-waves,
177
+ replan allowed, verify skipped, done)
178
+ is taken here from the run manifest
179
+ (.claude/ship/run.json) and the wave
180
+ state. The strict tail is part of
181
+ the program too: \`reviewed\`
182
+ adjudicates the findings and verdicts
183
+ the review step wrote and plans round
184
+ one, \`remediated --round <n>\`
185
+ re-adjudicates and asks for the next
186
+ round, the verdict or the halt, and
187
+ \`commit\` records what the handoff
188
+ wrote. No host carries the round
189
+ budget, and none may refuse a tail
190
+ flag. \`run close\` is the only one
191
+ that exits non-zero — on a halt, or
192
+ on a run whose trajectory cannot be
193
+ reconstructed
194
+ interlock merge-lanes --base <sha> --lanes <file>
195
+ Fold a batch's isolated lane worktrees
196
+ back into the shared tree (--isolate-waves
197
+ runs only). --lanes is a JSON array of
198
+ { label, worktreePath, reportedFiles }.
199
+ Exits 0 on a clean fold, 1 on a halt
200
+ (a real path collision or a lane whose
201
+ worktree could not be located) — never
202
+ auto-resolves, never applies a guess
203
+ interlock evals triage --results <file> Classify an eval results run:
204
+ regression, variance, or no signal.
205
+ No model, no network. Exit code is
206
+ the verdict (1=regression,
207
+ 2=no signal, 3=configuration)
208
+ interlock evals capture --run <id> --out <dir>
209
+ [--task <id>] Emit an eval-case skeleton from a
210
+ recorded run trajectory, with its
211
+ provenance. A DRAFT: every derived
212
+ value is marked CONFIRM. Refuses a
213
+ run that is not reconstructable, an
214
+ --out inside evals/, and an --out
215
+ that already holds a case — writing
216
+ nothing on any refusal (exit 1)
217
+ interlock evals calibrate --results <file>
218
+ [--labels <dir>] Report how often each judged grader
219
+ agreed with a stored human label, with
220
+ its denominator. No model, no network,
221
+ NO verdict — it names unmeasured
222
+ graders and unmatched items and always
223
+ exits 0. promote applies the floor
224
+ interlock evals promote --history <dir>
225
+ [--agreement <f>] Decide whether each case in the
226
+ recorded history may move from
227
+ advisory to blocking. The rule's
228
+ numbers come from interlock limits.
229
+ Exit code is the verdict (1=at least
230
+ one refused, 2=insufficient history)
231
+ interlock paths touched --commit <sha> Repository-relative paths one commit
232
+ touched, read from git
233
+ interlock paths predicted [--plan <file>] Paths the executed plan predicted its
234
+ tasks would touch, and whether every
235
+ task declared any. Both commands
236
+ always exit 0: an unreadable set is
237
+ reported unobserved with its reason,
238
+ never as an empty set and never as a
239
+ failed close
240
+ interlock remediate --findings <file> Plan one remediation round, or the verdict
241
+ interlock review --findings <f> --verdicts <f>
242
+ Resolve a review against skeptic verdicts.
243
+ Also reads repo-root REVIEW.md and drops
244
+ findings on its do-not-report paths
245
+ interlock review-policy [--json] Print the parsed repo-root REVIEW.md:
246
+ owner + prose (advice for the reviewer)
247
+ and any parse problems. Absent → empty
248
+ interlock risk [change] Classify blast radius for the continuity gate
249
+ interlock ledger [change] Summarize the decision ledger
250
+ interlock ready [change] --findings <f> Decide whether continuity may skip the human
251
+ interlock outcomes append --record <f> Append one run outcome to the corpus
252
+ interlock outcomes list Read the outcome corpus back
253
+ interlock run-log append --event <f> Append one ship-run trajectory event
254
+ interlock run-log list [--change <name>] List recorded ship runs
255
+ interlock run-log show <runId> Print one run's events in order
256
+ interlock run-log query --run <id> Filter one run's events
257
+ interlock run-log check --state <file> Reconstructability gate: exit 1 on
258
+ a gap, a missing start, or a
259
+ missing run-halt/run-complete
260
+ interlock tasks tick --ids <ids> Tick succeeded task checkboxes by id
261
+ interlock tasks coverage --classified <f> Halt if classified.json omits an unchecked task
262
+ interlock verify plan --profile <file> Decide which verification commands to run
263
+ interlock verify judge --plan <f> --results <f>
264
+ Judge reported results against the plan
265
+ interlock verify unit --result <file> Judge a unit result on its own
266
+ interlock verify spill --run-id <id> --kind <k> --input <f>
267
+ Spill oversized stdout/stderr to disk
268
+ interlock verify cluster --failures <f> Group failures by root-cause signature
269
+ interlock verify repair --state <file> Decide repair / halt / accept next
270
+ interlock plan fingerprint [change] Fingerprint a change's planning inputs
271
+ [--mode solo|waves] (the mode override is part of the print)
272
+ interlock plan reuse [change] Decide whether the stored plan may be
273
+ [--mode solo|waves] reused, and narrow it if so (a mode
274
+ override that differs from the stored
275
+ plan's forces a rebuild)
276
+ interlock wave-state create --plan <file> Start a run state from a wave plan
277
+ interlock wave-state next --state <file> Ask what the run should do next
278
+ interlock wave-state record-batch --state <f> --result <f>
279
+ interlock wave-state record-verify --state <f> --result <f>
280
+ interlock wave-state replan --state <f> --groups <f>
281
+ interlock autonomy level <path> Print the autonomy level of a path
282
+ interlock autonomy record <path> Record a run outcome
283
+ interlock autonomy clean <paths...> Credit a clean flow across paths
284
+ interlock autonomy state Dump the full ladder
285
+
286
+ Common options:
287
+ --json Emit JSON instead of human-readable text
288
+ --root <dir> Repo root (default: cwd)
289
+ Any <file> argument accepts "-" to read that input from stdin. Only one input
290
+ per invocation may be "-", since stdin can only be drained once.
291
+
292
+ Exit codes:
293
+ Every gating command exits 1 when it blocks, so the workflow branches on exit
294
+ status rather than on parsed prose:
295
+ gate surviving findings block the gate
296
+ doctor a preflight check would stop a zero-touch run
297
+ validate the change is not ready
298
+ remediate blockers survive the verdict round
299
+ verify judge a halting kind failed in this context, a result
300
+ field exceeded the preview budget, or (with
301
+ --state/--run-id) the trajectory append failed
302
+ verify unit the unit suite is red or was weakened, or a
303
+ result field exceeded the preview budget
304
+ verify repair the root-cause repair budget is spent
305
+ ledger the ledger is missing or unparseable, or
306
+ needs_human or invalid rows remain
307
+ ready the change may not skip the human checkpoint
308
+ merge-lanes a lane worktree collided with another on a real
309
+ path, or a lane's worktree could not be located —
310
+ a clean fold is the only zero exit
311
+ tasks tick an id could not be marked complete
312
+ wave-state next the run has halted, or the trajectory append failed
313
+ wave-state record-* the recorded result halted the run, or the
314
+ trajectory append failed
315
+ run-log check the run's trajectory is not reconstructable
316
+ evals triage at least one case is a regression (exit 1); a run
317
+ that yielded no signal (exit 2) or a configuration
318
+ problem (exit 3) is non-zero but distinct, so
319
+ neither reads as a clean pass
320
+ evals capture the capture was refused — the run is not
321
+ reconstructable, the output directory resolves
322
+ inside evals/, or it already holds a case. Nothing
323
+ was written on any of the three
324
+ evals promote at least one case may not yet be promoted (exit 1);
325
+ a history too thin to decide (exit 2) is non-zero
326
+ but distinct, because "not enough evidence" must
327
+ never read as "the evidence says no"
328
+ paths touched and paths predicted never exit non-zero either: a host calls
329
+ them on its way out, and a read that failed must leave the receipt's set
330
+ unobserved rather than fail the close.
331
+ risk never exits non-zero — branch on "continuityAllowed" instead, so a
332
+ high-risk classification is a checkpoint rather than a failed command.
333
+ evals calibrate never exits non-zero. That is a contract, not a convenience:
334
+ the calibration report measures judge/human agreement and applies no floor to
335
+ it, so an exit code would be a verdict it is forbidden to issue. Low agreement
336
+ is stated with its denominator and the command still succeeds; evals promote is
337
+ the only reader that turns that number into a decision.
338
+ drift, conformance and report never exit non-zero either. For report that is
339
+ a policy, not a convenience: §4.15(a) forbids gating on the recorded corpora,
340
+ and a non-zero status is exactly the comparison it forbids. Nothing in the
341
+ loop reads the report.
342
+
343
+ report:
344
+ Reads .claude/learning/outcomes.jsonl, .claude/ship/runs/*.jsonl and
345
+ .claude/metrics/*.json. Coverage first — how many trajectories carry a
346
+ receipt at all — because every receipt-derived indicator rests on it and a
347
+ thin corpus must read as thin rather than as clean. Metrics files are
348
+ classified by their "schema" key, never by filename: a skill also writes
349
+ review-artifacts-*.json into that directory, and it carries finding bodies
350
+ rather than counts. "Does the merged diff match the plan" is computed from the
351
+ two path sets the receipt records — touched paths that the plan predicted,
352
+ over touched paths — and every run it could not be computed over is excluded
353
+ and counted under the reason it was excluded, rather than approximated from a
354
+ nearby number.
355
+ --since <iso> Keep runs whose earliest record is at or after this
356
+ --change <name> Keep one change's runs, outcomes and metrics
357
+ --max-runs <n> Lower the scan bound; the published cap is the ceiling
358
+ (see interlock limits). Truncation is reported.
359
+ --html [path] Render the same report object as one self-contained
360
+ HTML document — no stylesheet, script or font is
361
+ fetched, so it opens offline. Bare, it writes to
362
+ stdout; given a path, it writes only there. Mutually
363
+ exclusive with --json. It is a generated file, not a
364
+ service, and like every other surface here it issues
365
+ no verdict: an unobserved indicator renders as
366
+ UNOBSERVED with its reason, never as a zero.
367
+
368
+ waves:
369
+ --classified <file> JSON: { "tasks": [{id, group, description, tier, model, isTestTask}] }
370
+ Use "-" to read from stdin.
371
+ --max-parallel <n> Cap agents spawned per batch (default ${LIMITS.maxParallel})
372
+ --mode solo|waves Force the plan shape. Absent, the classified input's own
373
+ recommendedMode decides inside the published envelope
374
+ (see interlock limits); default is waves. Passing both
375
+ solo and waves is rejected rather than resolved last-wins.
376
+ --red-wave <n> Run section <n> as a LEADING failing-test wave instead of
377
+ deferring its tests: they execute first and that wave's
378
+ inter-wave check is skipped, because the suite is expected
379
+ to fail there. Refused, with a warning, when the section
380
+ holds no test task or is not the first section.
381
+ --tdd Derive --red-wave from the classification: the lowest
382
+ section whose tasks are ALL test tasks. A run resolves it
383
+ from the tasks.md section heading instead.
384
+ --no-tdd Never treat any section as a red wave.
385
+
386
+ surface:
387
+ --changed <files...> Repo-relative paths. Use "-" to read newline-separated stdin.
388
+
389
+ gate:
390
+ --findings <file> JSON from the review dimensions. Use "-" for stdin.
391
+ --dismissed <t1,t2> Finding titles the skeptics verified away
392
+ --metrics <change> Also append the counts to .claude/metrics/ under this
393
+ change name, as "review" does. A metrics failure prints
394
+ its reason and is never fatal — the verdict and the exit
395
+ code are unaffected.
396
+ --min-quality <n> Quality-band floor (default ${TOLERANCE_BAND.minQualityToReport})
397
+ --no-tolerance Report every surviving finding, however weak
398
+ Findings the skeptics scored below the quality band are dropped before
399
+ counting, so a weakly-grounded finding never blocks; unscored findings are
400
+ always kept, and a skeptic disagreement wider than ${TOLERANCE_BAND.drift} keeps the finding.
401
+ Exits 1 when the gate blocks, so callers can branch on exit status.
402
+
403
+ limits:
404
+ Prints the caps the loop obeys and the workflow-runtime ceilings above them.
405
+ Cite these rather than restating a number in prose. With --json the per-tier
406
+ lane-cap table and the solo envelope are their own top-level groups (laneCaps,
407
+ solo), never entries of the iteration-count limits.
408
+
409
+ doctor:
410
+ The preflight. Every condition here would otherwise be discovered mid-run, by
411
+ which point a zero-touch run has already spent waves of agents to learn it —
412
+ or worse, has stopped on an approval prompt with nobody watching. Read-only:
413
+ it names a fix for every failure and applies none of them.
414
+ Checks: the Node version against the plugin floor and the OpenSpec CLI's
415
+ higher one; the INSTALLED plugin's workflow, agent types and binaries
416
+ (resolved from the plugin, never from the checkout you are standing in);
417
+ interlock / interlock-graph on PATH; the openspec CLI and whether this project
418
+ is initialised; git and a work tree; ${TEST_PROFILE_PATH};
419
+ the permission allowlist; and whether the run-state directories can be
420
+ written.
421
+ The allowlist check derives the commands it requires — the four the flow
422
+ always shells out to, plus whatever the test profile says this project runs —
423
+ and reports an uncovered command as a FAILURE, since an approval prompt in an
424
+ unattended run is a stopped run. A rule that exists but is narrower than the
425
+ command it would have to permit (\`Bash(interlock waves:*)\` for a requirement
426
+ of \`interlock\`) is reported as narrower, never counted as coverage. A "deny"
427
+ that overlaps a required command fails; an "ask" warns; defaultMode
428
+ "bypassPermissions" warns and checks nothing, because nothing will prompt.
429
+ Exits 1 when any check fails. Warnings never gate.
430
+
431
+ remediate:
432
+ --findings <file> Review output, in any shape gate accepts. "-" for stdin.
433
+ --round <n> 1-based (default 1). Rounds 1-${LIMITS.remediationRounds} are fix passes;
434
+ round ${LIMITS.remediationRounds + 1} is the verdict and is the only round that halts.
435
+ --min-quality <n> Quality-band floor, as for gate
436
+ --no-tolerance Disable the quality band
437
+ Exits 1 when the plan halts (blockers survived the verdict round).
438
+
439
+ review:
440
+ --findings <file> Findings raised by the review dimensions. "-" for stdin.
441
+ --verdicts <file> Flat JSON array of skeptic verdicts. "-" for stdin.
442
+ --metrics <change> Also append the counts to .claude/metrics/ under this
443
+ change name. A metrics failure prints its reason and is
444
+ never fatal — the exit code is unaffected.
445
+ --changed <p1,p2|-> The reviewed diff. A verdict that DISMISSES a finding
446
+ must cite "path:line" (or "path:start-end") naming a
447
+ path in this set — an uncited or unlocatable refutation
448
+ is a non-vote and the finding survives. Omit it and
449
+ only the citation shape is checked, never membership.
450
+ A verdict that a finding IS real needs no citation.
451
+ --min-quality <n> Quality-band floor, as for gate
452
+ --no-tolerance Disable the quality band
453
+ --changed also drives nothing else; REVIEW.md is located from --root (default
454
+ cwd). Findings on its do-not-report paths are dropped BEFORE the band, counted
455
+ as "droppedByPolicy" with the excluding path, and "policyProblems" reports a
456
+ malformed policy file rather than swallowing it.
457
+ Always exits 0; feed "surviving" to gate to decide whether it blocks.
458
+ "dismissalsRejected" counts refutations refused for want of a citation — a
459
+ high number means the skeptics are asserting rather than reading.
460
+
461
+ review-policy:
462
+ --root <dir> Repository root to read REVIEW.md from (default cwd).
463
+ --json Emit { prose, excludePaths, owner, problems }.
464
+ The prose is advice a reviewer reads; it can never lower survival. The band and
465
+ nit cap are NOT policy fields — a threshold-shaped key is reported, not adopted.
466
+ Always exits 0; an absent REVIEW.md is the empty policy, not an error.
467
+
468
+ risk:
469
+ [change] OpenSpec change name (resolved like validate when omitted)
470
+ --change <name> Same, as a flag
471
+ --paths <p1,p2> Changed or planned repo-relative paths. "-" reads
472
+ newline-separated stdin.
473
+ Artifact text is read from the change directory and classified alongside the
474
+ paths. Fails closed: no usable signal classifies as high, never low.
475
+
476
+ ledger:
477
+ [change] OpenSpec change name (resolved like validate when omitted)
478
+ Reads openspec/changes/<change>/decisions.md. Exits 1 when a needs_human row
479
+ remains, when a row is invalid, or when the ledger is MISSING — an absent
480
+ ledger is a failure of the audit, not an empty result, because an empty
481
+ result reads as "nothing needs a human". The JSON distinguishes missing,
482
+ unparseable, and present-and-empty.
483
+ An agent_resolved row is audited: it needs a written resolution, evidence a
484
+ reader can follow (a hedge such as "obvious" counts as blank), and its id must
485
+ appear in the change's design.md. An absent or unreadable design.md makes
486
+ every reference unresolvable rather than resolving all of them.
487
+
488
+ ready:
489
+ [change] OpenSpec change name (resolved like validate when omitted)
490
+ --findings <file|-> The artifact review's own findings output. The blocker
491
+ count is DERIVED from it by the same evaluation
492
+ "interlock gate" uses. REQUIRED — absent, empty and
493
+ unparseable all mean the review did not run, never
494
+ zero blockers.
495
+ --review <file|-> DEPRECATED: a transcribed { blockers, warnings } count.
496
+ Warns, and no longer satisfies the review-blockers
497
+ check on its own — the party writing that number is
498
+ the party being gated. When both are passed and they
499
+ disagree, --findings decides and the disagreement is
500
+ reported.
501
+ --paths <p1,p2|-> Changed or planned repo-relative paths, for the risk class
502
+ Fails closed: every check must affirmatively pass. A check that could not run
503
+ is never a pass, and an error while gathering evidence becomes a blocker.
504
+ Exits 1 when the change is not ready.
505
+
506
+ outcomes:
507
+ append --record <f|-> One outcome object; see .claude/learning/README.md.
508
+ "mode" must be "checkpoint" or "continue" — an
509
+ unrecognised mode is refused rather than guessed.
510
+ The record supplies the "reported" half only. The
511
+ "observed" half is derived here from the run's
512
+ receipt, found via --state <f> or --run-id <id>; an
513
+ "observed" group in the record file is refused, and
514
+ so is an observed field smuggled into "reported".
515
+ No receipt means the record says so — it never falls
516
+ back to the values the record file carried.
517
+ list Read .claude/learning/outcomes.jsonl back
518
+ Nothing gates on this corpus yet; it is recorded so that it can, later.
519
+ Always exits 0 — losing a corpus line must never fail the run that made it.
520
+
521
+ run-log:
522
+ append --event <f|-> One trajectory event; type must be one of run-start,
523
+ wave-action, cli-exit, agent-spawn, verify-judgement,
524
+ run-halt, run-complete, run-receipt. Written to
525
+ .claude/ship/runs/<runId>.jsonl, seq assigned by the
526
+ writer. Never throws — an unwritable log is reported
527
+ on stderr, not raised.
528
+ run-receipt is what the run observed about itself —
529
+ wave tallies, plan fingerprint, review counts,
530
+ degradations, commit identifier — so a trajectory can
531
+ be read without the repository beside it. Its fields
532
+ are copied by name like every other type's, so a run
533
+ may hand it a whole summary object. Absent means
534
+ unobserved, never zero: a run that halted before its
535
+ review reports no review counts rather than none.
536
+ list [--change <name>] List every run with a trajectory file: runId, change,
537
+ halted (with reason) or complete or in-progress, the
538
+ commit identifier, event/skipped counts, and whether a
539
+ receipt was recorded. Filter to one change with
540
+ --change. Tolerates a torn or garbage line the same
541
+ way "outcomes list" does — skips it, reports it.
542
+ show <runId> Print one run's events in sequence order.
543
+ query --run <id> [--type <t>] [--halted]
544
+ Filter one run's events. --type keeps only that event
545
+ type. --halted keeps only the events that explain a
546
+ halt — the run-halt line, plus any verify-judgement
547
+ that itself halted — combine with --type to narrow
548
+ further.
549
+ Session-query (list/show/query) is read-only and never-throws, like
550
+ "outcomes list": a missing run, a torn final line, or an unreadable log is
551
+ reported, never an exception. Always exits 0 — reading the trajectory is
552
+ never itself a gate.
553
+ check --state <file> | --run-id <id>
554
+ The reconstructability gate. Exits 1 unless the run's
555
+ trajectory has contiguous seq from 1, a run-start, a
556
+ run-halt or run-complete, and a cli-exit for every
557
+ wave-state / verify-judge invocation. A run-receipt is
558
+ not required — a run that died before its close could
559
+ not have written one — but two of them is a problem,
560
+ since a run has one close. Once this gate
561
+ is wired into "wave-state" and "verify judge" (see
562
+ those sections), a trajectory write failure on either
563
+ is itself a non-zero exit — the never-throw contract
564
+ above stays true only for "append"/"list"/"show"/
565
+ "query", not for the mutating commands the gate covers.
566
+
567
+ tasks:
568
+ tick --ids <ids> Comma-separated task ids (the leading token of a
569
+ checkbox, e.g. 1.1). Flips matching - [ ] to - [x].
570
+ Already-checked matches are reported, not rewritten.
571
+ Exits 1 and names every id it could not mark: a
572
+ completed task left silently unchecked reads
573
+ downstream as a failed task.
574
+ coverage --classified <f>
575
+ Remaining unchecked checkboxes vs classified.json.
576
+ Exits 1 when an unchecked box has no matching id, so
577
+ ship cannot start implementers on a partial plan.
578
+ [change] / --change OpenSpec change name (resolved like validate)
579
+
580
+ verify plan:
581
+ --profile <file> Parsed .claude/testing/profile.json. "-" for stdin.
582
+ --no-profile Plan without a profile (reports hasProfile:false and a
583
+ skip reason rather than guessing a command)
584
+ --typecheck-command <cmd> Typecheck command; skipped when absent, never invented
585
+ --lint-command <cmd> Lint command; skipped when absent
586
+ --e2e Opt in to e2e (off by default)
587
+ --no-coverage Skip coverage (advisory either way)
588
+ --no-lint Skip lint
589
+ --pre-existing The suite was already red before this change
590
+ --elapsed-ms <n> Verification time already spent this run
591
+ --budget-ms <n> Soft budget (default ${LIMITS.interWaveVerifyBudgetMs}ms); past it, typecheck only
592
+ --context <ctx> ${Object.keys(VERIFY_CONTEXTS).join('|')}. inter-wave omits e2e and
593
+ coverage by default (typecheck, unit, lint only).
594
+ --changed <files...> Repo-relative paths the last wave touched. When every
595
+ path is documentation, the plan emits no steps.
596
+
597
+ verify judge:
598
+ --plan <file> JSON from "interlock verify plan --json". "-" for stdin.
599
+ --results <file> JSON array of { kind, exitCode, ... } an agent reported
600
+ --context <ctx> ${Object.keys(VERIFY_CONTEXTS).join('|')} (default final). Load-bearing:
601
+ inter-wave halts on typecheck AND unit, final halts on
602
+ unit only, so a red typecheck stops the next wave but
603
+ does not stop the commit.
604
+ --baseline <file> Counts captured before the change, for the weakened-suite check
605
+ --pre-existing Excuse every failure as pre-existing
606
+ --pre-existing-signatures <file> JSON array of signatures known red beforehand
607
+ --state <file> Read "runId" from this wave-state file and append a
608
+ verify-judgement + cli-exit trajectory event pair
609
+ tagged with it. Omit --state and --run-id and no
610
+ event is logged; this command's exit code then never
611
+ depends on logging. Given either, a failed append now
612
+ ALSO exits 1 — the reconstructability gate (see
613
+ "run-log check") means a lost trajectory line here is
614
+ a halt, not a reported no-op.
615
+ --run-id <id> Same, when you have a run id but not a state file.
616
+ --state wins if both are given.
617
+ --change <name> Tag the logged event with this change name.
618
+ Exits 1 when the verdict halts, or when --state/--run-id was given and the
619
+ trajectory append failed.
620
+
621
+ verify unit / cluster / repair:
622
+ --result <file> verify unit: one unit result object. Exits 1 on halt.
623
+ --failures <file> verify cluster: JSON array of failure lines or objects
624
+ --state <file> verify repair: { iterationsUsed, clusters, halt, weakened,
625
+ preExisting, maxIterations }. Exits 1 when it halts.
626
+
627
+ verify judge / unit — the anti-swallow check:
628
+ Before judging, every result's "detail", "cliStdout", "reason", "stdout",
629
+ "stderr", "output", and each "failures[]" entry is checked against the
630
+ verify preview budget (\`interlock limits\`). A field over budget is
631
+ rejected outright — reason "oversized result field(s)" — rather than
632
+ judged: a verify agent must spill it with \`interlock verify spill\` and
633
+ pass the preview instead. Exits 1, same as any other halt.
634
+
635
+ verify spill:
636
+ --run-id <id> Run id the spilled output belongs to (required)
637
+ --kind <k> Step kind, e.g. unit/e2e/lint — becomes part of the
638
+ file name (required)
639
+ --input <file|-> Raw combined stdout/stderr to spill (required)
640
+ Writes the bytes to .claude/ship/spill/<runId>/<seq>-<kind>.log and prints
641
+ { locator, bytes, preview, truncated, sha256 } — put locator + preview in the
642
+ result the workflow's verify prompt writes, never the full text. Preview is a
643
+ head/tail slice bounded by \`interlock limits\`' verify preview budget.
644
+ Always exits 0.
645
+
646
+ plan:
647
+ fingerprint [change] Hash the inputs a plan for this change is derived from:
648
+ proposal.md, design.md, tasks.md (with checkbox markers
649
+ normalized, so ticking a task does not invalidate the
650
+ plan), every delta spec, the change name, --max-parallel,
651
+ the per-tier lane-cap table, the cohesion tier ceiling,
652
+ the solo envelope, the --mode override (recorded as auto
653
+ when absent), and the plan format version. --write stores
654
+ it at ${FINGERPRINT_PATH}.
655
+ reuse [change] Compare a stored fingerprint against a fresh one and, on
656
+ a match, narrow the stored plan to incomplete tasks and
657
+ write it to --out (default ${NARROWED_PLAN_PATH}).
658
+ Reports "status" as one of match, inputs-changed,
659
+ no-plan, no-fingerprint, unreadable-plan,
660
+ unreadable-fingerprint, plan-format-changed,
661
+ check-failed — a named outcome, never a bare boolean.
662
+ "noRemainingWork" means every task in the plan is
663
+ already complete; do not create a run from it.
664
+ --plan <file> Stored plan to check (default ${PLAN_PATH})
665
+ --fingerprint <file> Stored fingerprint (default ${FINGERPRINT_PATH})
666
+ --out <file> Where "reuse" writes the narrowed plan
667
+ --max-parallel <n> The cap the plan was (or will be) built with; it is part
668
+ of the fingerprint, so asking for a different width
669
+ re-plans rather than reusing a differently-shaped plan.
670
+ --mode solo|waves The mode override the plan was (or will be) built under;
671
+ part of the fingerprint, so a stored plan built with a
672
+ different override (or none) is rebuilt rather than reused.
673
+ --red-wave <n> The leading failing-test section the plan was (or will be)
674
+ built under. Part of the fingerprint for the same reason,
675
+ and OMITTED from it entirely when absent — so no plan
676
+ predating the TDD task shape invalidates for a signal it
677
+ never used.
678
+ Never exits non-zero: every failure to establish a match — including an error
679
+ raised while checking — is a reason to re-plan, which is what happens today.
680
+ Branch on "reuse", and report "reason" either way.
681
+
682
+ wave-state:
683
+ The run state machine, passed as JSON on stdin/stdout because the workflow
684
+ script cannot hold state across agent calls — each agent reads the state in
685
+ and writes the next one out.
686
+ create --plan <file> Plan from "interlock waves --json"; --max-parallel <n>
687
+ next --state <file> --json emits the STEP to take
688
+ record-batch --state <f> --result <f> result: { tasks: [{id, ok, error?, handoff?}], replanSuggested? }
689
+ handoff: { schema: "interlock.wave-handoff/1", taskId,
690
+ status: ok|blocked|partial, summary, evidence[], next,
691
+ blocker }. A task claiming ok:true without a valid,
692
+ in-budget packet is recorded as failed ("invalid
693
+ handoff: ..."); see "interlock limits" for the cap.
694
+ The next wave's step carries these as previousHandoffs.
695
+ record-verify --state <f> --result <f> result: { ok, errors?, blocksNextWave? } or
696
+ { skipped: true, reason }
697
+ replan --state <f> --groups <f> groups: [{ group, tasks: [...] }]
698
+ --write-state <file> record-* / replan: write the new state to <file>;
699
+ with --json, stdout is the NEXT STEP (same shape as "next")
700
+ --change <name> Tag the trajectory events below with this change name
701
+ (default "unnamed"); has no effect on the state itself.
702
+ With --json, create / record-* / replan emit the NEW STATE — pipe it into the
703
+ next call. Only "next" emits a step instead. --write-state flips record-* /
704
+ replan to that step-on-stdout shape so a ship agent need not call next again.
705
+ Every subcommand also appends a wave-action + cli-exit event to
706
+ .claude/ship/runs/<runId>.jsonl (runId read from the state). A failed append
707
+ is reported on stderr AND now exits 1 (the reconstructability gate) — a lost
708
+ trajectory line here is a halt, not a bookkeeping miss. "next" additionally
709
+ logs one agent-spawn per task on a run-batch/test-wave step, same gate.
710
+
711
+ autonomy record:
712
+ --blockers <n> Blockers caught by this gate (default 0)
713
+ --human-override Record that a human overrode the result
714
+
715
+ Examples:
716
+ interlock doctor || echo "fix these before starting a zero-touch run"
717
+ interlock doctor --json | jq '.failures'
718
+ interlock surface --changed src/Button.tsx docs/readme.md
719
+ interlock waves --classified plan.json --max-parallel 6 --json
720
+ interlock gate --findings review.json || echo "blocked"
721
+ interlock limits --json
722
+ interlock remediate --findings review.json --round 3 --json || echo "halted"
723
+ interlock review --findings raised.json --verdicts verdicts.json --metrics add-auth
724
+ interlock risk add-auth --paths src/auth/session.ts,src/api/login.ts --json
725
+ interlock ledger add-auth || echo "a human still owes a decision"
726
+ interlock verify plan --profile .claude/testing/profile.json --typecheck-command "npx tsc --noEmit" --json > vplan.json
727
+ interlock verify judge --plan vplan.json --results results.json --context inter-wave || echo "halt"
728
+ interlock plan reuse add-auth --json || true
729
+ interlock plan fingerprint add-auth --write --json
730
+ interlock waves --classified plan.json --json | interlock wave-state create --plan - --json > run.json
731
+ interlock wave-state next --state run.json --json
732
+ interlock wave-state record-batch --state run.json --result batch1.json --json > run2.json
733
+ interlock wave-state record-batch --state run.json --result batch1.json --write-state run.json --json
734
+ interlock autonomy level review-code
735
+ `
736
+
737
+ // Flags that never take a value. Everything else consumes the next argv entry
738
+ // unless that entry is itself a --flag.
739
+ const BOOLEAN_FLAGS = new Set([
740
+ 'json',
741
+ 'human-override',
742
+ 'no-tolerance',
743
+ 'e2e',
744
+ 'no-coverage',
745
+ 'no-lint',
746
+ 'no-profile',
747
+ 'pre-existing',
748
+ 'halted',
749
+ 'write',
750
+ // `run start`'s invocation flags. Each is a shape decision the driver already
751
+ // parsed; the run manifest is where they stop being host-specific.
752
+ 'apply-only',
753
+ 'no-commit',
754
+ 'skip-e2e',
755
+ 'skip-coverage',
756
+ 'isolate-waves',
757
+ 'continue',
758
+ // The task-shape override. `--red-wave` is deliberately NOT here: it takes a
759
+ // section number, and making the name boolean would swallow that value.
760
+ 'tdd',
761
+ 'no-tdd',
762
+ // `run close --notify`: both drivers pass it unconditionally, and the CLI —
763
+ // not the driver — decides whether a topic is configured (design D1).
764
+ 'notify'
765
+ // `--review`, `--handoff`, `--conformance` and `--strict` are deliberately
766
+ // NOT here. `interlock ready --review <findings.json>` takes a value under
767
+ // the same spelling, and making the name boolean everywhere would silently
768
+ // turn that file path into a positional. On `run start` they take no value,
769
+ // and the parser already yields `true` for a flag followed by another flag or
770
+ // by nothing — which is the only way `run start` is ever invoked.
771
+ ])
772
+
773
+ function parseArgs(argv) {
774
+ const out = { _: [], flags: {} }
775
+ for (let i = 0; i < argv.length; i++) {
776
+ const a = argv[i]
777
+ if (!a.startsWith('--')) {
778
+ out._.push(a)
779
+ continue
780
+ }
781
+ const key = a.slice(2)
782
+ const next = argv[i + 1]
783
+ // Boolean flags take no value.
784
+ if (BOOLEAN_FLAGS.has(key)) {
785
+ out.flags[key] = true
786
+ continue
787
+ }
788
+ if (key === 'changed') {
789
+ // Variadic: consume until the next --flag.
790
+ const vals = []
791
+ while (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) vals.push(argv[++i])
792
+ out.flags.changed = vals
793
+ continue
794
+ }
795
+ if (key === 'mode') {
796
+ // Accumulated rather than last-wins: `--mode solo --mode waves` must reach
797
+ // `decideMode` as an array so it is rejected as contradictory, not silently
798
+ // resolved to the last flag. A single `--mode solo` stays a scalar string.
799
+ const val = next === undefined || next.startsWith('--') ? true : argv[++i]
800
+ out.flags.mode = out.flags.mode === undefined ? val : [].concat(out.flags.mode, val)
801
+ continue
802
+ }
803
+ if (next === undefined || next.startsWith('--')) {
804
+ out.flags[key] = true
805
+ continue
806
+ }
807
+ out.flags[key] = next
808
+ i++
809
+ }
810
+ return out
811
+ }
812
+
813
+ // stdin can only be drained once, so a command asking for two "-" inputs would
814
+ // silently get an empty second one. Say so instead.
815
+ let stdinConsumed = false
816
+
817
+ function readStdin() {
818
+ if (stdinConsumed) {
819
+ die('only one input per invocation may be "-": stdin has already been read')
820
+ }
821
+ stdinConsumed = true
822
+ try {
823
+ return readFileSync(0, 'utf8')
824
+ } catch {
825
+ return ''
826
+ }
827
+ }
828
+
829
+ function readInput(value, what) {
830
+ if (!value || value === true) die(`${what} is required`)
831
+ const raw = value === '-' ? readStdin() : readFileSync(value, 'utf8')
832
+ try {
833
+ return JSON.parse(raw)
834
+ } catch (err) {
835
+ die(`${what} is not valid JSON: ${err.message}`)
836
+ }
837
+ }
838
+
839
+ // Like `readInput`, but for a payload that is bytes to spill rather than JSON
840
+ // to parse — `verify spill` hands this raw suite output, which is exactly the
841
+ // text the rest of the CLI must never try to JSON.parse.
842
+ function readRawInput(value, what) {
843
+ if (!value || value === true) die(`${what} is required`)
844
+ return value === '-' ? readStdin() : readFileSync(value, 'utf8')
845
+ }
846
+
847
+ function die(message) {
848
+ process.stderr.write(`interlock: ${message}\n`)
849
+ process.exit(1)
850
+ }
851
+
852
+ function warn(message) {
853
+ process.stderr.write(`interlock: ${message}\n`)
854
+ }
855
+
856
+ /**
857
+ * A run step in human form. The JSON is what a driver obeys; this is for the
858
+ * person running `interlock run` by hand to see what the program decided.
859
+ */
860
+ function formatRunStep(step) {
861
+ const lines = [`${step.action}${step.wave === undefined ? '' : ` — wave ${step.wave}`}`]
862
+ if (step.reason) lines.push(` reason: ${step.reason}`)
863
+ for (const s of step.spawns || []) {
864
+ lines.push(
865
+ ` spawn ${s.label} (${s.kind}${s.model ? `, ${s.model}` : ''}${s.effort ? `, ${s.effort}` : ''})` +
866
+ ` → ${s.promptPath}`
867
+ )
868
+ }
869
+ for (const banner of step.banners || []) lines.push(` ${banner}`)
870
+ lines.push(step.then ? ` then: interlock ${step.then.argv.join(' ')}` : ' then: (terminal)')
871
+ return lines.join('\n') + '\n'
872
+ }
873
+
874
+ function emit(json, obj, text) {
875
+ process.stdout.write(json ? JSON.stringify(obj, null, 2) + '\n' : text)
876
+ }
877
+
878
+ /**
879
+ * record-* / replan: default stdout is the new state. --write-state writes that
880
+ * state to a file and stdout becomes nextStep() — the shape `wave-state next`
881
+ * already emits — so a ship agent can record and learn the next action in one turn.
882
+ */
883
+ function emitRecordedState(json, flags, state, { root, change, logOk = true, source, recorded } = {}) {
884
+ const dest = flags['write-state']
885
+ if (dest === true) die('--write-state requires a file path')
886
+ if (dest === '-') die('--write-state cannot be "-"')
887
+ // The per-task verdicts this call recorded, reported to the caller that
888
+ // recorded them. Additive, on both paths: without it a host learns the next
889
+ // action and never learns that a task it was told succeeded was failed on
890
+ // arrival — so it ticks the box anyway. Never written to the state file: the
891
+ // outcomes describe one batch, and a resumed run must not read them as its
892
+ // own. (`mutable()` strips one that was piped back in.)
893
+ const carry = obj => (recorded ? { ...obj, recorded } : obj)
894
+ if (dest) {
895
+ mkdirSync(dirname(dest), { recursive: true })
896
+ writeFileSync(dest, JSON.stringify(state, null, 2) + '\n')
897
+ const step = nextStep(state)
898
+ const spawnOk = logAgentSpawns(root, { state, change, step, source })
899
+ emit(json, carry(step), formatRunState(state))
900
+ if (step.action === 'halt' || state.halt || !logOk || !spawnOk) process.exit(1)
901
+ return
902
+ }
903
+ emit(json, carry(state), formatRunState(state))
904
+ if (state.halt || !logOk) process.exit(1)
905
+ }
906
+
907
+ // `state.runId` travels on the frozen wave-state (design.md decision 1) — read
908
+ // the same defensive way `logWaveMutation` does, since a malformed state must
909
+ // not crash the CLI, only fail the (never-throw) trajectory append.
910
+ function runIdOf(state) {
911
+ return state && typeof state.runId === 'string' ? state.runId : undefined
912
+ }
913
+
914
+ // `state.change` travels the same way `state.runId` does, and is read the same
915
+ // defensive way. It is the AUTHORITY over `--change` (design.md decision 2): the
916
+ // state's name was fixed when the run was created, while the flag is
917
+ // per-invocation and is the mechanism that demonstrably drifted. Preferring the
918
+ // flag would let one mislabeled call relabel part of a trajectory, producing a
919
+ // log whose lines disagree about which change they belong to — worse than a
920
+ // uniformly unnamed one, because it looks correct.
921
+ function changeOf(state) {
922
+ const name = state && typeof state.change === 'string' ? state.change.trim() : ''
923
+ return name || undefined
924
+ }
925
+
926
+ // The name to stamp on this invocation's events: the state's, else the flag's
927
+ // (a state.json written before the name was carried on state). Neither leaves it
928
+ // undefined, and `appendRunLogEvent` writes the absent-name placeholder rather
929
+ // than failing — losing a trajectory label must not lose the run.
930
+ function changeFor(state, flagChange) {
931
+ const flag = typeof flagChange === 'string' && flagChange.trim() ? flagChange.trim() : undefined
932
+ return changeOf(state) || flag
933
+ }
934
+
935
+ // `wave-state next` / `--write-state` both surface the state machine's next
936
+ // step. A `run-batch`/`test-wave` step carries `tasks[]` (the current batch, as
937
+ // LANES) and `remainingBatches` (rest of the wave, as batches of lanes). ship.js
938
+ // runs remainingBatches sequentially before the next record ping, so the
939
+ // reconstructable spawn list is remainingBatches — not only tasks[]. Mid-wave
940
+ // record-batch --write-state is cursor catch-up and MUST NOT duplicate those
941
+ // events.
942
+ //
943
+ // One spawn per LANE, because a lane is one agent. Counting per task would
944
+ // report four spawns for a wave that ran two, which is the fold this change
945
+ // exists for reported as if it never happened.
946
+ function spawnLanesOf(step) {
947
+ const lanes =
948
+ Array.isArray(step.remainingBatches) && step.remainingBatches.length
949
+ ? step.remainingBatches.flat(1)
950
+ : Array.isArray(step.tasks)
951
+ ? step.tasks
952
+ : []
953
+ return lanes
954
+ .map(lane => (Array.isArray(lane) ? lane.filter(t => t && typeof t === 'object') : []))
955
+ .filter(lane => lane.length)
956
+ }
957
+
958
+ // The label the workflow gives the same agent (`workflows/ship.js` laneLabel), so
959
+ // a trajectory line and the agent it describes carry one name rather than two.
960
+ function laneLabelOf(lane) {
961
+ return lane.length === 1 ? lane[0].id : `${lane[0].id}+${lane.length - 1}`
962
+ }
963
+
964
+ // The model the lane runs on: its hardest task's. A lane is one agent and that
965
+ // agent has to be capable of everything in it.
966
+ function laneModelOf(lane) {
967
+ const tier = lane.reduce((m, t) => (Number.isInteger(t.tier) && t.tier > m ? t.tier : m), 0)
968
+ const hardest = lane.find(t => t.tier === tier) || lane[0]
969
+ return hardest && typeof hardest.model === 'string' ? hardest.model : undefined
970
+ }
971
+
972
+ // Takes the run's `state` rather than a runId, so the change name is resolved
973
+ // from it the same way `logWaveMutation` does instead of being threaded in by a
974
+ // caller who may not have been given one.
975
+ function logAgentSpawns(root, { state, change, step, source }) {
976
+ if (!step || (step.action !== 'run-batch' && step.action !== 'test-wave')) return true
977
+ if (source === 'record-batch' && Number(step.batchIndex) > 0) return true
978
+ const runId = runIdOf(state)
979
+ const changeName = changeFor(state, change)
980
+ let ok = true
981
+ for (const lane of spawnLanesOf(step)) {
982
+ const spawn = appendRunLogEvent(root, {
983
+ runId,
984
+ change: changeName,
985
+ type: 'agent-spawn',
986
+ label: laneLabelOf(lane),
987
+ model: laneModelOf(lane),
988
+ kind: 'implementer',
989
+ // The lane's anchor task. A multi-task lane's later ids are recoverable
990
+ // from the plan and from its per-task record-batch results; what this line
991
+ // is for is naming the AGENT, and there is one of those per lane.
992
+ taskId: typeof lane[0].id === 'string' ? lane[0].id : undefined
993
+ })
994
+ if (!spawn.written) {
995
+ process.stderr.write(`interlock: agent-spawn not recorded: ${spawn.reason}\n`)
996
+ ok = false
997
+ }
998
+ }
999
+ return ok
1000
+ }
1001
+
1002
+ // The anti-swallow check (design.md §3.3): a verify agent that pasted a
1003
+ // spilled suite log into a result field instead of using the locator+preview
1004
+ // `interlock verify spill` handed it is rejected before its result reaches a
1005
+ // judgement, never judged as if the oversized text were a legitimate report.
1006
+ function rejectOversized(results, context) {
1007
+ const check = checkResultFieldSizes(results)
1008
+ if (check.ok) return null
1009
+ const reason =
1010
+ `oversized result field(s): ${check.violations
1011
+ .map(v => `${v.kind}.${v.field} (${v.length} chars, budget ${v.limit})`)
1012
+ .join(', ')} — spill it with \`interlock verify spill\` and pass the preview instead`
1013
+ return {
1014
+ halt: true,
1015
+ reason,
1016
+ context: context || null,
1017
+ haltingKinds: [],
1018
+ unit: null,
1019
+ e2e: null,
1020
+ coverage: null,
1021
+ signals: [{ level: 'halt', kind: 'oversized-result', message: reason }],
1022
+ banners: [reason],
1023
+ skipped: []
1024
+ }
1025
+ }
1026
+
1027
+ // `verify judge` logs a trajectory event only when it can identify the run: a
1028
+ // `--run-id` given directly, or one read from `--state`'s frozen `runId` field
1029
+ // (design.md decision 4 — "the run id cannot drift" when it comes from the
1030
+ // same state file the caller is already threading through the loop). Neither
1031
+ // present means this judge call is not part of a logged ship run — e.g. a
1032
+ // person running `interlock verify judge` by hand — so nothing is logged and
1033
+ // nothing is required to be.
1034
+ function stateFromFlags(flags) {
1035
+ const stateFile = flags.state
1036
+ if (typeof stateFile !== 'string' || stateFile === '-') return undefined
1037
+ try {
1038
+ return JSON.parse(readFileSync(stateFile, 'utf8'))
1039
+ } catch {
1040
+ // Unreadable or malformed state: no run identity to log against, and this
1041
+ // is not the command that should fail the run over a logging concern.
1042
+ return undefined
1043
+ }
1044
+ }
1045
+
1046
+ function runIdFromFlags(flags) {
1047
+ if (typeof flags['run-id'] === 'string' && flags['run-id'].trim()) return flags['run-id'].trim()
1048
+ return runIdOf(stateFromFlags(flags))
1049
+ }
1050
+
1051
+ // The mark `durationMs` on a `cli-exit` is measured from. Taken at module scope
1052
+ // so it covers everything this process does under its own name — flag parsing
1053
+ // and the lib/ call — rather than only the last few statements before the
1054
+ // append.
1055
+ //
1056
+ // It is CLI SELF-TIME and nothing else. This process runs BETWEEN agent turns
1057
+ // and never observes one, so the interval here is typically a millisecond or
1058
+ // two; `lib/run-log.mjs` states the same limit at the field, and wave elapsed
1059
+ // time comes from `deriveWaveElapsed` instead. Filling the field with a number
1060
+ // that looked like agent wall clock would be worse than leaving it null.
1061
+ const CLI_STARTED_AT = performance.now()
1062
+
1063
+ // `undefined` rather than `0` when the clock gives nothing usable: the writer
1064
+ // coerces an absent value to `null`, and `null` reads as "not measured" while a
1065
+ // `0` would assert an instantaneous command.
1066
+ function cliDurationMs() {
1067
+ const ms = performance.now() - CLI_STARTED_AT
1068
+ return Number.isFinite(ms) && ms >= 0 ? Math.round(ms) : undefined
1069
+ }
1070
+
1071
+ // A `verify-judgement` line never carries the suite transcript: only the
1072
+ // verdict's own short reason and status, plus locators for whatever the
1073
+ // verify helper already spilled (task 3.3) — never the results' raw text.
1074
+ // Returns whether the trajectory was written, so the reconstructability gate
1075
+ // (task 6.2) can turn a failed append into a non-zero exit once it is wired
1076
+ // in — a caller with no runId gets `true` back, since nothing was owed.
1077
+ function logVerifyJudgement(root, { runId, change, verdict, results }) {
1078
+ if (!runId) return true
1079
+ const spill = Array.isArray(results)
1080
+ ? results.filter(r => r && typeof r.locator === 'string' && r.locator).map(r => r.locator)
1081
+ : []
1082
+ const logged = appendRunLogEvent(root, {
1083
+ runId,
1084
+ change,
1085
+ type: 'verify-judgement',
1086
+ context: verdict.context,
1087
+ halt: verdict.halt,
1088
+ reason: verdict.reason || (verdict.halt ? 'verification halted' : 'verification ok'),
1089
+ unitStatus: verdict.unit ? verdict.unit.status : null,
1090
+ spill
1091
+ })
1092
+ if (!logged.written) {
1093
+ process.stderr.write(`interlock: verify-judgement not recorded: ${logged.reason}\n`)
1094
+ }
1095
+ // Mirrors what `logWaveMutation` does for `wave-state`: a `cli-exit` alongside
1096
+ // the semantic event, so the reconstructability check (task 6.1) can require
1097
+ // "a cli-exit per wave-state / verify judge invocation" uniformly, without
1098
+ // special-casing verify.
1099
+ const exit = appendRunLogEvent(root, {
1100
+ runId,
1101
+ change,
1102
+ type: 'cli-exit',
1103
+ command: 'verify judge',
1104
+ exitCode: verdict.halt ? 1 : 0,
1105
+ durationMs: cliDurationMs()
1106
+ })
1107
+ if (!exit.written) {
1108
+ process.stderr.write(`interlock: cli-exit not recorded: ${exit.reason}\n`)
1109
+ }
1110
+ return logged.written && exit.written
1111
+ }
1112
+
1113
+ // `wave-state create|next|record-*|replan` all append their own trajectory
1114
+ // events (design.md decision 1: `runId` is created once, in createRunState,
1115
+ // and travels on the frozen state — no new flag needed) so a ship agent never
1116
+ // spends a turn calling `run-log append` itself for a wave-state mutation.
1117
+ // `nextStep(state)` already knows the state-machine action, wave, and cursor
1118
+ // this event describes; callers that also need `step` for their own stdout
1119
+ // (e.g. `next`) get it back instead of computing it twice. `ok` reports
1120
+ // whether both appends were written, so the reconstructability gate (task
1121
+ // 6.2) can turn a failed trajectory write into a non-zero exit.
1122
+ function logWaveMutation(root, { source, change, state }) {
1123
+ const step = nextStep(state)
1124
+ const runId = runIdOf(state)
1125
+ const changeName = changeFor(state, change)
1126
+ const wave = appendRunLogEvent(root, {
1127
+ runId,
1128
+ change: changeName,
1129
+ type: 'wave-action',
1130
+ source,
1131
+ action: step.action,
1132
+ // `step.wave` is `wave.group` from lib/waves.mjs — a plan-assigned integer,
1133
+ // not a label — so it is stringified for the text field rather than dropped.
1134
+ wave: step.wave === undefined || step.wave === null ? null : String(step.wave),
1135
+ waveIndex: Number.isInteger(step.waveIndex) ? step.waveIndex : null,
1136
+ batchIndex: Number.isInteger(step.batchIndex) ? step.batchIndex : null,
1137
+ phase: state && state.cursor ? state.cursor.phase : null
1138
+ })
1139
+ if (!wave.written) {
1140
+ process.stderr.write(`interlock: wave-action not recorded: ${wave.reason}\n`)
1141
+ }
1142
+ const exit = appendRunLogEvent(root, {
1143
+ runId,
1144
+ change: changeName,
1145
+ type: 'cli-exit',
1146
+ command: `wave-state ${source}`,
1147
+ // `nextStep` returns action:'halt' exactly when state.halt is set, so this
1148
+ // mirrors the exit code every call site below actually produces.
1149
+ exitCode: state && state.halt ? 1 : 0,
1150
+ durationMs: cliDurationMs()
1151
+ })
1152
+ if (!exit.written) {
1153
+ process.stderr.write(`interlock: cli-exit not recorded: ${exit.reason}\n`)
1154
+ }
1155
+ return { step, ok: wave.written && exit.written }
1156
+ }
1157
+
1158
+ /**
1159
+ * The changed-path set as VERSION CONTROL sees it, for auditing handoff
1160
+ * evidence at `record-batch`.
1161
+ *
1162
+ * Read here rather than in `lib/waves.mjs` because that module is pure, and
1163
+ * read at THIS moment because it is the only one that means anything: the
1164
+ * working tree still matches what the implementer just claimed. Audited later,
1165
+ * the tree has moved and a `confirmed` verdict is worthless.
1166
+ *
1167
+ * Never throws, following `lib/drift.mjs`'s `gitLastCommit` precedent. Missing
1168
+ * git, a non-repository directory, a worktree in a state git dislikes — all
1169
+ * mean "no observed set", which is a documented fallback to the task's own
1170
+ * report, never a failed batch. Recording a wave's work must not depend on git
1171
+ * being cooperative.
1172
+ *
1173
+ * `-z` because a path with a space, a quote or a tab is a path, not a parse
1174
+ * error; `--untracked-files=all` because a new file inside a new directory
1175
+ * would otherwise be reported as the directory and match no locator.
1176
+ */
1177
+ function observedChangedPaths(root) {
1178
+ let raw
1179
+ try {
1180
+ raw = execFileSync('git', ['status', '--porcelain', '-z', '--untracked-files=all'], {
1181
+ cwd: root,
1182
+ encoding: 'utf8',
1183
+ stdio: ['ignore', 'pipe', 'ignore']
1184
+ })
1185
+ } catch {
1186
+ return []
1187
+ }
1188
+
1189
+ const fields = raw.split('\0').filter(f => f.length)
1190
+ const paths = []
1191
+ for (let i = 0; i < fields.length; i++) {
1192
+ const field = fields[i]
1193
+ // "XY path". Anything shorter carries no path.
1194
+ if (field.length < 4) continue
1195
+ const status = field.slice(0, 2)
1196
+ const path = field.slice(3)
1197
+ if (path) paths.push(path)
1198
+ // A rename or copy carries its SOURCE in the following field. Both sides are
1199
+ // paths this run changed, so both are kept — and the source has to be
1200
+ // consumed here or the next iteration would read it as a status line and
1201
+ // slice three characters off the front of a real path.
1202
+ if (/[RC]/.test(status) && fields[i + 1]) {
1203
+ paths.push(fields[i + 1])
1204
+ i += 1
1205
+ }
1206
+ }
1207
+ return paths
1208
+ }
1209
+
1210
+ /**
1211
+ * A lane worktree's actual writes relative to the shared base commit, as git
1212
+ * sees them — never a prediction. `null` means git could not answer (bad
1213
+ * path, not a worktree, git failure of any kind), which the caller treats as
1214
+ * "this lane's worktree could not be located" rather than "wrote nothing".
1215
+ *
1216
+ * `-z` for the same reason `observedChangedPaths` uses it: a path with a
1217
+ * space, quote or tab is a path, not a field separator.
1218
+ *
1219
+ * @param {string} worktreePath
1220
+ * @param {string} base
1221
+ * @returns {Array<{status: 'A'|'M'|'D'|'R', path: string, oldPath?: string}>|null}
1222
+ */
1223
+ function laneDiff(worktreePath, base) {
1224
+ let raw
1225
+ try {
1226
+ raw = execFileSync('git', ['-C', worktreePath, 'diff', '--name-status', '-z', base], {
1227
+ encoding: 'utf8',
1228
+ stdio: ['ignore', 'pipe', 'ignore']
1229
+ })
1230
+ } catch {
1231
+ return null
1232
+ }
1233
+ const fields = raw.split('\0').filter(f => f.length)
1234
+ const out = []
1235
+ for (let i = 0; i < fields.length; i++) {
1236
+ const status = fields[i]
1237
+ if (/^[AMD]/.test(status)) {
1238
+ const path = fields[++i]
1239
+ if (path) out.push({ status: status[0], path })
1240
+ } else if (/^[RC]/.test(status)) {
1241
+ // "R100\told\tnew": a rename/copy carries its source in the following
1242
+ // field, the same shape `observedChangedPaths` already accounts for.
1243
+ const oldPath = fields[++i]
1244
+ const newPath = fields[++i]
1245
+ if (newPath) out.push({ status: status[0], path: newPath, oldPath: oldPath || undefined })
1246
+ }
1247
+ }
1248
+
1249
+ // `git diff <base>` never reports an untracked file — a lane's new file is
1250
+ // invisible to it exactly the way `observedChangedPaths` below has to work
1251
+ // around with `--untracked-files=all`. Add them as new writes explicitly,
1252
+ // rather than silently reporting a lane's new file as "no change".
1253
+ let untracked
1254
+ try {
1255
+ untracked = execFileSync(
1256
+ 'git',
1257
+ ['-C', worktreePath, 'ls-files', '--others', '--exclude-standard', '-z'],
1258
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
1259
+ )
1260
+ } catch {
1261
+ return null
1262
+ }
1263
+ for (const path of untracked.split('\0').filter(p => p.length)) {
1264
+ out.push({ status: 'A', path })
1265
+ }
1266
+ return out
1267
+ }
1268
+
1269
+ /**
1270
+ * Apply one lane's already-decided-clean writes into the shared tree. Called
1271
+ * only after `mergeDecision` returned `status: 'clean'` — a collision or an
1272
+ * unresolved lane never reaches here, by construction of the caller.
1273
+ */
1274
+ function applyLaneDiff(root, worktreePath, diffEntries) {
1275
+ for (const entry of diffEntries) {
1276
+ const destAbs = join(root, entry.path)
1277
+ if (entry.status === 'D') {
1278
+ if (existsSync(destAbs)) rmSync(destAbs)
1279
+ continue
1280
+ }
1281
+ const srcAbs = join(worktreePath, entry.path)
1282
+ if (!existsSync(srcAbs)) continue
1283
+ mkdirSync(dirname(destAbs), { recursive: true })
1284
+ copyFileSync(srcAbs, destAbs)
1285
+ if (entry.status === 'R' && entry.oldPath && entry.oldPath !== entry.path) {
1286
+ const oldAbs = join(root, entry.oldPath)
1287
+ if (existsSync(oldAbs)) rmSync(oldAbs)
1288
+ }
1289
+ }
1290
+ }
1291
+
1292
+ /**
1293
+ * The git plumbing half of `merge-lanes`: turns each lane's self-reported
1294
+ * `worktreePath` into an observed changed-file set, hands the decision to the
1295
+ * pure `mergeDecision`, and — on a clean fold only — applies the writes and
1296
+ * removes the lane worktrees uniformly. A collision or an unresolved lane
1297
+ * touches nothing and leaves every surviving worktree named on disk.
1298
+ *
1299
+ * @param {string} root
1300
+ * @param {Array<{label: string, worktreePath?: string, reportedFiles?: string[]}>} laneEntries
1301
+ * @param {string} base the shared-tree commit every lane's diff is taken against
1302
+ */
1303
+ function runMergeLanes(root, laneEntries, base) {
1304
+ const entries = Array.isArray(laneEntries) ? laneEntries : []
1305
+ const diffsByLabel = new Map()
1306
+ const changedByLane = {}
1307
+ const survivingInfo = []
1308
+
1309
+ const lanesForDecision = entries.map(l => ({
1310
+ label: l && typeof l.label === 'string' ? l.label.trim() : ''
1311
+ }))
1312
+
1313
+ for (const l of entries) {
1314
+ const label = l && typeof l.label === 'string' ? l.label.trim() : ''
1315
+ if (!label) continue
1316
+ const path = l && typeof l.worktreePath === 'string' ? l.worktreePath.trim() : ''
1317
+ survivingInfo.push({ label, worktreePath: path || null })
1318
+ if (!path) continue // no worktreePath reported at all — unresolved, named by mergeDecision
1319
+
1320
+ if (!existsSync(path)) {
1321
+ // The runtime auto-removes an unchanged worktree. A lane that reported
1322
+ // no changed files itself is a legitimate empty-write lane, not a lane
1323
+ // whose worktree "could not be located" — anything else missing this
1324
+ // path is unresolved.
1325
+ const reported = Array.isArray(l.reportedFiles) ? l.reportedFiles : null
1326
+ if (reported && reported.length === 0) {
1327
+ changedByLane[label] = []
1328
+ diffsByLabel.set(label, [])
1329
+ }
1330
+ continue
1331
+ }
1332
+
1333
+ const diff = laneDiff(path, base)
1334
+ if (diff === null) continue // git could not diff this worktree — unresolved
1335
+ diffsByLabel.set(label, diff)
1336
+ changedByLane[label] = diff.map(d => d.path)
1337
+ }
1338
+
1339
+ const decision = mergeDecision({ lanes: lanesForDecision, changedByLane, base })
1340
+
1341
+ if (decision.status !== 'clean') {
1342
+ const survivingWorktrees = survivingInfo.filter(s => s.worktreePath && existsSync(s.worktreePath))
1343
+ return { ...decision, survivingWorktrees }
1344
+ }
1345
+
1346
+ for (const fold of decision.folds) {
1347
+ const info = survivingInfo.find(s => s.label === fold.lane)
1348
+ if (!info || !info.worktreePath || !existsSync(info.worktreePath)) continue
1349
+ applyLaneDiff(root, info.worktreePath, diffsByLabel.get(fold.lane) || [])
1350
+ }
1351
+
1352
+ // Uniform cleanup: an unchanged lane worktree the runtime would have
1353
+ // auto-removed anyway is removed here too, so post-fold state never depends
1354
+ // on which lanes happened to write something.
1355
+ const cleanupWarnings = []
1356
+ for (const info of survivingInfo) {
1357
+ if (!info.worktreePath || !existsSync(info.worktreePath)) continue
1358
+ try {
1359
+ execFileSync('git', ['worktree', 'remove', info.worktreePath, '--force'], {
1360
+ cwd: root,
1361
+ stdio: 'ignore'
1362
+ })
1363
+ } catch (err) {
1364
+ cleanupWarnings.push({ label: info.label, worktreePath: info.worktreePath, reason: err.message })
1365
+ }
1366
+ }
1367
+
1368
+ return {
1369
+ ...decision,
1370
+ survivingWorktrees: [],
1371
+ ...(cleanupWarnings.length ? { cleanupWarnings } : {})
1372
+ }
1373
+ }
1374
+
1375
+ function formatMergeLanes(result) {
1376
+ if (result.status === 'clean') {
1377
+ const rows = result.folds
1378
+ .map(f => ` ${f.lane}: ${f.files.length ? f.files.join(', ') : '(no changes)'}`)
1379
+ .join('\n')
1380
+ const warn = result.cleanupWarnings && result.cleanupWarnings.length
1381
+ ? `\ncleanup warnings:\n${result.cleanupWarnings.map(w => ` ${w.label} (${w.worktreePath}): ${w.reason}`).join('\n')}\n`
1382
+ : ''
1383
+ return `CLEAN FOLD\n${rows}\n${warn}`
1384
+ }
1385
+ if (result.status === 'collision') {
1386
+ const rows = result.collisions
1387
+ .map(c => ` ${c.canonicalPath}: ${c.lanes.join(', ')}`)
1388
+ .join('\n')
1389
+ const survivors = result.survivingWorktrees
1390
+ .map(s => ` ${s.label}: ${s.worktreePath}`)
1391
+ .join('\n')
1392
+ return `HALT — path collision\n${rows}\nsurviving worktrees:\n${survivors}\n`
1393
+ }
1394
+ const rows = result.unresolved.map(u => ` ${u.label || '(no label)'}: ${u.reason}`).join('\n')
1395
+ const survivors = result.survivingWorktrees
1396
+ .map(s => ` ${s.label}: ${s.worktreePath}`)
1397
+ .join('\n')
1398
+ return `HALT — unresolved lane(s)\n${rows}\nsurviving worktrees:\n${survivors}\n`
1399
+ }
1400
+
1401
+ // --min-quality / --no-tolerance mean the same thing everywhere the tolerance
1402
+ // band applies, so the three call sites share one reading of them.
1403
+ function parseBand(flags) {
1404
+ if (flags['no-tolerance']) return null
1405
+ if (flags['min-quality'] === undefined) return undefined
1406
+ const min = flags['min-quality'] === true ? NaN : Number(flags['min-quality'])
1407
+ if (!Number.isFinite(min)) die('--min-quality requires a number')
1408
+ return { ...TOLERANCE_BAND, minQualityToReport: min }
1409
+ }
1410
+
1411
+ function num(flags, key) {
1412
+ if (flags[key] === undefined) return undefined
1413
+ const n = flags[key] === true ? NaN : Number(flags[key])
1414
+ if (!Number.isFinite(n)) die(`--${key} requires a number`)
1415
+ return n
1416
+ }
1417
+
1418
+ // The plan mode override, resolved for the planner. Absent → undefined (the
1419
+ // classifier's recommendation decides inside the envelope). A single value is
1420
+ // passed through as a string so `decideMode` can accept or reject it; two
1421
+ // contradictory flags are passed through as the array they arrived as, because
1422
+ // resolving them here would hide the contradiction the planner is meant to name.
1423
+ function modeFlag(flags) {
1424
+ const m = flags.mode
1425
+ if (m === undefined) return undefined
1426
+ const values = Array.isArray(m) ? m : [m]
1427
+ if (values.some(v => v === true)) die('--mode requires a value (solo|waves)')
1428
+ const distinct = [...new Set(values)]
1429
+ return distinct.length === 1 ? distinct[0] : distinct
1430
+ }
1431
+
1432
+ // `--tdd` / `--no-tdd`, resolved the way `modeFlag` resolves `--mode`: both at
1433
+ // once is refused rather than settled last-wins, because the two produce
1434
+ // different wave orders and different verifications, and picking one silently
1435
+ // would run the shape the operator did not ask for.
1436
+ function tddFlag(flags) {
1437
+ const on = flags.tdd === true || flags.tdd !== undefined
1438
+ const off = flags['no-tdd'] === true || flags['no-tdd'] !== undefined
1439
+ if (on && off) {
1440
+ die('contradictory task-shape flags: --tdd and --no-tdd were both passed — pass exactly one, or neither')
1441
+ }
1442
+ if (on) return 'tdd'
1443
+ if (off) return 'no-tdd'
1444
+ return null
1445
+ }
1446
+
1447
+ // The red wave for a bare `interlock waves` call. `--red-wave <n>` is the
1448
+ // explicit form; `--tdd` derives it from the classification — the lowest group
1449
+ // whose tasks are all test tasks — which is all this subcommand can see. The
1450
+ // planner adjudicates whatever comes back and refuses a claim the tasks
1451
+ // contradict, so nothing here has to validate the answer.
1452
+ function redWaveFlag(flags, input) {
1453
+ if (tddFlag(flags) === 'no-tdd') return undefined
1454
+ if (flags['red-wave'] !== undefined) {
1455
+ const n = Number(flags['red-wave'])
1456
+ if (!Number.isInteger(n)) die('--red-wave requires an integer section number')
1457
+ return n
1458
+ }
1459
+ if (tddFlag(flags) !== 'tdd') return undefined
1460
+ const tasks = Array.isArray(input && input.tasks) ? input.tasks : []
1461
+ const groups = [...new Set(tasks.map(t => t && t.group).filter(Number.isInteger))].sort(
1462
+ (a, b) => a - b
1463
+ )
1464
+ const lowest = groups[0]
1465
+ if (lowest === undefined) return undefined
1466
+ const inLowest = tasks.filter(t => t && t.group === lowest)
1467
+ return inLowest.length && inLowest.every(t => t.isTestTask === true) ? lowest : undefined
1468
+ }
1469
+
1470
+ function requestedName(positional, flags) {
1471
+ if (typeof flags.change === 'string' && flags.change.trim()) return flags.change.trim()
1472
+ return positional[1]
1473
+ }
1474
+
1475
+ // One resolution path for every command that names a change, so "which change?"
1476
+ // fails the same actionable way each time.
1477
+ function requireChange(root, requested, json) {
1478
+ const resolved = resolveChange(root, requested)
1479
+ if (!resolved.error) return resolved.name
1480
+ if (json) {
1481
+ emit(true, resolved, '')
1482
+ process.exit(1)
1483
+ }
1484
+ die(
1485
+ `${resolved.error}${
1486
+ resolved.candidates.length ? `\n candidates: ${resolved.candidates.join(', ')}` : ''
1487
+ }`
1488
+ )
1489
+ }
1490
+
1491
+ // risk.mjs is pure and takes artifact TEXT, so the filesystem read belongs here.
1492
+ function readChangeArtifacts(root, change) {
1493
+ const info = inspectChange(root, change)
1494
+ const artifacts = {}
1495
+ for (const name of info.present) {
1496
+ artifacts[name] = readFileSync(join(info.path, name), 'utf8')
1497
+ }
1498
+ for (const rel of info.specFiles) {
1499
+ const p = join(info.path, 'specs', rel)
1500
+ if (existsSync(p)) artifacts[join('specs', rel)] = readFileSync(p, 'utf8')
1501
+ }
1502
+ return artifacts
1503
+ }
1504
+
1505
+ function splitPaths(value) {
1506
+ if (value === undefined || value === true) return []
1507
+ const raw = value === '-' ? readStdin().split('\n') : String(value).split(',')
1508
+ return raw.map(p => p.trim()).filter(Boolean)
1509
+ }
1510
+
1511
+ async function main() {
1512
+ const argv = process.argv.slice(2)
1513
+ if (!argv.length || argv[0] === '-h' || argv[0] === '--help') {
1514
+ process.stdout.write(USAGE)
1515
+ return
1516
+ }
1517
+
1518
+ const { _: positional, flags } = parseArgs(argv)
1519
+ const cmd = positional[0]
1520
+ const json = Boolean(flags.json)
1521
+ const root = typeof flags.root === 'string' ? flags.root : '.'
1522
+
1523
+ switch (cmd) {
1524
+ case 'waves': {
1525
+ const input = readInput(flags.classified, '--classified')
1526
+ const maxParallel = flags['max-parallel'] ? Number(flags['max-parallel']) : undefined
1527
+ // This subcommand sees only the classified input — it has no change name
1528
+ // and therefore no tasks.md to read the section marker from. So the red
1529
+ // wave is either named outright (`--red-wave`) or derived from the
1530
+ // classification's own structure (`--tdd`). A run resolves it from the
1531
+ // marker instead; see `resolveRedWave` in lib/run.mjs.
1532
+ const plan = planWaves(input, {
1533
+ maxParallel,
1534
+ mode: modeFlag(flags),
1535
+ redWave: redWaveFlag(flags, input)
1536
+ })
1537
+ emit(json, plan, formatPlan(plan))
1538
+ break
1539
+ }
1540
+
1541
+ case 'surface': {
1542
+ let changed = flags.changed
1543
+ if (changed === '-' || (Array.isArray(changed) && changed[0] === '-')) {
1544
+ changed = readStdin().split('\n')
1545
+ }
1546
+ if (!Array.isArray(changed)) changed = changed ? [changed] : []
1547
+ const result = classifySurface(changed)
1548
+ const text =
1549
+ result.files.map(f => `${f.class}\t${f.path}\t(${f.reason})`).join('\n') +
1550
+ (result.files.length ? '\n' : '') +
1551
+ `\nui=${result.uiCount} indirect=${result.indirectCount} backend=${result.backendCount}\n` +
1552
+ `needsManualTestPlan=${result.needsManualTestPlan} needsDevopsReview=${result.needsDevopsReview}\n`
1553
+ emit(json, result, text)
1554
+ break
1555
+ }
1556
+
1557
+ case 'gate': {
1558
+ const input = readInput(flags.findings, '--findings')
1559
+ const dismissed =
1560
+ typeof flags.dismissed === 'string'
1561
+ ? flags.dismissed.split(',').map(s => s.trim()).filter(Boolean)
1562
+ : []
1563
+ // The band is on by default and applied inside evaluateGate; these flags
1564
+ // only tune or disable it.
1565
+ const band = parseBand(flags)
1566
+ const result = evaluateGate(input, { dismissed, band })
1567
+
1568
+ // Metrics are bookkeeping, exactly as in `review`: they report their own
1569
+ // failure and never touch the verdict or the exit code, so a read-only
1570
+ // checkout cannot fail a gate.
1571
+ //
1572
+ // `evaluateGate().counts` is a severity histogram (blocker/warning/
1573
+ // suggestion), NOT the four-way partition `writeReviewMetrics` records,
1574
+ // so it cannot be handed over the way `resolveReview().counts` can. The
1575
+ // four figures are derived here from the ones that produced the verdict —
1576
+ // arithmetic over the returned counts, never a second reading of the
1577
+ // input, which is what `review/metrics-emission` requires. A finding the
1578
+ // gate refused to interpret still reached the verdict (it blocks), so
1579
+ // `malformed` counts as surviving rather than vanishing from both sides.
1580
+ let metrics = null
1581
+ if (flags.metrics !== undefined) {
1582
+ if (flags.metrics === true) die('--metrics requires a change name')
1583
+ const surviving = result.total + result.malformed.length
1584
+ metrics = writeReviewMetrics(root, {
1585
+ change: String(flags.metrics),
1586
+ counts: {
1587
+ raised: result.dismissedCount + result.droppedByQuality + surviving,
1588
+ dismissed: result.dismissedCount,
1589
+ droppedByQuality: result.droppedByQuality,
1590
+ surviving
1591
+ }
1592
+ })
1593
+ if (!metrics.written) warn(`metrics not written: ${metrics.reason}`)
1594
+ }
1595
+
1596
+ const text =
1597
+ formatGate(result) +
1598
+ (metrics ? ` metrics: ${metrics.written ? metrics.path : `not written (${metrics.reason})`}\n` : '')
1599
+ emit(json, { ...result, ...(metrics ? { metrics } : {}) }, text)
1600
+ if (!result.passed) process.exit(1)
1601
+ break
1602
+ }
1603
+
1604
+ case 'validate': {
1605
+ const name = requireChange(root, requestedName(positional, flags), json)
1606
+ const result = inspectChange(root, name)
1607
+ emit(json, result, formatInspection(result))
1608
+ if (!result.ready) process.exit(1)
1609
+ break
1610
+ }
1611
+
1612
+ case 'changes': {
1613
+ const changes = listChanges(root)
1614
+ emit(json, { changes }, changes.length ? changes.join('\n') + '\n' : '(none)\n')
1615
+ break
1616
+ }
1617
+
1618
+ case 'drift': {
1619
+ // No exit(1) here, ever. Unlike every other gating subcommand, drift
1620
+ // reports and does not block — see the header of lib/drift.mjs for why.
1621
+ //
1622
+ // `--changed` absent and `--changed` empty are different: absent means the
1623
+ // caller has no diff and orphan detection cannot run, empty means the diff
1624
+ // was genuinely empty. Only the first is "not checked".
1625
+ const changed = flags.changed === undefined ? null : splitPaths(flags.changed)
1626
+ const report = analyzeDrift(root, changed ? { changed } : {})
1627
+ emit(json, report, formatDrift(report) + '\n')
1628
+ break
1629
+ }
1630
+
1631
+ case 'report': {
1632
+ // No exit(1) here, ever — and unlike `drift`, not because a signal is
1633
+ // inferred but because §4.15(a) forbids the comparison a non-zero status
1634
+ // would encode. Every value here is one `if` away from a threshold; the
1635
+ // absence of that `if` is the feature. See the header of lib/report.mjs.
1636
+ const report = buildReport(root, {
1637
+ since: typeof flags.since === 'string' ? flags.since : undefined,
1638
+ change: typeof flags.change === 'string' ? flags.change : undefined,
1639
+ maxRuns: num(flags, 'max-runs')
1640
+ })
1641
+
1642
+ if (flags.html !== undefined) {
1643
+ // Refused rather than resolved by precedence. `emit` threads a single
1644
+ // `json` flag, so a rule about which surface wins would be invisible at
1645
+ // the call site and unlike every other subcommand here.
1646
+ if (json) die('--html and --json are mutually exclusive: choose one surface')
1647
+
1648
+ const document = renderReportHtml(report)
1649
+
1650
+ // Bare --html goes to stdout, so the document composes with a shell
1651
+ // redirect and no invocation writes to disk unasked. A named path is
1652
+ // the only thing that makes this command a writer.
1653
+ if (flags.html === true) {
1654
+ process.stdout.write(document)
1655
+ break
1656
+ }
1657
+
1658
+ const dest = String(flags.html)
1659
+ if (!dest.trim()) die('--html <path> requires a file path')
1660
+ // Written beside the destination and renamed into place, so a failure
1661
+ // partway through leaves nothing at the path the caller named — an
1662
+ // aborted render must not look like a report that says less than it
1663
+ // should. `rename` within a directory is atomic.
1664
+ const staging = `${dest}.interlock-partial`
1665
+ try {
1666
+ writeFileSync(staging, document)
1667
+ renameSync(staging, dest)
1668
+ } catch (err) {
1669
+ try {
1670
+ rmSync(staging, { force: true })
1671
+ } catch {
1672
+ // The cleanup of a file that may never have existed cannot itself
1673
+ // be the reported failure.
1674
+ }
1675
+ die(`could not write ${dest}: ${err && err.message}`)
1676
+ }
1677
+ process.stdout.write(`report written to ${dest}\n`)
1678
+ break
1679
+ }
1680
+
1681
+ emit(json, report, formatReport(report))
1682
+ break
1683
+ }
1684
+
1685
+ case 'conformance': {
1686
+ // Emits questions, never verdicts, and never exits non-zero — see the
1687
+ // header of lib/conformance.mjs.
1688
+ const name = typeof flags.change === 'string' ? flags.change : positional[1]
1689
+ const changed = flags.changed === undefined ? [] : splitPaths(flags.changed)
1690
+ const result = buildChecklist(root, name, { changed })
1691
+ emit(json, result, formatChecklist(result) + '\n')
1692
+ break
1693
+ }
1694
+
1695
+ case 'limits': {
1696
+ // `laneCaps` and `solo` are their OWN top-level groups, not entries of
1697
+ // `limits`: LIMITS holds positive-integer loop counts (its invariant test
1698
+ // asserts it), whereas the lane-cap table is a tier→cap map and the solo
1699
+ // envelope is a separate object — the same reason `effort` is emitted apart.
1700
+ emit(
1701
+ json,
1702
+ {
1703
+ limits: LIMITS,
1704
+ runtime: RUNTIME,
1705
+ evals: EVAL_CAPS,
1706
+ // Its own group, beside `evals` rather than inside it: the eval caps
1707
+ // are scalars a reader compares a running total against, and this is
1708
+ // the table that produced the total. Its `id` is what a recorded
1709
+ // result row is keyed against.
1710
+ prices: MODEL_PRICES,
1711
+ report: REPORT_CAPS,
1712
+ effort: EFFORT,
1713
+ laneCaps: LANE_CAPS,
1714
+ solo: SOLO
1715
+ },
1716
+ formatLimits()
1717
+ )
1718
+ break
1719
+ }
1720
+
1721
+ case 'notify': {
1722
+ // The CLI's ONE outbound request, and the only place a topic is ever
1723
+ // read (design D1, D2). Unconfigured is not a failure: a skill or a
1724
+ // driver calls this unconditionally, and an operator who never set a
1725
+ // topic gets a line saying so and exit 0, never a halted loop.
1726
+ const config = readNotifyConfig(process.env)
1727
+ // Order matters: a malformed topic yields `{invalid}` with no `topic`
1728
+ // key at all, and reporting that as "not configured" would tell an
1729
+ // operator who DID configure it that they had not.
1730
+ if (!config.invalid && !config.topic) {
1731
+ const notConfigured = { sent: false, reason: `not configured — set ${NOTIFY_TOPIC_ENV}` }
1732
+ emit(json, notConfigured, `push: not configured — set ${NOTIFY_TOPIC_ENV}\n`)
1733
+ break
1734
+ }
1735
+
1736
+ let message
1737
+ if (positional[1] === 'checkpoint') {
1738
+ const change = typeof positional[2] === 'string' ? positional[2].trim() : ''
1739
+ if (!change) die('interlock notify checkpoint <change> — the change name is required')
1740
+ message = composeCheckpointMessage(change)
1741
+ } else {
1742
+ const title = typeof flags.title === 'string' ? flags.title.trim() : ''
1743
+ if (!title) die('--title <text> is required (or use: interlock notify checkpoint <change>)')
1744
+ const priority = flags.priority === 'high' ? 'high' : 'default'
1745
+ message = { title, body: typeof flags.body === 'string' ? flags.body : '', priority }
1746
+ }
1747
+
1748
+ if (config.invalid) {
1749
+ const bad = { sent: false, reason: `invalid configuration: ${config.invalid}` }
1750
+ emit(json, bad, `push: failed — ${bad.reason}\n`)
1751
+ process.exit(1)
1752
+ }
1753
+
1754
+ const result = await postNtfy({ url: config.url, topic: config.topic, ...message })
1755
+ emit(json, result, result.sent ? 'push: sent (ntfy)\n' : `push: failed — ${result.reason}\n`)
1756
+ // A push nobody asked about is advisory; a push someone invoked directly
1757
+ // is a command, and a command that did not do its job exits non-zero.
1758
+ if (!result.sent) process.exit(1)
1759
+ break
1760
+ }
1761
+
1762
+ case 'merge-lanes': {
1763
+ const base = typeof flags.base === 'string' ? flags.base.trim() : ''
1764
+ if (!base) die('--base <commit> is required')
1765
+ const input = readInput(flags.lanes, '--lanes <file|->')
1766
+ const laneEntries = Array.isArray(input) ? input : Array.isArray(input && input.lanes) ? input.lanes : null
1767
+ if (!laneEntries) die('--lanes must be a JSON array of { label, worktreePath, reportedFiles }')
1768
+ const result = runMergeLanes(root, laneEntries, base)
1769
+ emit(json, result, formatMergeLanes(result))
1770
+ // Exit code is the whole contract here: 0 is a clean fold, non-zero is a
1771
+ // halt (collision or an unresolved lane) — distinct so the workflow can
1772
+ // branch on exit status the same way every other gating command does.
1773
+ if (result.status !== 'clean') process.exit(1)
1774
+ break
1775
+ }
1776
+
1777
+ case 'doctor': {
1778
+ // Read-only by construction — see the header of lib/doctor.mjs. The exit
1779
+ // code is the whole point: a preflight that only prints is a preflight a
1780
+ // zero-touch run walks straight past.
1781
+ const report = diagnose(root)
1782
+ emit(json, report, formatDoctor(report))
1783
+ if (!report.ok) process.exit(1)
1784
+ break
1785
+ }
1786
+
1787
+ case 'evals': {
1788
+ const sub = positional[1]
1789
+ if (sub === 'triage') {
1790
+ const results = readInput(flags.results, '--results')
1791
+ const verdict = triage(results)
1792
+ emit(json, verdict, formatTriage(verdict))
1793
+ if (verdict.exitCode) process.exit(verdict.exitCode)
1794
+ } else if (sub === 'capture') {
1795
+ const runId = typeof flags.run === 'string' ? flags.run : ''
1796
+ if (!runId) die('evals capture requires --run <runId>')
1797
+ const outDir = typeof flags.out === 'string' ? flags.out : ''
1798
+ if (!outDir) die('evals capture requires --out <dir>')
1799
+ const result = captureCase(root, {
1800
+ runId,
1801
+ taskId: typeof flags.task === 'string' ? flags.task : undefined,
1802
+ outDir
1803
+ })
1804
+ emit(json, result, formatCapture(result))
1805
+ // A refusal is the contract, so it is an exit code rather than a
1806
+ // sentence: a run nobody can replay, an output directory inside the
1807
+ // suite, or one that already holds a case. Nothing was written on any
1808
+ // of the three.
1809
+ if (!result.ok) process.exit(1)
1810
+ } else if (sub === 'calibrate') {
1811
+ // Half the input is repository state (the committed human labels) and
1812
+ // half is runtime state (the judge's recorded votes), which is why this
1813
+ // is a command and the calibration PRESENCE check is a test.
1814
+ //
1815
+ // It issues no verdict and always exits 0, whatever the agreement says.
1816
+ // The floor is EVAL_CAPS.judgeAgreementFloor and only `evals promote`
1817
+ // applies it.
1818
+ const results = readInput(flags.results, '--results')
1819
+ const labelsDir = typeof flags.labels === 'string' ? flags.labels : join(root, 'evals')
1820
+ const { labels, problems } = readLabels(labelsDir)
1821
+ const report = calibrate({ labels, results })
1822
+ emit(json, { ...report, problems }, formatCalibration(report, problems))
1823
+ } else if (sub === 'promote') {
1824
+ const dir = typeof flags.history === 'string' ? flags.history : ''
1825
+ if (!dir) die('evals promote requires --history <dir>')
1826
+ // Every entry is carried into the decision, readable or not. An
1827
+ // unreadable file is passed through as `{ file, error }` so the module
1828
+ // can name it and state that it was excluded — dropping it here would
1829
+ // silently narrow the window, which is the one thing the promotion spec
1830
+ // forbids about this path.
1831
+ const entries = []
1832
+ if (existsSync(dir)) {
1833
+ for (const name of readdirSync(dir).filter(f => f.endsWith('.json')).sort()) {
1834
+ const file = join(dir, name)
1835
+ try {
1836
+ entries.push({ file: name, record: JSON.parse(readFileSync(file, 'utf8')) })
1837
+ } catch (err) {
1838
+ entries.push({ file: name, error: `unparseable (${err && err.message})` })
1839
+ }
1840
+ }
1841
+ }
1842
+ // Agreement is optional input: without it, a judged case is refused for
1843
+ // being unmeasured rather than promoted on run history alone.
1844
+ const agreement =
1845
+ typeof flags.agreement === 'string' ? readInput(flags.agreement, '--agreement') : {}
1846
+ const verdict = promote({ history: entries, agreement })
1847
+ emit(json, verdict, formatPromotion(verdict))
1848
+ if (verdict.exitCode) process.exit(verdict.exitCode)
1849
+ } else {
1850
+ die(`unknown evals subcommand: ${sub || '(none)'} — expected triage|capture|calibrate|promote`)
1851
+ }
1852
+ break
1853
+ }
1854
+
1855
+ case 'paths': {
1856
+ // The two deterministic reads behind the receipt's path sets. Neither
1857
+ // ever exits non-zero: a host calls these on its way out, and a failed
1858
+ // read must leave the receipt unobserved rather than fail the close. The
1859
+ // reason is printed and carried in the payload.
1860
+ const sub = positional[1]
1861
+ if (sub === 'touched') {
1862
+ const commit = typeof flags.commit === 'string' ? flags.commit : positional[2]
1863
+ const result = readTouchedPaths(root, commit)
1864
+ emit(json, result, formatPathSet('paths touched', result))
1865
+ if (!result.observed && !json) process.stderr.write(`interlock: ${result.reason}\n`)
1866
+ } else if (sub === 'predicted') {
1867
+ const result = readPredictedPaths(root, {
1868
+ planPath: typeof flags.plan === 'string' ? flags.plan : undefined
1869
+ })
1870
+ emit(json, result, formatPathSet('paths predicted', result))
1871
+ if (!result.observed && !json) process.stderr.write(`interlock: ${result.reason}\n`)
1872
+ } else {
1873
+ die(`unknown paths subcommand: ${sub || '(none)'} — expected touched|predicted`)
1874
+ }
1875
+ break
1876
+ }
1877
+
1878
+ case 'remediate': {
1879
+ const input = readInput(flags.findings, '--findings')
1880
+ const round = num(flags, 'round')
1881
+ const plan = planRemediation(input, { round, band: parseBand(flags) })
1882
+ emit(json, plan, formatRemediationPlan(plan))
1883
+ if (plan.halt) process.exit(1)
1884
+ break
1885
+ }
1886
+
1887
+ case 'review': {
1888
+ const findings = readInput(flags.findings, '--findings')
1889
+ const verdicts = readInput(flags.verdicts, '--verdicts')
1890
+ // The reviewed diff. A dismissing verdict has to cite a path that is in
1891
+ // it — shape alone is satisfied by inventing `lib/nowhere.ts:1`. Optional
1892
+ // because a caller that cannot name the changed files must still get the
1893
+ // shape half rather than no gate at all.
1894
+ const changedPaths = splitPaths(flags.changed)
1895
+
1896
+ // The repo-root REVIEW.md, if any. Its do-not-report paths are enforced
1897
+ // here as a survival filter — never handed to a model as a request. Its
1898
+ // problems (a malformed file, a band-like key) are reported alongside the
1899
+ // review, so a broken policy is diagnosable rather than silent.
1900
+ const policy = readReviewPolicy(root)
1901
+
1902
+ const result = resolveReview(findings, verdicts, {
1903
+ band: parseBand(flags),
1904
+ changedPaths,
1905
+ excludePaths: policy.excludePaths
1906
+ })
1907
+
1908
+ // Metrics are bookkeeping: they report their own failure and never touch
1909
+ // the exit code, so a read-only checkout cannot fail a review.
1910
+ let metrics = null
1911
+ if (flags.metrics !== undefined) {
1912
+ if (flags.metrics === true) die('--metrics requires a change name')
1913
+ metrics = writeReviewMetrics(root, { change: String(flags.metrics), counts: result.counts })
1914
+ if (!metrics.written) warn(`metrics not written: ${metrics.reason}`)
1915
+ }
1916
+
1917
+ const policyProblems = policy.problems.length
1918
+ ? ` policy: ${policy.problems.length} problem(s) in REVIEW.md\n` +
1919
+ policy.problems.map(p => ` - ${p}`).join('\n') + '\n'
1920
+ : ''
1921
+ const text =
1922
+ formatReview(result) +
1923
+ policyProblems +
1924
+ (metrics ? ` metrics: ${metrics.written ? metrics.path : `not written (${metrics.reason})`}\n` : '')
1925
+ emit(json, { ...result, policyProblems: policy.problems, ...(metrics ? { metrics } : {}) }, text)
1926
+ break
1927
+ }
1928
+
1929
+ case 'review-policy': {
1930
+ // Read-only view of the parsed REVIEW.md, so the workflow can inject the
1931
+ // prose + owner into the reviewer instructions without re-parsing the file
1932
+ // (the runtime cannot import lib modules). Always exits 0: a missing or
1933
+ // malformed policy is reported, never fatal.
1934
+ const policy = readReviewPolicy(root)
1935
+ emit(json, policy, formatReviewPolicy(policy))
1936
+ break
1937
+ }
1938
+
1939
+ case 'risk': {
1940
+ const requested = typeof flags.change === 'string' ? flags.change : positional[1]
1941
+ const changedPaths = splitPaths(flags.paths)
1942
+
1943
+ let change = null
1944
+ let artifacts = {}
1945
+ const resolved = resolveChange(root, requested)
1946
+ if (resolved.error) {
1947
+ // Paths alone are still an honest basis; without them there is nothing
1948
+ // to classify, so the unresolved change is the error.
1949
+ if (!changedPaths.length) {
1950
+ requireChange(root, requested, json)
1951
+ }
1952
+ warn(`${resolved.error} — classifying from --paths alone, with no artifact text`)
1953
+ } else {
1954
+ change = resolved.name
1955
+ artifacts = readChangeArtifacts(root, change)
1956
+ }
1957
+
1958
+ const result = classifyRisk({ changedPaths, artifacts })
1959
+ emit(json, { change, ...result }, formatRisk(result))
1960
+ break
1961
+ }
1962
+
1963
+ case 'ledger': {
1964
+ const name = requireChange(root, requestedName(positional, flags), json)
1965
+ const parsed = readLedger(root, name)
1966
+ const summary = summarizeLedger(parsed)
1967
+ // `summary.invalid` is a count, so the rows behind it get their own key
1968
+ // rather than shadowing it.
1969
+ emit(
1970
+ json,
1971
+ { change: name, path: parsed.path, ...summary, rows: parsed.rows, invalidRows: parsed.invalid },
1972
+ formatLedger(parsed)
1973
+ )
1974
+ // A missing ledger exits non-zero and says *missing*, not *empty*. The
1975
+ // audit's likeliest failure is that it was never written, and an empty
1976
+ // result reads as "nothing needs a human" — which is the one conclusion
1977
+ // an absent file cannot support. `summary.missing` / `summary.unparseable`
1978
+ // keep the three cases distinguishable in the JSON.
1979
+ if (summary.blocking) process.exit(1)
1980
+ break
1981
+ }
1982
+
1983
+ case 'ready': {
1984
+ const name = requireChange(root, requestedName(positional, flags), json)
1985
+ // Read RAW, not parsed: an unreadable findings file must reach the gate
1986
+ // as "the review did not run" and become a blocker with a reason, rather
1987
+ // than dying in the argument parser with a message nobody records.
1988
+ const findings = flags.findings ? readRawInput(flags.findings, '--findings') : undefined
1989
+ const review = flags.review ? readInput(flags.review, '--review') : undefined
1990
+ if (review !== undefined && findings === undefined) {
1991
+ warn(
1992
+ '--review is deprecated and no longer satisfies the review-blockers check on its own: ' +
1993
+ 'its provenance is the agent being gated. Pass the artifact review\'s own findings ' +
1994
+ 'file to --findings instead.'
1995
+ )
1996
+ }
1997
+ const paths =
1998
+ typeof flags.paths === 'string'
1999
+ ? (flags.paths === '-' ? readStdin() : flags.paths)
2000
+ .split(flags.paths === '-' ? '\n' : ',')
2001
+ .map(s => s.trim())
2002
+ .filter(Boolean)
2003
+ : undefined
2004
+ const result = assessReadiness(root, name, { findings, review, changedPaths: paths })
2005
+ emit(json, result, formatReadiness(result))
2006
+ // Fail closed: not-ready is a non-zero exit, so a caller that forgets to
2007
+ // read the JSON still cannot walk past the gate.
2008
+ if (!result.ready) process.exit(1)
2009
+ break
2010
+ }
2011
+
2012
+ case 'outcomes': {
2013
+ const sub = positional[1]
2014
+ if (sub === 'append') {
2015
+ const record = readInput(flags.record, '--record <file|->')
2016
+ // The record file is composed by an agent on the workflow host, so the
2017
+ // observed half is never taken from it: it is derived here from the
2018
+ // run's own receipt. A record file that carries one is refused by name
2019
+ // rather than passed through — the prompt is documentation and the
2020
+ // writer is the mechanism.
2021
+ const supplied =
2022
+ record && typeof record === 'object' && !Array.isArray(record) ? record : {}
2023
+ const carriedObserved = Object.prototype.hasOwnProperty.call(supplied, 'observed')
2024
+ const { observed: _refused, ...reportedHalf } = supplied
2025
+ const runId = runIdFromFlags(flags)
2026
+ // No run identity, or no receipt on the trajectory, records the
2027
+ // absence. It deliberately does not fall back to the record file:
2028
+ // trusting the untrusted source exactly when the trusted one is
2029
+ // missing would defeat the partition on the runs most likely to need
2030
+ // it.
2031
+ const receipt = runId
2032
+ ? queryRunLog(root, runId, { type: 'run-receipt' }).records.pop() || null
2033
+ : null
2034
+ const written = appendOutcome(root, { ...reportedHalf, observed: observedFromReceipt(receipt) })
2035
+ if (carriedObserved) {
2036
+ warn(
2037
+ 'outcome record carried an "observed" group: refused. Observed values are derived ' +
2038
+ 'from the run receipt, never supplied by the step that writes the record.'
2039
+ )
2040
+ }
2041
+ if (!written.written) {
2042
+ // Never fatal: losing a corpus line must not fail the run that
2043
+ // produced it. Say so on stderr and exit 0.
2044
+ process.stderr.write(`interlock: outcome not recorded: ${written.reason}\n`)
2045
+ } else if (written.reason) {
2046
+ // Written, but partly refused. Reported for the same reason: a
2047
+ // closing step whose payload was refused should be able to say so.
2048
+ warn(`outcome recorded with a refusal: ${written.reason}`)
2049
+ }
2050
+ emit(
2051
+ json,
2052
+ { ...written, observedRefused: carriedObserved, receipt: Boolean(receipt) },
2053
+ `${written.written ? `recorded → ${written.path}` : `not recorded: ${written.reason}`}\n`
2054
+ )
2055
+ } else if (sub === 'list' || sub === undefined) {
2056
+ const result = readOutcomes(root)
2057
+ emit(json, result, formatOutcomes(result))
2058
+ } else {
2059
+ die(`unknown outcomes subcommand: ${sub}`)
2060
+ }
2061
+ break
2062
+ }
2063
+
2064
+ case 'run-log': {
2065
+ const sub = positional[1]
2066
+ if (sub === 'append') {
2067
+ const record = readInput(flags.event, '--event <file|->')
2068
+ const { written, path, reason } = appendRunLogEvent(root, record)
2069
+ if (!written) {
2070
+ // Never fatal: losing a trajectory line must not fail the run that
2071
+ // produced it. Say so on stderr and exit 0.
2072
+ process.stderr.write(`interlock: event not recorded: ${reason}\n`)
2073
+ }
2074
+ emit(json, { written, path, reason }, `${written ? `recorded → ${path}` : `not recorded: ${reason}`}\n`)
2075
+ } else if (sub === 'list') {
2076
+ const change = typeof flags.change === 'string' ? flags.change : undefined
2077
+ const runs = listRunLogs(root, { change })
2078
+ emit(json, { runs }, formatRunLogList(runs))
2079
+ } else if (sub === 'show') {
2080
+ const runId = positional[2]
2081
+ if (!runId) die('run-log show requires a runId')
2082
+ const result = readRunLog(root, runId)
2083
+ emit(json, result, formatRunLog(result))
2084
+ } else if (sub === 'query') {
2085
+ const runId = typeof flags.run === 'string' ? flags.run : undefined
2086
+ if (!runId) die('--run is required')
2087
+ const result = queryRunLog(root, runId, {
2088
+ type: typeof flags.type === 'string' ? flags.type : undefined,
2089
+ halted: flags.halted === true
2090
+ })
2091
+ emit(json, result, formatRunLog(result))
2092
+ } else if (sub === 'check') {
2093
+ const runId = runIdFromFlags(flags)
2094
+ if (!runId) die('run-log check requires --state <file> (with a runId) or --run-id <id>')
2095
+ const result = checkRunLog(root, runId)
2096
+ emit(json, result, formatRunLogCheck(result))
2097
+ if (!result.ok) process.exit(1)
2098
+ } else {
2099
+ die(`unknown run-log subcommand: ${sub || '(none)'} — expected append|list|show|query|check`)
2100
+ }
2101
+ break
2102
+ }
2103
+
2104
+ case 'tasks': {
2105
+ const sub = positional[1]
2106
+ if (sub === 'tick') {
2107
+ const name = requireChange(root, requestedName(positional, flags), json)
2108
+ const ids = splitPaths(flags.ids)
2109
+ if (!ids.length) die('--ids is required')
2110
+ const result = tickTasks(root, name, ids)
2111
+ const text =
2112
+ `ticked ${result.ticked.length}` +
2113
+ (result.already.length ? `, already ${result.already.length}` : '') +
2114
+ (result.missing.length ? `, missing ${result.missing.join(', ')}` : '') +
2115
+ `\n`
2116
+ emit(json, result, text)
2117
+ // An id that could not be marked exits non-zero and names itself. A
2118
+ // completed task left silently unchecked reads downstream as a failed
2119
+ // task, so the caller has to be able to see the difference — and an
2120
+ // empty id set is refused above, which keeps "nothing to mark" distinct
2121
+ // from "marking failed".
2122
+ if (result.missing.length) {
2123
+ process.stderr.write(
2124
+ `interlock: could not mark ${result.missing.length} task id(s) complete in ` +
2125
+ `${result.path}: ${result.missing.join(', ')} — no matching checkbox\n`
2126
+ )
2127
+ process.exit(1)
2128
+ }
2129
+ } else if (sub === 'coverage') {
2130
+ const name = requireChange(root, requestedName(positional, flags), json)
2131
+ const classified = readInput(flags.classified, '--classified')
2132
+ const inspection = inspectChange(root, name)
2133
+ const listed = Array.isArray(classified)
2134
+ ? classified
2135
+ : Array.isArray(classified.tasks)
2136
+ ? classified.tasks
2137
+ : []
2138
+ const result = { change: name, ...planCoverage(inspection.tasks.items, listed) }
2139
+ const text = result.ok
2140
+ ? `COVERAGE OK — ${result.classified} classified, ${result.remaining} remaining\n`
2141
+ : `COVERAGE GAP — omitted: ${result.omitted.join(', ')}\n`
2142
+ emit(json, result, text)
2143
+ if (!result.ok) process.exit(1)
2144
+ } else {
2145
+ die(`unknown tasks subcommand: ${sub || '(none)'} — expected tick|coverage`)
2146
+ }
2147
+ break
2148
+ }
2149
+
2150
+ case 'verify': {
2151
+ const sub = positional[1]
2152
+ if (sub === 'plan') {
2153
+ const profile = flags['no-profile']
2154
+ ? null
2155
+ : readInput(flags.profile, '--profile <file|-> (or --no-profile)')
2156
+ const opts = {
2157
+ e2e: flags.e2e === true,
2158
+ coverage: flags['no-coverage'] !== true,
2159
+ lint: flags['no-lint'] !== true,
2160
+ typecheckCommand:
2161
+ typeof flags['typecheck-command'] === 'string' ? flags['typecheck-command'] : null,
2162
+ lintCommand: typeof flags['lint-command'] === 'string' ? flags['lint-command'] : null,
2163
+ preExistingFailures: flags['pre-existing'] === true
2164
+ }
2165
+ const elapsedMs = num(flags, 'elapsed-ms')
2166
+ if (elapsedMs !== undefined) opts.elapsedMs = elapsedMs
2167
+ const budgetMs = num(flags, 'budget-ms')
2168
+ if (budgetMs !== undefined) opts.budgetMs = budgetMs
2169
+ const context = flags.context === undefined ? undefined : String(flags.context)
2170
+ if (context !== undefined) {
2171
+ if (!Object.prototype.hasOwnProperty.call(VERIFY_CONTEXTS, context)) {
2172
+ die(
2173
+ `--context must be one of ${Object.keys(VERIFY_CONTEXTS).join('|')} (got "${context}")`
2174
+ )
2175
+ }
2176
+ opts.context = context
2177
+ if (context === 'inter-wave') opts.coverage = false
2178
+ }
2179
+ if (flags.changed !== undefined) {
2180
+ opts.changed = Array.isArray(flags.changed) ? flags.changed : splitPaths(flags.changed)
2181
+ }
2182
+
2183
+ const plan = planVerification(profile, opts)
2184
+ emit(json, plan, formatVerification(plan))
2185
+ } else if (sub === 'judge') {
2186
+ const plan = readInput(flags.plan, '--plan <file|->')
2187
+ if (!plan || !Array.isArray(plan.steps) || !Array.isArray(plan.skipped)) {
2188
+ die('--plan must be the JSON output of `interlock verify plan --json`')
2189
+ }
2190
+ const results = readInput(flags.results, '--results <file|->')
2191
+ if (!Array.isArray(results)) {
2192
+ die('--results must be a JSON array of { kind, exitCode, ... } objects')
2193
+ }
2194
+ const context = flags.context === undefined ? 'final' : String(flags.context)
2195
+ if (!Object.prototype.hasOwnProperty.call(VERIFY_CONTEXTS, context)) {
2196
+ die(
2197
+ `--context must be one of ${Object.keys(VERIFY_CONTEXTS).join('|')} (got "${context}")`
2198
+ )
2199
+ }
2200
+ const rejection = rejectOversized(results, context)
2201
+ if (rejection) {
2202
+ emit(json, rejection, formatVerification(rejection))
2203
+ process.exit(1)
2204
+ }
2205
+ const opts = { context, ...verifyJudgeOpts(flags) }
2206
+ const verdict = judgeVerification(plan, results, opts)
2207
+ const logOk = logVerifyJudgement(root, {
2208
+ runId: runIdFromFlags(flags),
2209
+ // Same precedence as every wave-state append site: the state names the
2210
+ // run, the flag is only the pre-state-name fallback.
2211
+ change: changeFor(stateFromFlags(flags), flags.change),
2212
+ verdict,
2213
+ results
2214
+ })
2215
+ emit(json, verdict, formatVerification(verdict))
2216
+ if (verdict.halt || !logOk) process.exit(1)
2217
+ } else if (sub === 'unit') {
2218
+ const result = readInput(flags.result, '--result <file|->')
2219
+ const rejection = rejectOversized([result])
2220
+ if (rejection) {
2221
+ emit(json, rejection, formatVerification(rejection))
2222
+ process.exit(1)
2223
+ }
2224
+ const judgement = judgeUnitResult(result, verifyJudgeOpts(flags))
2225
+ emit(json, judgement, formatVerification(judgement))
2226
+ if (judgement.halt) process.exit(1)
2227
+ } else if (sub === 'spill') {
2228
+ const runId = typeof flags['run-id'] === 'string' ? flags['run-id'].trim() : ''
2229
+ if (!runId) die('--run-id is required')
2230
+ const kind = typeof flags.kind === 'string' ? flags.kind.trim() : ''
2231
+ if (!kind) die('--kind is required')
2232
+ const raw = readRawInput(flags.input, '--input <file|->')
2233
+ const result = spillBytes(root, { runId, kind, bytes: raw })
2234
+ emit(
2235
+ json,
2236
+ result,
2237
+ `spilled ${result.bytes} byte(s) → ${result.locator}${result.truncated ? ' (truncated preview)' : ''}\n`
2238
+ )
2239
+ } else if (sub === 'cluster') {
2240
+ const failures = readInput(flags.failures, '--failures <file|->')
2241
+ if (!Array.isArray(failures)) {
2242
+ die('--failures must be a JSON array of failure lines or objects')
2243
+ }
2244
+ const clusters = clusterFailures(failures)
2245
+ const text = clusters.length
2246
+ ? clusters.map(c => ` x${c.count}\t${c.signature}`).join('\n') + '\n'
2247
+ : 'no failures\n'
2248
+ emit(json, clusters, text)
2249
+ } else if (sub === 'repair') {
2250
+ const state = readInput(flags.state, '--state <file|->')
2251
+ const action = nextRepairAction(state)
2252
+ emit(json, action, formatVerification(action))
2253
+ if (action.action === 'halt') process.exit(1)
2254
+ } else {
2255
+ die(
2256
+ `unknown verify subcommand: ${sub || '(none)'} — expected plan|judge|unit|spill|cluster|repair`
2257
+ )
2258
+ }
2259
+ break
2260
+ }
2261
+
2262
+ case 'plan': {
2263
+ const sub = positional[1]
2264
+ // `requestedName` reads positional[1], which here is the SUBCOMMAND — so
2265
+ // the change is positional[2] or --change, and a bare `interlock plan
2266
+ // reuse` resolves the single active change the same way validate does.
2267
+ const requested =
2268
+ typeof flags.change === 'string' && flags.change.trim() ? flags.change.trim() : positional[2]
2269
+ const maxParallel = num(flags, 'max-parallel')
2270
+ const mode = modeFlag(flags)
2271
+ // Part of the hash exactly as `--mode` is, and for the same reason: a
2272
+ // stored plan built under a different shape has to be rebuilt rather than
2273
+ // reused. Only the explicit form is accepted here — this subcommand does
2274
+ // not re-derive the shape, it reports the hash for the one it is told.
2275
+ const redWave = num(flags, 'red-wave')
2276
+
2277
+ if (sub === 'fingerprint') {
2278
+ const name = requireChange(root, requested, json)
2279
+ const fp = computeFingerprint(root, name, { maxParallel, mode, redWave })
2280
+ let written = null
2281
+ if (flags.write === true) {
2282
+ const dest = typeof flags.fingerprint === 'string' ? flags.fingerprint : undefined
2283
+ written = writeFingerprint(root, fp, { path: dest })
2284
+ if (!written.written) warn(`fingerprint not written: ${written.reason}`)
2285
+ }
2286
+ const text =
2287
+ formatFingerprint(fp) +
2288
+ (written
2289
+ ? ` ${written.written ? `written → ${written.path}` : `not written (${written.reason})`}\n`
2290
+ : '')
2291
+ emit(json, written ? { ...fp, written } : fp, text)
2292
+ // Always exit 0. A fingerprint that could not be computed means the next
2293
+ // run re-plans, which is today's behaviour — not a reason to fail the run
2294
+ // that just produced a working plan.
2295
+ } else if (sub === 'reuse') {
2296
+ const name = requireChange(root, requested, json)
2297
+ const result = resolvePlanReuse(root, name, {
2298
+ maxParallel,
2299
+ mode,
2300
+ redWave,
2301
+ planPath: typeof flags.plan === 'string' ? flags.plan : undefined,
2302
+ fingerprintPath: typeof flags.fingerprint === 'string' ? flags.fingerprint : undefined
2303
+ })
2304
+
2305
+ // The narrowed plan is written to its OWN path: overwriting the stored
2306
+ // plan with a narrowed copy would make the next run's fingerprint check
2307
+ // compare against a plan that no longer covers the tasks it was hashed
2308
+ // for.
2309
+ let narrowedPath = null
2310
+ if (result.reuse && result.narrowed && !result.noRemainingWork) {
2311
+ narrowedPath =
2312
+ typeof flags.out === 'string' && flags.out.trim() ? flags.out.trim() : NARROWED_PLAN_PATH
2313
+ const dest = join(root, narrowedPath)
2314
+ try {
2315
+ mkdirSync(dirname(dest), { recursive: true })
2316
+ writeFileSync(dest, JSON.stringify(result.narrowed, null, 2) + '\n')
2317
+ } catch (err) {
2318
+ // Cannot stage the narrowed plan → cannot reuse it. Say which, and
2319
+ // fall back to the classifier rather than to a plan nobody can read.
2320
+ const failed = {
2321
+ change: name,
2322
+ reuse: false,
2323
+ status: 'check-failed',
2324
+ reason: `the narrowed plan could not be written to ${narrowedPath}: ${err.message}`,
2325
+ narrowedPath: null
2326
+ }
2327
+ emit(json, failed, formatPlanReuse(failed))
2328
+ break
2329
+ }
2330
+ }
2331
+
2332
+ const payload = {
2333
+ change: name,
2334
+ reuse: result.reuse,
2335
+ status: result.status,
2336
+ reason: result.reason,
2337
+ noRemainingWork: Boolean(result.noRemainingWork),
2338
+ narrowedPath,
2339
+ planPath: result.planPath,
2340
+ fingerprintPath: result.fingerprintPath,
2341
+ waveCount: result.waveCount ?? null,
2342
+ laneCount: result.laneCount ?? null,
2343
+ taskCount: result.taskCount ?? null,
2344
+ droppedTaskIds: result.droppedTaskIds || []
2345
+ }
2346
+ emit(
2347
+ json,
2348
+ payload,
2349
+ formatPlanReuse(result) +
2350
+ (narrowedPath ? ` narrowed plan → ${narrowedPath}\n` : '')
2351
+ )
2352
+ // Always exit 0, in both directions: not reusing is the normal, correct
2353
+ // outcome on a first run, and an error while checking is a reason to
2354
+ // re-plan rather than a reason to stop.
2355
+ } else {
2356
+ die(`unknown plan subcommand: ${sub || '(none)'} — expected fingerprint|reuse`)
2357
+ }
2358
+ break
2359
+ }
2360
+
2361
+ // The run program (`lib/run.mjs`). Every step of the lean ship loop is
2362
+ // emitted here: what to spawn, with which briefing, and which argv to call
2363
+ // next. A host obeys the record and decides nothing (design D3).
2364
+ //
2365
+ // `wave-state` below is untouched by this and stays the pure thing `run`
2366
+ // obeys. The three helpers `lib/run.mjs` cannot import — the git readers and
2367
+ // the trajectory loggers that live in this file — are handed in as `deps`
2368
+ // rather than written a second time there.
2369
+ case 'run': {
2370
+ const sub = positional[1]
2371
+ const ctx = {
2372
+ root,
2373
+ warn,
2374
+ deps: {
2375
+ headCommit: repoRoot => {
2376
+ try {
2377
+ return execFileSync('git', ['rev-parse', 'HEAD'], {
2378
+ cwd: repoRoot,
2379
+ encoding: 'utf8',
2380
+ stdio: ['ignore', 'pipe', 'ignore']
2381
+ }).trim() || null
2382
+ } catch {
2383
+ // An unreadable base is null, never a guess: `decorate` halts on
2384
+ // it rather than folding a batch against a commit nobody read.
2385
+ return null
2386
+ }
2387
+ },
2388
+ logWaveMutation,
2389
+ logAgentSpawns,
2390
+ observedChangedPaths,
2391
+ runMergeLanes
2392
+ }
2393
+ }
2394
+
2395
+ // The sequence guard. The manifest records the argv the last step named as
2396
+ // its continuation; a driver that skipped a step, repeated one, or invented
2397
+ // a subcommand would otherwise advance the state machine twice or record a
2398
+ // batch against the wrong wave — both silent.
2399
+ //
2400
+ // `close` is exempt, in both directions. A driver that hit an error of its
2401
+ // own — a spawn that never returned, a transport that dropped — closes
2402
+ // from wherever it is, and refusing that would leave the run with no
2403
+ // receipt, no outcome record and no terminal event. A run nobody can
2404
+ // reconstruct is the failure this trajectory exists to prevent; a
2405
+ // mis-sequenced close is not.
2406
+ if (sub !== 'start' && sub !== 'close') {
2407
+ const manifest = readManifest(root)
2408
+ if (manifest) {
2409
+ // The RAW argv, not the parsed positionals: a step's continuation
2410
+ // names its flags (`--classified <path>`, `--context inter-wave`) and
2411
+ // those are part of what the program asked for.
2412
+ const problem = checkSequence(manifest, argv)
2413
+ if (problem) die(problem)
2414
+ }
2415
+ }
2416
+
2417
+ const results = flags.results ? readInput(flags.results, '--results <file|->') : []
2418
+ const resultList = Array.isArray(results) ? results : [results]
2419
+
2420
+ let step
2421
+ if (sub === 'start') {
2422
+ // A name that resolves to nothing is a HALT STEP, not a `die`. Every
2423
+ // other `run` subcommand answers with a step, and a host that got an
2424
+ // error object where it expected one would report "the relay could not
2425
+ // be read" — which is true of the transport and false of the run.
2426
+ const requested = requestedName(positional.slice(1), flags)
2427
+ const resolved = resolveChange(root, requested)
2428
+ if (resolved.error) {
2429
+ const step = haltStep(
2430
+ `validate failed: ${resolved.error}${
2431
+ Array.isArray(resolved.candidates) && resolved.candidates.length
2432
+ ? ` (candidates: ${resolved.candidates.join(', ')})`
2433
+ : ''
2434
+ }`
2435
+ )
2436
+ emit(json, step, formatRunStep(step))
2437
+ break
2438
+ }
2439
+ const name = resolved.name
2440
+ const laneMode = modeFlag(flags)
2441
+ // What the host declared it cannot do (design D1). A driver that passed
2442
+ // only an id — `workflows/ship.js` does — is the Workflow host, and
2443
+ // `newManifest` assumes exactly that shape for it.
2444
+ let hostCapabilities
2445
+ if (typeof flags['host-capabilities'] === 'string') {
2446
+ try {
2447
+ hostCapabilities = JSON.parse(flags['host-capabilities'])
2448
+ } catch (err) {
2449
+ die(`--host-capabilities must be a JSON object of the host's declared capabilities: ${err.message}`)
2450
+ }
2451
+ }
2452
+ step = runStart(ctx, {
2453
+ change: name,
2454
+ host: typeof flags.host === 'string' ? flags.host : undefined,
2455
+ hostCapabilities,
2456
+ flags: {
2457
+ applyOnly: flags['apply-only'] === true,
2458
+ noCommit: flags['no-commit'] === true,
2459
+ skipE2e: flags['skip-e2e'] === true,
2460
+ skipCoverage: flags['skip-coverage'] === true,
2461
+ isolateWaves: flags['isolate-waves'] === true,
2462
+ laneMode: laneMode || null,
2463
+ tddMode: tddFlag(flags),
2464
+ maxParallel: num(flags, 'max-parallel') ?? null,
2465
+ mode: flags.continue === true ? 'continue' : 'checkpoint',
2466
+ review: flags.review !== undefined || flags.strict !== undefined,
2467
+ handoff: flags.handoff !== undefined || flags.strict !== undefined,
2468
+ conformance: flags.conformance !== undefined || flags.strict !== undefined,
2469
+ strict: flags.strict !== undefined
2470
+ }
2471
+ })
2472
+ } else if (sub === 'classified') {
2473
+ const classified =
2474
+ typeof flags.classified === 'string' && flags.classified.trim()
2475
+ ? flags.classified.trim()
2476
+ : CLASSIFIED_PATH
2477
+ step = runClassified(ctx, { classifiedPath: classified })
2478
+ } else if (sub === 'record-batch') {
2479
+ step = runRecordBatch(ctx, { results: resultList })
2480
+ } else if (sub === 'judge') {
2481
+ const context = flags.context === undefined ? 'final' : String(flags.context)
2482
+ if (context !== 'inter-wave' && context !== 'final') {
2483
+ die(`--context must be inter-wave|final (got "${context}")`)
2484
+ }
2485
+ step = runJudge(ctx, { context, results: resultList })
2486
+ } else if (sub === 'verify-final') {
2487
+ step = runVerifyFinal(ctx)
2488
+ } else if (sub === 'reviewed') {
2489
+ step = runReviewed(ctx, { results: resultList })
2490
+ } else if (sub === 'remediated') {
2491
+ const roundNum = num(flags, 'round')
2492
+ if (!Number.isInteger(roundNum)) die('run remediated requires --round <n>')
2493
+ step = runRemediated(ctx, { round: roundNum, results: resultList })
2494
+ } else if (sub === 'commit') {
2495
+ step = runCommit(ctx, { results: resultList })
2496
+ } else if (sub === 'replan') {
2497
+ step = runReplan(ctx, { results: resultList })
2498
+ } else if (sub === 'next') {
2499
+ step = runNext(ctx)
2500
+ } else if (sub === 'close') {
2501
+ const hostBanners = flags['host-banners']
2502
+ ? readInput(flags['host-banners'], '--host-banners <file|->')
2503
+ : []
2504
+ const hostObserved = flags['host-observed']
2505
+ ? readInput(flags['host-observed'], '--host-observed <file|->')
2506
+ : null
2507
+ step = await runClose(ctx, {
2508
+ results: resultList,
2509
+ halt: typeof flags.halt === 'string' ? flags.halt : null,
2510
+ hostBanners: Array.isArray(hostBanners) ? hostBanners : [],
2511
+ hostObserved,
2512
+ notify: flags.notify === true
2513
+ })
2514
+ emit(json, step, step.summary + '\n')
2515
+ if (step.exitCode !== 0) process.exit(step.exitCode)
2516
+ break
2517
+ } else {
2518
+ die(
2519
+ `unknown run subcommand: ${sub || '(none)'} — expected ` +
2520
+ 'start|classified|record-batch|judge|verify-final|reviewed|remediated|commit|replan|next|close'
2521
+ )
2522
+ }
2523
+
2524
+ emit(json, step, formatRunStep(step))
2525
+ // A halt step still exits 0: its `then` is the close, and a driver that
2526
+ // stopped here would leave the run with no receipt and no terminal event.
2527
+ // `run close` is the call that carries the non-zero exit.
2528
+ break
2529
+ }
2530
+
2531
+ case 'wave-state': {
2532
+ const sub = positional[1]
2533
+ const change = typeof flags.change === 'string' ? flags.change : undefined
2534
+ if (sub === 'create') {
2535
+ const plan = readInput(flags.plan, '--plan <file|->')
2536
+ const maxParallel = num(flags, 'max-parallel')
2537
+ // The one place the name enters the run: from here it travels on the
2538
+ // state, and no later wave-state invocation needs the flag.
2539
+ const state = createRunState(plan, { maxParallel, change })
2540
+ const { ok: logOk } = logWaveMutation(root, { source: 'create', change, state })
2541
+ emit(json, state, formatRunState(state))
2542
+ if (!logOk) process.exit(1)
2543
+ } else if (sub === 'next') {
2544
+ const state = readInput(flags.state, '--state <file|->')
2545
+ const { step, ok: logOk } = logWaveMutation(root, { source: 'next', change, state })
2546
+ const spawnOk = logAgentSpawns(root, { state, change, step, source: 'next' })
2547
+ emit(json, step, formatRunState(state))
2548
+ if (step.action === 'halt' || !logOk || !spawnOk) process.exit(1)
2549
+ } else if (sub === 'record-batch' || sub === 'record-verify') {
2550
+ const state = readInput(flags.state, '--state <file|->')
2551
+ const result = readInput(flags.result, '--result <file|->')
2552
+ // The observed path set is passed as an argument, never read from
2553
+ // `result`: `result` is written by the agent being audited, and an audit
2554
+ // whose input its subject supplies is not an audit.
2555
+ const next =
2556
+ sub === 'record-batch'
2557
+ ? recordBatchResult(state, result, { changedPaths: observedChangedPaths(root) })
2558
+ : recordVerifyResult(state, result)
2559
+ const { ok: logOk } = logWaveMutation(root, { source: sub, change, state: next })
2560
+ // Derived from the two states rather than returned by the recorder, so
2561
+ // `recordBatchResult`'s signature and every other caller are untouched.
2562
+ const recorded = sub === 'record-batch' ? batchOutcomes(state, next) : undefined
2563
+ emitRecordedState(json, flags, next, { root, change, logOk, source: sub, recorded })
2564
+ } else if (sub === 'replan') {
2565
+ const state = readInput(flags.state, '--state <file|->')
2566
+ const groups = readInput(flags.groups, '--groups <file|->')
2567
+ if (!Array.isArray(groups)) {
2568
+ die('--groups must be a JSON array of { group, tasks } revisions')
2569
+ }
2570
+ const next = applyReplan(state, groups)
2571
+ const { ok: logOk } = logWaveMutation(root, { source: 'replan', change, state: next })
2572
+ emitRecordedState(json, flags, next, { root, change, logOk, source: 'replan' })
2573
+ } else {
2574
+ die(
2575
+ `unknown wave-state subcommand: ${sub || '(none)'} — expected ` +
2576
+ 'create|next|record-batch|record-verify|replan'
2577
+ )
2578
+ }
2579
+ break
2580
+ }
2581
+
2582
+ case 'autonomy': {
2583
+ const sub = positional[1]
2584
+ const opts = { root: flags.root ? String(flags.root) : undefined }
2585
+ if (sub === 'level') {
2586
+ const path = positional[2]
2587
+ if (!path) die('autonomy level requires a path')
2588
+ const level = getLevel(path, opts)
2589
+ emit(json, { path, level }, `${level}\n`)
2590
+ } else if (sub === 'record') {
2591
+ const path = positional[2]
2592
+ if (!path) die('autonomy record requires a path')
2593
+ const outcome = flags['human-override']
2594
+ ? { human_override: true }
2595
+ : { blockers: Number(flags.blockers || 0) }
2596
+ const result = record(path, outcome, opts)
2597
+ const text = result.promoted
2598
+ ? `${path}: promoted ${result.from} → ${result.to}\n`
2599
+ : `${path}: ${result.current_level} (${result.consecutive_clean}/${result.needed} clean)\n`
2600
+ emit(json, { path, ...result }, text)
2601
+ } else if (sub === 'clean') {
2602
+ const paths = positional.slice(2)
2603
+ if (!paths.length) die('autonomy clean requires at least one path')
2604
+ const results = recordCleanFlow(paths, opts)
2605
+ const text = results
2606
+ .map(r =>
2607
+ r.promoted
2608
+ ? `${r.path}: promoted ${r.from} → ${r.to}`
2609
+ : `${r.path}: ${r.current_level} (${r.consecutive_clean}/${r.needed} clean)`
2610
+ )
2611
+ .join('\n') + '\n'
2612
+ emit(json, results, text)
2613
+ } else if (sub === 'state') {
2614
+ const state = getState(opts)
2615
+ const text = Object.entries(state).length
2616
+ ? Object.entries(state)
2617
+ .map(([p, e]) => `${e.current_level}\t${p}\truns=${e.runs} clean=${e.consecutive_clean} blockers=${e.blockers_caught}`)
2618
+ .join('\n') + '\n'
2619
+ : '(no recorded runs)\n'
2620
+ emit(json, state, text)
2621
+ } else {
2622
+ die(`unknown autonomy subcommand: ${sub || '(none)'}`)
2623
+ }
2624
+ break
2625
+ }
2626
+
2627
+ default:
2628
+ process.stderr.write(`Unknown command: ${cmd}\n\n${USAGE}`)
2629
+ process.exit(1)
2630
+ }
2631
+ }
2632
+
2633
+ // `judge` and `unit` take the same two evidence inputs, so they read them the
2634
+ // same way.
2635
+ function verifyJudgeOpts(flags) {
2636
+ const opts = {}
2637
+ if (flags.baseline !== undefined) opts.baseline = readInput(flags.baseline, '--baseline <file|->')
2638
+ if (flags['pre-existing'] === true) opts.preExistingFailures = true
2639
+ if (flags['pre-existing-signatures'] !== undefined) {
2640
+ const sigs = readInput(flags['pre-existing-signatures'], '--pre-existing-signatures <file|->')
2641
+ if (!Array.isArray(sigs)) die('--pre-existing-signatures must be a JSON array of strings')
2642
+ opts.preExistingFailures = sigs
2643
+ }
2644
+ return opts
2645
+ }
2646
+
2647
+ // `main()` is async only because `run close` awaits one bounded `fetch`
2648
+ // (design D14). Every other case stays synchronous and returns before the
2649
+ // first await, so a rejection here reaches exactly the handler a throw used
2650
+ // to — same body, same `die()`, same exit code.
2651
+ main().catch(err => {
2652
+ if (err && err.userFacing) die(err.message)
2653
+ process.stderr.write(`interlock: ${err && err.message ? err.message : err}\n`)
2654
+ process.exit(1)
2655
+ })