@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
@@ -0,0 +1,66 @@
1
+ # `--continue` — the continuity path
2
+
3
+ Only when the user passed `--continue`. Without it, the spec run ended at the human checkpoint (SKILL.md §6).
4
+
5
+ Continuity does not decide anything itself. It asks one machine one question — *may this change skip the human read?* — and obeys the answer. The judgement is `interlock ready`, which is code; this file is only the wiring.
6
+
7
+ **If `--force-checkpoint` was also passed, stop at SKILL.md §6 now.** Do not run readiness. The user opted back out, and the flag exists precisely so that changing your mind costs nothing.
8
+
9
+ ## Ask
10
+
11
+ Hand the gate the artifact review's **own findings file** — the one `/interlock:review-artifacts` wrote. Do not compose a file containing a blocker count, and do not transcribe the count anywhere: the gate derives it, using the same evaluation `interlock gate` uses.
12
+
13
+ That is the whole point. The number that decides whether a human reads this spec must not be written by the agent being gated.
14
+
15
+ ```bash
16
+ interlock ready "<name>" --findings <path to the artifact review's findings JSON> --paths <planned paths> --json
17
+ ```
18
+
19
+ If the artifact review wrote no findings file, **the review has not run.** Stop at the human checkpoint (SKILL.md §6) and say so. Do not synthesise a findings file, and do not fall back to a count — an absent, empty or unparseable findings file is a blocker, never zero blockers.
20
+
21
+ `--review <file>` (a hand-written `{blockers, warnings}`) is **deprecated**. It warns, and on its own it no longer satisfies the review-blockers check.
22
+
23
+ `--paths` is the repo-relative paths `tasks.md` and `design.md` say this change will touch. They need not exist yet — the classifier reads planned paths. Passing none makes the blast radius unclassifiable, which fails closed to `high` and stops continuity, so pass them.
24
+
25
+ ## Branch on the exit code, never on the prose
26
+
27
+ **Exit 0** (`ready: true`) — invoke `/interlock:ship` (the trampoline skill). It launches the workflow; pass `mode: "continue"` so the outcome corpus is not misfiled as a checkpoint:
28
+
29
+ ```
30
+ Workflow({
31
+ scriptPath: "${CLAUDE_PLUGIN_ROOT}/workflows/ship.js",
32
+ args: { change: "<change-name>", mode: "continue" }
33
+ })
34
+ ```
35
+
36
+ If you invoke the skill instead, pass `mode=continue` in its arguments so the trampoline forwards `{ change, mode: "continue" }`.
37
+
38
+ Say what you are doing and why it was allowed: the risk class, and that continuity was requested. Then hand over; from that point nothing can ask the user anything. **Do not call Workflow again** after it returns. Leftover checkboxes are a report, not a second launch.
39
+
40
+ Ship is lean by default (waves → verify → commit). Continuity does **not** invent `--strict`. Pass `flags: ["strict"]` only when the user asked for the review/handoff tail.
41
+
42
+ The mode matters more than it looks. The corpus exists to compare continuity runs against checkpoint runs, and a continuity run filed as a checkpoint is worse than no record — it makes the comparison say the opposite of the truth.
43
+
44
+ **Exit 1** (`ready: false`) — **do not ship.** Present *only* what blocks:
45
+
46
+ - Every `needs_human` row from the ledger — id and question.
47
+ - Every entry in the readiness `blockers[]` — the message, and its evidence line.
48
+
49
+ Nothing else. **Do not dump the spec.** The user opted out of reading it; the point is to ask them the specific questions, not to hand back the reading they declined. Offer *"open the artifacts"* as a secondary action for anyone who wants it, and name the change directory so they can.
50
+
51
+ Title it plainly: **Continuity paused — N decisions need you.** `AskUserQuestion` is available here for the ledger rows; this is not a zero-touch skill, and this is the last place a question is possible.
52
+
53
+ ## After the human answers
54
+
55
+ 1. Write the resolutions into `openspec/changes/<name>/decisions.md` — edit each row in place per `${CLAUDE_PLUGIN_ROOT}/shared/DECISION-LEDGER.md`: flip the class, write the answer into `resolution`, cite the human in `evidence`. Never delete a row.
56
+ 2. Update `design.md` where an answer changed a decision, **and record the resolved decision there by its id** (`D2`). `interlock ledger` now checks that: an `agent_resolved` row whose id appears nowhere in `design.md` is invalid and blocks exactly like `needs_human`. A resolution the design contradicts is worse than an open question; a resolution the design never mentions is one nobody can find.
57
+ 3. Re-run the Ask command.
58
+ 4. Passes → ship. Still blocked → present the remaining rows the same way. If the second run blocks on something the human cannot answer in a sentence — an artifact is not implementable, the risk class is too high — stop and route to the default checkpoint. Continuity is not a loop to grind against.
59
+
60
+ ## The rules that make this safe enough
61
+
62
+ - **Fail closed. Any doubt stops.** `ready` exits non-zero when a check could not run, not only when one failed — an unread ledger, an absent review, a classifier that threw are all blockers. So branch on the **exit code**. Never on parsed prose, never on your own reading of the artifacts, and never on a partial pass because most checks were green.
63
+ - **`ship` cannot ask anything.** It is a dynamic workflow, and the workflow runtime accepts no mid-run user input at all. Every interrupt therefore happens *here*, before ship starts. There is no "we will confirm that during implementation".
64
+ - **Autonomy level does not imply continuity.** They answer different questions. Do not consult the ladder here, do not mention it as a reason, and do not offer `--continue` because a path is L3.
65
+ - **Never offer `--continue` yourself.** It is opt-in, and a suggestion from the tool is not an opt-in. If the user has not asked for it, SKILL.md §6 is the end of the run.
66
+ - **Continuity cannot catch a wrong idea.** Readiness proves the change is *implementable*, never that it is *right*. Only a person reading the spec can do that. Say so when a continuity run starts, rather than implying a green gate means a good change.
@@ -0,0 +1,565 @@
1
+ export const meta = {
2
+ name: 'ship',
3
+ description:
4
+ 'Take a reviewed OpenSpec change from tasks to commit in one uninterrupted run — dependency-ordered waves of parallel implementers, unit verification, and a commit. Asks nothing. Pass --strict for the previous default (adversarial review, handoff, conformance).'
5
+ }
6
+
7
+ // ship — the loop, INTERPRETED.
8
+ //
9
+ // This used to be `skills/ship/SKILL.md`: eleven numbered headings of prose a
10
+ // model was asked to follow in order. Then it became a script that held the
11
+ // loop, the branching and every agent's briefing — and `bin/interlock-ship-acp`
12
+ // held a second copy of the same, sharing this file's policy by evaluating
13
+ // marked blocks of its source through `new Function`. Every new classifier
14
+ // field, banner or prompt sentence had to be added twice, and this repository's
15
+ // own memory records the times it was added once.
16
+ //
17
+ // So the loop moved into the CLI (`lib/run.mjs`) and what is left here is an
18
+ // interpreter:
19
+ //
20
+ // interlock run emits a step: the agents to spawn, with their briefings,
21
+ // and the exact argv to call once they return
22
+ // this script spawns what a step names and calls the argv it names
23
+ // agents read files, write code, run commands
24
+ //
25
+ // The loop is four lines. It branches on nothing — not a flag, not a mode, not
26
+ // a count, not a verdict. Every branch the run has (apply-only, no-commit,
27
+ // skip-e2e, isolate-waves, replan allowed, verify skipped, done) is taken inside
28
+ // the CLI, from the run manifest and the wave state. There is nothing here for
29
+ // the ACP driver to duplicate, because there is nothing here to duplicate.
30
+ //
31
+ // What this file still owns is host plumbing, and nothing else:
32
+ //
33
+ // - `parseInvocation`, because argument delivery is host-specific.
34
+ // - The ping that runs a CLI command. The runtime loads no modules and gives
35
+ // the script no filesystem or shell of its own, so every `interlock` call is
36
+ // an agent that runs the command and copies stdout.
37
+ // - Delivering a briefing BY REFERENCE. The script cannot hold several
38
+ // kilobytes of prompt without a ping copying it, and a relay miss on a 4 KB
39
+ // string is silent under-instruction — so the worker is handed a path and a
40
+ // hash and must report the hash back. See BOOTSTRAP.
41
+ // - The runtime's own token counter, which no other process can read.
42
+ // - `RUNAWAY_BACKSTOP`, which is the RUNTIME's agent ceiling and not a policy
43
+ // cap. The policy bound on run steps is `interlock limits`' `maxRunSteps`,
44
+ // enforced by the CLI, which returns a halt step naming it.
45
+ //
46
+ // The strict tail — adversarial review, bounded remediation, the verdict and
47
+ // the handoff artifacts — used to run inline here behind a `host-tail` step,
48
+ // and was the last loop text this file held. `emit-strict-tail-from-cli` made
49
+ // it part of the program the CLI emits, so this script interprets those steps
50
+ // exactly as it interprets a batch: spawn what the step names, write the
51
+ // results, call the argv. There is no seam left, and the no-policy sweep in
52
+ // test/workflows.test.mjs has no allowance.
53
+ //
54
+ // The runtime also accepts no mid-run user input. That is the zero-touch
55
+ // contract, and it is structural rather than aspirational — there is no
56
+ // AskUserQuestion to remove, because there is nobody listening.
57
+
58
+ // --- run configuration -----------------------------------------------------
59
+
60
+ // The Workflow tool delivers `args` as a string, a JSON array, or `{ change }`.
61
+ // Treating an array as "not an object" used to drop the name, so validate ran
62
+ // nameless against every active change and halted. parseInvocation is marked
63
+ // so tests can eval it without the runtime.
64
+ // PARSE_INVOCATION_START
65
+ function parseInvocation(args) {
66
+ let value = args
67
+ if (typeof value === 'string') {
68
+ const trimmed = value.trim()
69
+ if (
70
+ (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
71
+ (trimmed.startsWith('[') && trimmed.endsWith(']'))
72
+ ) {
73
+ try {
74
+ value = JSON.parse(trimmed)
75
+ } catch {
76
+ value = trimmed
77
+ }
78
+ } else {
79
+ value = trimmed
80
+ }
81
+ }
82
+ const tokens = Array.isArray(value)
83
+ ? value.map(String)
84
+ : typeof value === 'string'
85
+ ? value.split(/\s+/).filter(Boolean)
86
+ : []
87
+ const opts = typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {}
88
+ const flags = new Set(
89
+ (Array.isArray(opts.flags) ? opts.flags : []).concat(tokens.filter(t => t.startsWith('-'))).map(String)
90
+ )
91
+ const has = name => flags.has(name) || flags.has(`--${name}`) || opts[name] === true
92
+ const named =
93
+ (typeof opts.change === 'string' && opts.change.trim()) ||
94
+ (typeof opts.name === 'string' && opts.name.trim()) ||
95
+ ''
96
+ const strict = has('strict')
97
+ // The PLAN shape, which is a different decision from `mode` below (that one is
98
+ // checkpoint/continue). Both flags at once is a contradiction the run reports
99
+ // and stops on, never a last-wins guess: the two produce different bills and
100
+ // different agent counts, and picking one silently would ship the shape the
101
+ // operator did not ask for. The conflict is reported rather than thrown so the
102
+ // parse stays pure and the halt happens where every other halt does.
103
+ const soloFlag = has('solo')
104
+ const wavesFlag = has('waves')
105
+ // The TASK shape, which is a third decision again: whether the change's
106
+ // leading failing-test section runs first instead of deferring. Absent both
107
+ // flags, `tasks.md`'s own section heading decides — so an ordinary change is
108
+ // planned exactly as it was before either flag existed. Contradictory flags
109
+ // are reported and stopped on for the same reason --solo/--waves are: the two
110
+ // produce different wave orders and different verifications.
111
+ const tddFlag = has('tdd')
112
+ const noTddFlag = has('no-tdd')
113
+ return {
114
+ changeArg: named || tokens.find(t => !t.startsWith('-')) || '',
115
+ applyOnly: has('apply-only'),
116
+ noCommit: has('no-commit'),
117
+ skipE2e: has('skip-e2e'),
118
+ skipCoverage: has('skip-coverage'),
119
+ review: strict || has('review'),
120
+ handoff: strict || has('handoff'),
121
+ conformance: strict || has('conformance'),
122
+ strict,
123
+ // Opt-in, default off — unset, a run is byte-for-byte today's behavior: no
124
+ // worktree, no merge-lanes step. See design.md Decision 1 for why this is
125
+ // gated rather than always-on.
126
+ isolateWaves: has('isolate-waves'),
127
+ laneMode: soloFlag && wavesFlag ? null : soloFlag ? 'solo' : wavesFlag ? 'waves' : null,
128
+ laneModeConflict: soloFlag && wavesFlag,
129
+ tddMode: tddFlag && noTddFlag ? null : tddFlag ? 'tdd' : noTddFlag ? 'no-tdd' : null,
130
+ tddModeConflict: tddFlag && noTddFlag,
131
+ maxParallel: Number.isInteger(opts.maxParallel) ? opts.maxParallel : null,
132
+ mode: opts.mode === 'continue' ? 'continue' : 'checkpoint'
133
+ }
134
+ }
135
+ // PARSE_INVOCATION_END
136
+
137
+ // --- host plumbing ----------------------------------------------------------
138
+
139
+ // Spawn prefix for this script's OWN control-plane pings. A step names the type
140
+ // and tools of every agent it asks for, so these two literals are all that is
141
+ // left of the four this file used to carry — and they are the ping's, which is
142
+ // plumbing rather than policy. Dual-write `type` (plugin agent) and `tools`
143
+ // (allowlist) so a runtime that ignores one key still shrinks the inherited
144
+ // catalog.
145
+ const PING_AGENT = 'interlock:ping'
146
+ const PING_TOOLS = ['Bash', 'Read', 'Write']
147
+ const pingExtra = { type: PING_AGENT, tools: PING_TOOLS }
148
+
149
+ const WORK = '.claude/ship'
150
+ const RESULTS = `${WORK}/results.json`
151
+
152
+ // The runtime caps a run at 1000 agents. This is that ceiling, named so nobody
153
+ // mistakes it for policy: it is NOT a bound on the loop, and it is deliberately
154
+ // not derived from `maxRunSteps` or from anything else the CLI publishes. It
155
+ // exists only so a loop whose exit condition depends on a relay cannot spin
156
+ // forever if that relay breaks.
157
+ const RUNAWAY_BACKSTOP = 1000
158
+
159
+ // Briefings are delivered BY REFERENCE (design D2). The CLI writes each one to a
160
+ // file whose first line carries its sha256; this script hands the worker the
161
+ // path and requires the hash back. A result whose `briefing` is absent or
162
+ // differs is treated as a null result — the task fails closed with a named
163
+ // reason, exactly as a missing handoff packet does.
164
+ //
165
+ // This text is plumbing, not policy: it names a file and a field and says
166
+ // nothing whatsoever about the work. It is pinned against
167
+ // test/fixtures/prompts/bootstrap.txt so it cannot quietly grow instructions.
168
+ const BOOTSTRAP = (label, path, sha) =>
169
+ `Your briefing for this step is the file ${path}.\n\n` +
170
+ `Read it in full before doing anything else and follow it as the whole of your instructions. ` +
171
+ `Its first line is an HTML comment carrying a sha256; report that hex string as "briefing" in ` +
172
+ `your result. A result without it is discarded and this step is recorded as failed, so read the ` +
173
+ `file rather than guessing at what "${label}" means.\n\n` +
174
+ `Expected sha256: ${sha}`
175
+
176
+ // Everything this host observed that the CLI could not, which is now two
177
+ // things: `banners`, the degradations only this host can raise, folded into the
178
+ // close through --host-banners; and `spend`, the runtime's own token counter,
179
+ // which no other process can read. The review, remediation and handoff figures
180
+ // used to live here too and are the CLI's own now — it adjudicated them.
181
+ const banners = []
182
+ const summary = { spend: [], notes: [] }
183
+ // --- token spend -----------------------------------------------------------
184
+ //
185
+ // `budget` is a workflow-runtime global — `{total, spent(), remaining()}` — and
186
+ // is read through three guards, none of which is paranoia:
187
+ //
188
+ // 1. `typeof budget === 'undefined'`. The ACP host evaluates parts of this
189
+ // file with no such global, and a bare reference would throw a
190
+ // ReferenceError rather than degrade.
191
+ // 2. `budget.spent` is a function. A runtime that stops exposing accounting
192
+ // partway through a run must leave the later waves unmeasured, not fail
193
+ // them.
194
+ // 3. NOT `budget.total`. It is null whenever no token target was given, while
195
+ // `spent()` stays perfectly meaningful — guarding on it would blank the
196
+ // measurement on every ordinary run.
197
+ //
198
+ // Every failure path returns null, never 0 and never a throw: a run must not die
199
+ // over its own bookkeeping, and an unmeasured wave must not read as a free one.
200
+ function spentTokens() {
201
+ try {
202
+ if (typeof budget === 'undefined' || !budget || typeof budget.spent !== 'function') return null
203
+ const n = Number(budget.spent())
204
+ return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : null
205
+ } catch {
206
+ return null
207
+ }
208
+ }
209
+
210
+ // The reading the run opened at, and the reading at the last wave boundary.
211
+ const spendOpenedAt = spentTokens()
212
+ let spendMark = spendOpenedAt
213
+
214
+ /**
215
+ * Close the current wave span and attribute its measured delta to `wave`.
216
+ *
217
+ * Called at the point the script closes a wave — beside the `summary.waves`
218
+ * push, and after the inter-wave verification ping — and nowhere else, so the
219
+ * script's notion of a wave boundary and the recorded attribution come from one
220
+ * place. Two derivations of "where a wave ended" would drift, and spend would
221
+ * land on the wrong wave with nothing to reveal it.
222
+ *
223
+ * Keyed by wave rather than pushed blindly: a wave that closes an
224
+ * implementation span and then a verification span has two measured deltas and
225
+ * one wave. Adding two measured figures is not the same as inventing a split of
226
+ * one, which is why the deltas are summed and never divided.
227
+ *
228
+ * A wave whose span could not be measured records null. It is never assumed to
229
+ * be zero on the grounds that no implementer ran — a verification-only span
230
+ * still spends the orchestrator's turns.
231
+ */
232
+ function markWaveSpend(wave) {
233
+ const now = spentTokens()
234
+ const delta = now === null || spendMark === null ? null : Math.max(0, now - spendMark)
235
+ if (now !== null) spendMark = now
236
+ const key = wave === undefined || wave === null || !String(wave).trim() ? 'unnumbered' : String(wave)
237
+ const found = summary.spend.find(s => s.wave === key)
238
+ if (!found) summary.spend.push({ wave: key, outputTokens: delta })
239
+ else if (delta !== null) found.outputTokens = found.outputTokens === null ? delta : found.outputTokens + delta
240
+ }
241
+
242
+ // --- the run ----------------------------------------------------------------
243
+
244
+ const {
245
+ changeArg,
246
+ applyOnly,
247
+ noCommit,
248
+ skipE2e,
249
+ skipCoverage,
250
+ review,
251
+ handoff,
252
+ conformance,
253
+ strict,
254
+ isolateWaves,
255
+ laneMode,
256
+ laneModeConflict,
257
+ tddMode,
258
+ tddModeConflict,
259
+ maxParallel,
260
+ mode
261
+ } = parseInvocation(typeof args === 'undefined' ? undefined : args)
262
+
263
+ let steps = 0
264
+ let resolvedChange = changeArg || '(unresolved)'
265
+
266
+ /** A control-plane ping: mechanical, cheap, and never asked to decide anything. */
267
+ const ping = (name, prompt, schema) => agent(prompt, { label: name, schema, ...pingExtra })
268
+
269
+ /**
270
+ * Spawn one agent a step named.
271
+ *
272
+ * The step named its type, tools, model, effort, isolation and schema, so this
273
+ * passes them through and adds only the two things a by-reference host needs:
274
+ * the bootstrap, and `briefing` as a required field.
275
+ */
276
+ async function spawnOne(s) {
277
+ const result = await agent(BOOTSTRAP(s.label, s.promptPath, s.promptSha256), {
278
+ label: s.label,
279
+ type: s.type,
280
+ tools: s.tools,
281
+ ...(s.model ? { model: s.model } : {}),
282
+ ...(s.effort ? { effort: s.effort } : {}),
283
+ ...(s.isolation ? { isolation: s.isolation } : {}),
284
+ schema: {
285
+ ...s.schema,
286
+ required: [...(s.schema.required || []), 'briefing'],
287
+ properties: { ...(s.schema.properties || {}), briefing: { type: 'string' } }
288
+ }
289
+ })
290
+ // Fail closed on an unacknowledged briefing. An agent that did not read its
291
+ // instructions did not do this task, whatever it reports about it — and a
292
+ // silently under-instructed worker is exactly the failure prompt integrity
293
+ // exists to catch. So it is a null result with a named reason, which the
294
+ // recorder already treats as a failed task, rather than a warning beside a
295
+ // result that gets counted.
296
+ const ack = result && typeof result.briefing === 'string' ? result.briefing.trim() : ''
297
+ if (ack !== s.promptSha256) {
298
+ banners.push(
299
+ `BRIEFING NOT ACKNOWLEDGED: ${s.label} reported ${ack || '(nothing)'} for a briefing whose ` +
300
+ `sha256 is ${s.promptSha256} — the step is recorded as failed rather than trusted`
301
+ )
302
+ return null
303
+ }
304
+ return result
305
+ }
306
+
307
+ /** Spawn everything a step names, in parallel. */
308
+ async function spawnAll(step) {
309
+ const spawns = Array.isArray(step.spawns) ? step.spawns : []
310
+ if (!spawns.length) return []
311
+ return await pipeline(spawns, s => spawnOne(s))
312
+ }
313
+
314
+ /**
315
+ * Run one `interlock` command and return the step it printed.
316
+ *
317
+ * The script has no shell, so this is a ping that writes whatever files the
318
+ * command reads, runs the argv and copies stdout. `cliStdout` is the record:
319
+ * a step is read from the CLI's own bytes, never from a field a model retyped,
320
+ * and a relay that produced neither is a loud stop rather than a guess at what
321
+ * the CLI decided.
322
+ */
323
+ async function cli(argv, results, extraWrites = []) {
324
+ const label = `cli-${++steps}`
325
+ const writes = [...(results === undefined ? [] : [{ path: RESULTS, value: results }]), ...extraWrites]
326
+ const writeLines = writes
327
+ .map(w => `Write this JSON to ${w.path} exactly as given:\n${JSON.stringify(w.value)}`)
328
+ .join('\n\n')
329
+ // Quoted, because an argv token can hold a whole sentence: a halt reason is
330
+ // one argument and reads as several the moment it is joined with spaces. The
331
+ // relay runs a command line, so the quoting has to happen where the command
332
+ // line is built.
333
+ const quote = a => (/[^\w@%+=:,./-]/.test(String(a)) ? `'${String(a).replace(/'/g, `'\\''`)}'` : String(a))
334
+ const command =
335
+ `interlock ${argv.map(quote).join(' ')}${results === undefined ? '' : ` --results ${RESULTS}`} --json`
336
+ const relayed = await ping(
337
+ label,
338
+ `You are a mechanical relay for one command. Do not interpret it, and do not do the work it ` +
339
+ `describes.\n\n` +
340
+ (writeLines ? `${writeLines}\n\n` : '') +
341
+ `Then run exactly:\n ${command}\n\n` +
342
+ `Copy that command's stdout into this result verbatim as cliStdout, and copy its "action" ` +
343
+ `field into action. Never invent either, never summarize, and never adjust a value: a step ` +
344
+ `edited in transit is indistinguishable afterwards from the step the CLI emitted.\n` +
345
+ `A non-zero exit is still a result — copy whatever it printed.`,
346
+ {
347
+ type: 'object',
348
+ properties: { cliStdout: { type: 'string' }, action: { type: 'string' } }
349
+ }
350
+ )
351
+ if (relayed && typeof relayed.cliStdout === 'string') {
352
+ try {
353
+ const parsed = JSON.parse(relayed.cliStdout)
354
+ if (parsed && typeof parsed.action === 'string') return parsed
355
+ } catch {
356
+ // stdout was not JSON. Nothing is inferred from that: the caller stops.
357
+ }
358
+ }
359
+ return null
360
+ }
361
+
362
+ // Before anything is spawned: two contradictory shape flags is a question only
363
+ // the operator can answer, and this run has nobody to ask. Halting here costs
364
+ // one invocation; guessing costs a whole run of the wrong shape.
365
+ if (laneModeConflict) {
366
+ return await stop(
367
+ 'contradictory plan-shape flags: --solo and --waves were both passed — pass exactly one, or ' +
368
+ 'neither to let the classifier recommend inside the published envelope'
369
+ )
370
+ }
371
+
372
+ if (tddModeConflict) {
373
+ return await stop(
374
+ 'contradictory task-shape flags: --tdd and --no-tdd were both passed — pass exactly one, or ' +
375
+ "neither to let tasks.md's own leading-section heading decide"
376
+ )
377
+ }
378
+
379
+ // The environment probe. Everything it asks about is a property of THIS HOST —
380
+ // whether haiku is reachable, whether the graph was built, whether a test
381
+ // profile exists, whether model routing is overridden — so it is asked here and
382
+ // carried to the close as a host banner, rather than being something the CLI
383
+ // could have found out for itself.
384
+ const probed = await ping(
385
+ 'validate',
386
+ `Report this environment. Change nothing, and do not start any work.\n\n` +
387
+ `Run: test -f .claude/graph/graph.json && echo yes || echo no\n` +
388
+ `Report yes as hasGraph:true, no as hasGraph:false with a one-line graphReason.\n\n` +
389
+ `Run: test -f .claude/testing/profile.json && echo yes || echo no\n` +
390
+ `Report it as hasTestProfile.\n\n` +
391
+ `Run: printenv CLAUDE_CODE_SUBAGENT_MODEL\n` +
392
+ `If it prints a value, report it as subagentModelOverride. If it is unset the command exits ` +
393
+ `non-zero and prints nothing — that is the normal case, so leave the field out rather than ` +
394
+ `reporting an empty string.\n\n` +
395
+ `Then run: printenv CLAUDE_CODE_USE_BEDROCK; printenv AWS_BEDROCK\n` +
396
+ `If subagentModelOverride is set, leave haikuAvailable out — routing is already overridden.\n` +
397
+ `If either Bedrock variable prints a non-empty value other than 0 or false, report ` +
398
+ `haikuAvailable:false. Bedrock accounts often cannot reach haiku and a failed ping halts the loop.\n` +
399
+ `Otherwise report haikuAvailable:true. When unsure, haikuAvailable:false so pings inherit ` +
400
+ `the session model rather than hard-failing.\n\n` +
401
+ `Then create the working directory ${WORK}/.`,
402
+ {
403
+ type: 'object',
404
+ properties: {
405
+ hasGraph: { type: 'boolean' },
406
+ graphReason: { type: 'string' },
407
+ hasTestProfile: { type: 'boolean' },
408
+ subagentModelOverride: { type: 'string' },
409
+ haikuAvailable: { type: 'boolean' }
410
+ }
411
+ }
412
+ )
413
+
414
+ if (probed && probed.hasGraph === false) {
415
+ banners.push(
416
+ `GRAPH UNAVAILABLE: ${probed.graphReason || 'never built'} — implementer and reviewer agents fall back to grep and will be slower`
417
+ )
418
+ }
419
+ if (probed && probed.hasTestProfile === false) {
420
+ banners.push('NO TEST PROFILE: run /interlock:fix-tests --reconfigure once')
421
+ }
422
+ // CLAUDE_CODE_SUBAGENT_MODEL overrides both the session model and the per-agent
423
+ // model a step asks for, so when it is set the planner's tier ladder — the opus
424
+ // clamp, the haiku pings — is not in effect and the run costs whatever that
425
+ // model costs. Nothing here can prevent that; it is the user's environment. But
426
+ // a summary claiming no degradation while the entire model ladder was bypassed
427
+ // is exactly the silence the banner block exists to remove.
428
+ const subagentModel =
429
+ probed && typeof probed.subagentModelOverride === 'string'
430
+ ? probed.subagentModelOverride.trim()
431
+ : ''
432
+ if (subagentModel) {
433
+ banners.push(
434
+ `MODEL ROUTING OVERRIDDEN: CLAUDE_CODE_SUBAGENT_MODEL=${subagentModel} — every agent runs on ` +
435
+ `that model, so the per-tier assignment in the plan is not in effect`
436
+ )
437
+ } else if (probed && probed.haikuAvailable === true) {
438
+ // Mutate rather than rebind — `ping` closes over the object.
439
+ pingExtra.model = 'haiku'
440
+ }
441
+
442
+ // --- the loop ---------------------------------------------------------------
443
+
444
+ let step = await cli([
445
+ 'run',
446
+ 'start',
447
+ ...(changeArg ? ['--change', changeArg] : []),
448
+ ...(applyOnly ? ['--apply-only'] : []),
449
+ ...(noCommit ? ['--no-commit'] : []),
450
+ ...(skipE2e ? ['--skip-e2e'] : []),
451
+ ...(skipCoverage ? ['--skip-coverage'] : []),
452
+ ...(isolateWaves ? ['--isolate-waves'] : []),
453
+ ...(laneMode ? ['--mode', laneMode] : []),
454
+ ...(tddMode === 'tdd' ? ['--tdd'] : tddMode === 'no-tdd' ? ['--no-tdd'] : []),
455
+ ...(Number.isInteger(maxParallel) ? ['--max-parallel', String(maxParallel)] : []),
456
+ ...(mode === 'continue' ? ['--continue'] : []),
457
+ ...(review ? ['--review'] : []),
458
+ ...(handoff ? ['--handoff'] : []),
459
+ ...(conformance ? ['--conformance'] : []),
460
+ ...(strict ? ['--strict'] : []),
461
+ '--host',
462
+ 'workflow'
463
+ ])
464
+
465
+ while (step && step.then) {
466
+ if (steps > RUNAWAY_BACKSTOP) {
467
+ return await stop(`the interpreter exceeded the runtime's agent ceiling (${RUNAWAY_BACKSTOP})`)
468
+ }
469
+ for (const banner of step.banners || []) banners.push(banner)
470
+ if (typeof step.change === 'string' && step.change) resolvedChange = step.change
471
+
472
+ // A wave's measured span closes where the wave closes: at the step that
473
+ // dispatches or verifies it. One place, so the boundary the script acted on
474
+ // and the boundary the receipt reports cannot drift apart.
475
+ if (step.action === 'run-batch' || step.action === 'test-wave' || step.action === 'verify') {
476
+ markWaveSpend(step.wave)
477
+ }
478
+
479
+ const results = await spawnAll(step)
480
+ // The close is the one continuation that needs more than the spawn results:
481
+ // it reads what only this host observed. Appending here rather than at the
482
+ // one call site `stop()` uses is what keeps a CLEAN run's banners and token
483
+ // spend from being dropped — they used to reach the close only on a halt,
484
+ // which is the path least likely to have any.
485
+ step = isClose(step.then.argv)
486
+ ? await cli([...step.then.argv, ...closeArgs()], results, closeWrites())
487
+ : await cli(step.then.argv, results)
488
+ }
489
+
490
+ if (!step) {
491
+ return await stop('the run program returned no step — the CLI relay could not be read')
492
+ }
493
+
494
+ // The last argv was `run close`, so `step` IS the close: its summary and its
495
+ // banners. Print and stop.
496
+ return finish(step)
497
+
498
+ // --- closing ----------------------------------------------------------------
499
+
500
+ /**
501
+ * Stop the run through the CLI's own close, so a halt records a receipt, an
502
+ * outcome and a terminal trajectory event exactly as a completion does. A
503
+ * halted run is the most informative record in the corpus, which is why it is
504
+ * written on the way out rather than skipped as a failure.
505
+ */
506
+ function isClose(argv) {
507
+ return Array.isArray(argv) && argv[0] === 'run' && argv[1] === 'close'
508
+ }
509
+
510
+ async function stop(reason) {
511
+ const closed = await cli(['run', 'close', '--halt', reason, ...closeArgs()], undefined, closeWrites())
512
+ return finish(
513
+ closed || {
514
+ action: 'halt',
515
+ summary:
516
+ `SHIP HALTED — ${reason}\n\n` +
517
+ `The close itself could not be reached, so no receipt was written for this run.`
518
+ }
519
+ )
520
+ }
521
+
522
+ /** The flags the close reads for what only this host observed. */
523
+ function closeArgs() {
524
+ return [
525
+ ...(banners.length ? ['--host-banners', `${WORK}/host-banners.json`] : []),
526
+ '--host-observed',
527
+ `${WORK}/host-observed.json`,
528
+ // Unconditional: this driver knows nothing about topics, servers or
529
+ // notification text. It requests the push; the CLI decides whether one is
530
+ // configured and what it says (design D1).
531
+ '--notify'
532
+ ]
533
+ }
534
+
535
+ /** Those same values, as writes the relay performs before running the close. */
536
+ function closeWrites() {
537
+ const spentAtClose = spentTokens()
538
+ return [
539
+ ...(banners.length ? [{ path: `${WORK}/host-banners.json`, value: banners }] : []),
540
+ {
541
+ path: `${WORK}/host-observed.json`,
542
+ value: {
543
+ ...summary,
544
+ outputTokens:
545
+ spentAtClose === null || spendOpenedAt === null
546
+ ? null
547
+ : Math.max(0, spentAtClose - spendOpenedAt)
548
+ }
549
+ }
550
+ ]
551
+ }
552
+
553
+ /** Print the close's summary. */
554
+ function finish(closed) {
555
+ const text =
556
+ typeof closed.summary === 'string' && closed.summary.trim()
557
+ ? closed.summary
558
+ : `SHIP ${closed.action === 'halt' ? 'HALTED' : 'ENDED'} — ${resolvedChange}`
559
+ return (
560
+ text +
561
+ // Printed by this host alone: `/goal` is a Claude Code convention and means
562
+ // nothing on another host, so it is not part of the summary the CLI builds.
563
+ '\nGOAL MET: interlock ship returned a terminal summary.'
564
+ )
565
+ }