@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,6 @@
1
+ #!/usr/bin/env node
2
+ // Entry point for the interlock-graph CLI.
3
+ // Claude Code adds a plugin's bin/ to the Bash tool's PATH while the plugin is
4
+ // enabled, so skills invoke this as a bare `interlock-graph` command with no
5
+ // path resolution ladder.
6
+ import './../lib/graph/cli.mjs';
@@ -0,0 +1,508 @@
1
+ #!/usr/bin/env node
2
+ // interlock-run — the ship loop, hosted over a vendor coding CLI.
3
+ //
4
+ // This is the second driver of the same loop, and it is allowed to exist
5
+ // because it duplicates NOTHING. The loop is a program the CLI emits
6
+ // (`lib/run.mjs`): a step names the agents to spawn — with their briefings,
7
+ // models, tools, result schemas and lane worktrees — and the exact argv to call
8
+ // once they return. This file creates what a step names and calls the argv it
9
+ // names.
10
+ //
11
+ // It was `interlock-ship-acp` and spoke exactly one protocol to exactly one kind
12
+ // of agent. What is left after the loop moved into the CLI is a transport, and a
13
+ // transport has no business being welded to one vendor: the same program now
14
+ // runs over `claude -p`, over an ACP agent, over `codex exec` and over `qwen -p`.
15
+ // Which one is `--host <id>`, the adapter is one file under `lib/host/`, and the
16
+ // registry hands this file a host plus a declaration of what that host CANNOT
17
+ // do (design D1).
18
+ //
19
+ // This driver owns exactly three things the Workflow runtime owns on its side:
20
+ //
21
+ // the worktree under --isolate-waves the step names a directory per lane and
22
+ // this file runs `git worktree add`; `run record-batch` folds
23
+ // and removes them, and halts on a real collision (design D7).
24
+ // the transport one agent process per lane, prompt inline on stdin.
25
+ // the banners which host, which billing path, which guards are absent, and
26
+ // which spawns could not have their model applied (design D8).
27
+ //
28
+ // Everything else — wave order, caps, verdicts, the review, the receipt — is
29
+ // behind `runCli` on purpose, so a second host cannot quietly grow a second copy
30
+ // of the rules.
31
+ //
32
+ // Usage:
33
+ // interlock-run <change> --host claude
34
+ // INTERLOCK_ACP_COMMAND="my-acp-agent" interlock-run <change> --host acp
35
+
36
+ import { execFileSync } from 'node:child_process'
37
+ import { mkdirSync, writeFileSync } from 'node:fs'
38
+ import { dirname, join } from 'node:path'
39
+ import { createHost, drivesClaudeBinary, HOST_IDS } from '../lib/host/registry.mjs'
40
+ import { MODEL_MAP_ENV } from '../lib/host/model-map.mjs'
41
+ import { CODEX_KEY_ENVS, onChatGptPlan } from '../lib/host/codex.mjs'
42
+
43
+ const WORK = '.claude/ship'
44
+
45
+ /** Where the host id comes from when `--host` is absent. */
46
+ const HOST_ENV = 'INTERLOCK_RUN_HOST'
47
+
48
+ // The same kind of backstop the workflow script keeps, and for the same reason:
49
+ // a loop whose exit condition depends on a relay needs one that does not. It is
50
+ // NOT a policy cap — the bound on how many steps a run may take is published by
51
+ // `interlock limits` as `maxRunSteps` and enforced by the CLI, which returns a
52
+ // halt step naming it. This number is deliberately not derived from that one.
53
+ const RUNAWAY_BACKSTOP = 1000
54
+
55
+ const USAGE = `interlock-run — ship over a vendor coding CLI (experimental second host)
56
+
57
+ Usage:
58
+ interlock-run [<change>] --host <id> [flags]
59
+
60
+ Hosts:
61
+ claude the installed Claude Code CLI, headless (\`claude -p\`)
62
+ acp any Agent Client Protocol agent ($INTERLOCK_ACP_COMMAND)
63
+ codex the OpenAI Codex CLI (\`codex exec\`)
64
+ qwen the Qwen Code CLI (\`qwen -p\`)
65
+
66
+ /interlock:ship on Claude Code stays the supported path and is not this
67
+ binary: the Workflow runtime inside an interactive session is the one
68
+ Anthropic exempted from its paused programmatic-use billing change. This
69
+ runner is the multi-vendor path, and it says which billing path it is on.
70
+
71
+ Flags:
72
+ --host <id> which adapter to run on; defaults to $${HOST_ENV}
73
+ --change <name> change to ship (same as the positional)
74
+ --acp-command <cmd> ACP agent command; defaults to $INTERLOCK_ACP_COMMAND
75
+ --root <dir> repo root (default: cwd)
76
+ --timeout-ms <ms> per-spawn timeout handed to the adapter
77
+ --max-parallel <n> cap the planner's batch width
78
+ --solo plan the whole change as one lane (mutually exclusive
79
+ with --waves; neither lets the classifier recommend)
80
+ --waves force the waves shape even for a small change
81
+ --tdd run the change's leading failing-test section FIRST
82
+ rather than deferring its tests to the trailing wave
83
+ (mutually exclusive with --no-tdd; neither lets the
84
+ "Failing tests first" heading in tasks.md decide)
85
+ --no-tdd never run a leading failing-test wave, whatever
86
+ tasks.md is marked with
87
+ --continue resume rather than start a fresh checkpoint
88
+ --isolate-waves run each lane in its own git worktree, folded per batch
89
+ --apply-only stop after the waves
90
+ --no-commit run everything, leave the commit
91
+ --skip-e2e final verification skips e2e
92
+ --skip-coverage final verification skips coverage
93
+ --review adversarial review, then bounded remediation
94
+ --handoff manual test plan, code explanation, learnings
95
+ --conformance answer each delta spec's scenarios against the code
96
+ --strict all three of the above
97
+ --verbose print host events as they happen
98
+
99
+ Environment:
100
+ ${MODEL_MAP_ENV} {"<host>":{"haiku":"...","sonnet":"...","opus":"..."}}
101
+ Claude accepts the slugs unmapped; Codex and Qwen do
102
+ not, and an unmapped spawn there is bannered, not
103
+ silently run on the operator's default.
104
+
105
+ The tail runs on this host:
106
+ The review, remediation and handoff steps are emitted by \`interlock run\`,
107
+ exactly as the waves are, and this driver interprets them the same way. The
108
+ adjudication, the round budget and the dimension selection stay in the CLI,
109
+ so a strict run here halts and reports on the same terms as one on Claude
110
+ Code.
111
+
112
+ Exit codes:
113
+ 0 ran to a terminal summary
114
+ 1 the run halted
115
+ 2 the invocation could not be started (an unknown or unusable host)
116
+ `
117
+
118
+ // --- invocation -------------------------------------------------------------
119
+
120
+ const VALUE_FLAGS = new Set(['change', 'host', 'acp-command', 'root', 'max-parallel', 'timeout-ms'])
121
+
122
+ function parseArgv(argv) {
123
+ const flags = {}
124
+ const positional = []
125
+ for (let i = 0; i < argv.length; i++) {
126
+ const arg = argv[i]
127
+ if (!arg.startsWith('--')) {
128
+ positional.push(arg)
129
+ continue
130
+ }
131
+ const body = arg.slice(2)
132
+ const eq = body.indexOf('=')
133
+ if (eq !== -1) {
134
+ flags[body.slice(0, eq)] = body.slice(eq + 1)
135
+ continue
136
+ }
137
+ if (VALUE_FLAGS.has(body) && i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
138
+ flags[body] = argv[++i]
139
+ continue
140
+ }
141
+ flags[body] = true
142
+ }
143
+ return { flags, positional }
144
+ }
145
+
146
+ // Every flag USAGE documents, read out of USAGE itself so the two cannot come
147
+ // apart: a flag added to one and not the other is either undocumented or
148
+ // unusable, and both failures are silent. A `--flag` outside this set is
149
+ // REFUSED by name rather than swallowed by `parseArgv` as a bare boolean —
150
+ // `--solo` accepted and dropped is the degraded path this repository does not
151
+ // take quietly.
152
+ const KNOWN_FLAGS = new Set([
153
+ ...[...USAGE.matchAll(/^\s{2}--([a-z][a-z0-9-]*)/gm)].map(m => m[1]),
154
+ 'help',
155
+ 'h'
156
+ ])
157
+
158
+ const { flags, positional } = parseArgv(process.argv.slice(2))
159
+
160
+ if (flags.help || flags.h) {
161
+ process.stdout.write(USAGE)
162
+ process.exit(0)
163
+ }
164
+
165
+ function refuse(message) {
166
+ process.stderr.write(`interlock-run: ${message}\n\n${USAGE}`)
167
+ process.exit(2)
168
+ }
169
+
170
+ for (const name of Object.keys(flags)) {
171
+ if (!KNOWN_FLAGS.has(name)) refuse(`unknown flag --${name}`)
172
+ }
173
+
174
+ // Two contradictory shape flags is a question only the operator can answer, and
175
+ // this run has nobody to ask. Refused HERE, before the host is created and
176
+ // before anything is written — the same order `workflows/ship.js` refuses it in.
177
+ if (flags.solo === true && flags.waves === true) {
178
+ refuse(
179
+ 'contradictory plan-shape flags: --solo and --waves were both passed — pass exactly one, or ' +
180
+ 'neither to let the classifier recommend inside the published envelope'
181
+ )
182
+ }
183
+
184
+ if (flags.tdd === true && flags['no-tdd'] === true) {
185
+ refuse(
186
+ 'contradictory task-shape flags: --tdd and --no-tdd were both passed — pass exactly one, or ' +
187
+ "neither to let tasks.md's own leading-section heading decide"
188
+ )
189
+ }
190
+
191
+ // The plan shape and the resume mode, forwarded to `run start` exactly as
192
+ // `workflows/ship.js` forwards them. This driver never reads either again:
193
+ // which plan a mode buys is the run program's decision, taken once, in the CLI.
194
+ const laneMode = flags.solo === true ? 'solo' : flags.waves === true ? 'waves' : null
195
+ const tddMode = flags.tdd === true ? 'tdd' : flags['no-tdd'] === true ? 'no-tdd' : null
196
+ const continueRun = flags.continue === true
197
+
198
+ const root = typeof flags.root === 'string' ? flags.root : process.cwd()
199
+ const changeArg = typeof flags.change === 'string' ? flags.change : positional[0] || ''
200
+ const maxParallel = Number.parseInt(String(flags['max-parallel'] ?? ''), 10)
201
+ const applyOnly = flags['apply-only'] === true
202
+ const noCommit = flags['no-commit'] === true
203
+ const skipE2e = flags['skip-e2e'] === true
204
+ const skipCoverage = flags['skip-coverage'] === true
205
+ const isolateWaves = flags['isolate-waves'] === true
206
+ // Passed straight through to `run start`, which records them on the manifest.
207
+ // This driver never reads them again: which steps a tail flag buys is the run
208
+ // program's decision, taken once, in the CLI.
209
+ const review = flags.review === true
210
+ const handoff = flags.handoff === true
211
+ const conformance = flags.conformance === true
212
+ const strict = flags.strict === true
213
+ const verbose = flags.verbose === true
214
+
215
+ const hostId =
216
+ (typeof flags.host === 'string' && flags.host.trim()) ||
217
+ (typeof process.env[HOST_ENV] === 'string' && process.env[HOST_ENV].trim()) ||
218
+ ''
219
+
220
+ // Every spawn that carried a model, and what the adapter managed to do with it.
221
+ // The driver computes NOTHING about routing: `applied` is the adapter's field,
222
+ // because the adapter is the only party that saw the wire.
223
+ const routing = []
224
+
225
+ // Refusal comes BEFORE anything is created or written. A run that started and
226
+ // then discovered it had no host would already have a manifest, a briefing and a
227
+ // trajectory describing a run that never happened.
228
+ let host
229
+ try {
230
+ if (!hostId) {
231
+ throw new Error(`--host is required (or set $${HOST_ENV}) — known hosts: ${HOST_IDS.join(', ')}`)
232
+ }
233
+ host = createHost(hostId, {
234
+ ...(hostId === 'acp' && typeof flags['acp-command'] === 'string'
235
+ ? { command: flags['acp-command'] }
236
+ : {}),
237
+ cwd: root,
238
+ timeoutMs: Number.isInteger(Number.parseInt(String(flags['timeout-ms'] ?? ''), 10))
239
+ ? Number.parseInt(String(flags['timeout-ms']), 10)
240
+ : undefined,
241
+ onEvent: event => {
242
+ if (event && event.type === 'model-routing') {
243
+ routing.push(event)
244
+ if (verbose) {
245
+ process.stderr.write(
246
+ `${hostId}: model routing ${event.label}: ${event.requested} → ` +
247
+ (event.applied ? `${event.value} via ${event.via}` : `not applied (${event.reason})`) +
248
+ '\n'
249
+ )
250
+ }
251
+ return
252
+ }
253
+ if (verbose) process.stderr.write(`${hostId}: ${JSON.stringify(event)}\n`)
254
+ }
255
+ })
256
+ } catch (err) {
257
+ process.stderr.write(`interlock-run: ${err.message}\n\n${USAGE}`)
258
+ process.exit(2)
259
+ }
260
+
261
+ const capabilities = host.capabilities || {}
262
+
263
+ // --- plumbing ---------------------------------------------------------------
264
+
265
+ // Which host, which billing path, and which guards are not in force. Raised
266
+ // before the first step so they appear even on a run that halts immediately, and
267
+ // folded into the CLI's summary through --host-banners so that a reader sees
268
+ // them in one block with everything else the run degraded on (design D8).
269
+ const banners = [`RUNNER HOST: ${hostId} (experimental)`]
270
+
271
+ if (drivesClaudeBinary(hostId, process.env, typeof flags['acp-command'] === 'string' ? flags['acp-command'] : undefined)) {
272
+ banners.push(
273
+ `SUBSCRIPTION PATH: programmatic (${hostId}) — claude -p, the Agent SDK and ACP are the usage ` +
274
+ `Anthropic has flagged for separate billing; /interlock:ship on the Workflow runtime is the ` +
275
+ `exempted path, see docs/04-when-it-stops.md`
276
+ )
277
+ }
278
+ if (hostId === 'codex' && onChatGptPlan(process.env)) {
279
+ banners.push(
280
+ `CHATGPT PLAN PATH (codex): neither ${CODEX_KEY_ENVS.join(' nor ')} is set, so this run spends ` +
281
+ `the ChatGPT plan's device-auth session — it goes stale in about a week, and OpenAI directs ` +
282
+ `unattended volume to an API key`
283
+ )
284
+ }
285
+ if (capabilities.hooks === false) {
286
+ banners.push(
287
+ `HOOKS NOT IN FORCE (${hostId}): this repository's PreToolUse guards are Claude Code's, so ` +
288
+ `nothing stops a repair step from weakening a test here except the CLI's own unit-suite ` +
289
+ `shrink check`
290
+ )
291
+ }
292
+
293
+ /**
294
+ * What the run has to say about model routing, read off the adapter's events.
295
+ *
296
+ * Conditional on purpose: it prints when at least one spawn that asked for a
297
+ * model could not have it applied, and names each one with the adapter's reason.
298
+ * A run on which every model landed says so instead — silence would read the
299
+ * same as a run that degraded and hid it.
300
+ */
301
+ function routingBanners() {
302
+ if (!routing.length) return []
303
+ const unapplied = routing.filter(event => event.applied === false)
304
+ if (!unapplied.length) {
305
+ return [`model routing: applied on ${routing.length}/${routing.length} spawns`]
306
+ }
307
+ return [
308
+ `MODEL ROUTING UNAVAILABLE (${hostId}): the plan's per-tier model assignment is not in effect`,
309
+ ...unapplied.map(event => ` — ${event.label}: ${event.reason}`)
310
+ ]
311
+ }
312
+
313
+ let resolvedChange = changeArg || '(unresolved)'
314
+ let steps = 0
315
+
316
+ /** Write a work file and return the repo-relative path the CLI is given. */
317
+ function write(rel, data) {
318
+ const abs = join(root, rel)
319
+ mkdirSync(dirname(abs), { recursive: true })
320
+ writeFileSync(abs, typeof data === 'string' ? data : JSON.stringify(data, null, 2) + '\n')
321
+ return rel
322
+ }
323
+
324
+ /** Run the CLI through the host port and parse `--json` stdout when present. */
325
+ async function cli(argv) {
326
+ const result = await host.runCli([...argv, '--json'])
327
+ if (!result.stdout.trim()) return null
328
+ try {
329
+ return JSON.parse(result.stdout)
330
+ } catch {
331
+ // Unparseable stdout is not a step. Nothing is inferred from it.
332
+ return null
333
+ }
334
+ }
335
+
336
+ function log(line) {
337
+ process.stdout.write(`${line}\n`)
338
+ }
339
+
340
+ /**
341
+ * Create the worktrees a step named, one per lane, from the batch's merge base.
342
+ *
343
+ * The step derives the path and this file creates the directory, so the fold and
344
+ * the checkout cannot disagree about where a lane ran. A creation that fails is
345
+ * NOT swallowed: the lane would silently run in the shared tree beside its
346
+ * siblings, which is the exact defect isolation exists to prevent, so the run
347
+ * halts naming the lane.
348
+ *
349
+ * @param {object} step
350
+ * @returns {string|null} a halt reason, or null when every lane has its worktree
351
+ */
352
+ function createWorktrees(step) {
353
+ const base = typeof step.mergeBase === 'string' ? step.mergeBase : ''
354
+ const lanes = (step.spawns || []).filter(s => s && s.worktree && s.worktree.path)
355
+ if (!lanes.length) return null
356
+ if (!base) {
357
+ return `the step named lane worktrees but no merge base, so no lane could be forked from the shared tree`
358
+ }
359
+ for (const spawn of lanes) {
360
+ const path = spawn.worktree.path
361
+ try {
362
+ // --detach: a lane is a checkout of one commit, not a branch anyone will
363
+ // push. --force lets a re-run reuse a directory a previous halt left named.
364
+ execFileSync('git', ['worktree', 'add', '--detach', '--force', path, base], {
365
+ cwd: root,
366
+ stdio: ['ignore', 'ignore', 'pipe']
367
+ })
368
+ } catch (err) {
369
+ return (
370
+ `could not create the lane worktree for ${spawn.label} at ${path}: ` +
371
+ `${(err && err.stderr ? String(err.stderr).trim() : err && err.message) || 'git failed'}`
372
+ )
373
+ }
374
+ }
375
+ return null
376
+ }
377
+
378
+ /**
379
+ * Spawn one agent a step named.
380
+ *
381
+ * The briefing goes INLINE — this host can hold a multi-kilobyte string, so
382
+ * there is nothing to relay and nothing to acknowledge (design D2). `cwd` is the
383
+ * lane's worktree when the step named one, so the agent's writes land where the
384
+ * fold will look for them.
385
+ */
386
+ function spawnOne(s) {
387
+ return host.spawn({
388
+ label: s.label,
389
+ prompt: s.prompt,
390
+ schema: s.schema,
391
+ ...(s.model ? { model: s.model } : {}),
392
+ ...(s.worktree && s.worktree.path ? { cwd: join(root, s.worktree.path) } : {}),
393
+ type: s.type,
394
+ tools: s.tools
395
+ })
396
+ }
397
+
398
+ /** Spawn everything a step names, in parallel. */
399
+ function spawnAll(step) {
400
+ const spawns = Array.isArray(step.spawns) ? step.spawns : []
401
+ if (!spawns.length) return Promise.resolve([])
402
+ return host.mapPipeline(spawns, s => spawnOne(s))
403
+ }
404
+
405
+ // --- the loop ---------------------------------------------------------------
406
+ //
407
+ // Identical in shape to the workflow host's. The two drivers differ in how a
408
+ // prompt reaches an agent, in who creates a lane's worktree, and in nothing else.
409
+
410
+ let step = await cli([
411
+ 'run',
412
+ 'start',
413
+ ...(changeArg ? ['--change', changeArg] : []),
414
+ ...(applyOnly ? ['--apply-only'] : []),
415
+ ...(noCommit ? ['--no-commit'] : []),
416
+ ...(skipE2e ? ['--skip-e2e'] : []),
417
+ ...(skipCoverage ? ['--skip-coverage'] : []),
418
+ ...(isolateWaves ? ['--isolate-waves'] : []),
419
+ ...(laneMode ? ['--mode', laneMode] : []),
420
+ ...(tddMode === 'tdd' ? ['--tdd'] : tddMode === 'no-tdd' ? ['--no-tdd'] : []),
421
+ ...(Number.isInteger(maxParallel) ? ['--max-parallel', String(maxParallel)] : []),
422
+ ...(continueRun ? ['--continue'] : []),
423
+ ...(review ? ['--review'] : []),
424
+ ...(handoff ? ['--handoff'] : []),
425
+ ...(conformance ? ['--conformance'] : []),
426
+ ...(strict ? ['--strict'] : []),
427
+ '--root',
428
+ '.',
429
+ '--host',
430
+ hostId,
431
+ '--host-capabilities',
432
+ JSON.stringify(capabilities)
433
+ ])
434
+
435
+ while (step && step.then) {
436
+ if (++steps > RUNAWAY_BACKSTOP) {
437
+ await stop(`the interpreter exceeded its runaway backstop (${RUNAWAY_BACKSTOP})`)
438
+ }
439
+ for (const banner of step.banners || []) banners.push(banner)
440
+ if (typeof step.change === 'string' && step.change) resolvedChange = step.change
441
+
442
+ const worktreeProblem = createWorktrees(step)
443
+ if (worktreeProblem) await stop(worktreeProblem)
444
+
445
+ for (const s of step.spawns || []) log(` ${step.action}: ${s.label}`)
446
+ const results = await spawnAll(step)
447
+ write(`${WORK}/results.json`, results)
448
+ // The close is the one continuation that needs more than the spawn results:
449
+ // it reads the banners only this host could raise. Appended here as well as
450
+ // at `stop()`, so a CLEAN run reports them too — the path least likely to
451
+ // have any is also the path where a missing one is least visible.
452
+ const isClose = step.then.argv[0] === 'run' && step.then.argv[1] === 'close'
453
+ step = await cli([
454
+ ...step.then.argv,
455
+ ...(isClose ? closeArgs() : []),
456
+ '--results',
457
+ `${WORK}/results.json`,
458
+ '--root',
459
+ '.'
460
+ ])
461
+ }
462
+
463
+ if (!step) {
464
+ await stop('the run program returned no step — the CLI produced no readable output')
465
+ }
466
+
467
+ finish(step)
468
+
469
+ // --- closing ----------------------------------------------------------------
470
+
471
+ /**
472
+ * Stop the run through the CLI's own close, so a halt records a receipt, an
473
+ * outcome and a terminal trajectory event exactly as a completion does.
474
+ */
475
+ async function stop(reason) {
476
+ const closed = await cli(['run', 'close', '--halt', reason, ...closeArgs(), '--root', '.'])
477
+ finish(
478
+ closed || {
479
+ action: 'halt',
480
+ exitCode: 1,
481
+ summary:
482
+ `SHIP HALTED — ${reason}\n\n` +
483
+ `The close itself could not be reached, so no receipt was written for this run.`
484
+ }
485
+ )
486
+ }
487
+
488
+ /** The flags the close reads for what only this host observed. */
489
+ function closeArgs() {
490
+ // Routing is resolved here rather than pushed as it happened, because the
491
+ // question it answers — did EVERY spawn get its model — is only answerable
492
+ // once the last spawn has returned.
493
+ //
494
+ // `--notify` is unconditional: this driver knows nothing about topics,
495
+ // servers or notification text. It requests the push; the CLI decides
496
+ // whether one is configured and what it says (design D1).
497
+ return ['--host-banners', write(`${WORK}/host-banners.json`, [...banners, ...routingBanners()]), '--notify']
498
+ }
499
+
500
+ /** Print the close's summary and exit on its status. */
501
+ function finish(closed) {
502
+ log(
503
+ typeof closed.summary === 'string' && closed.summary.trim()
504
+ ? closed.summary
505
+ : `SHIP ${closed.action === 'halt' ? 'HALTED' : 'ENDED'} — ${resolvedChange}`
506
+ )
507
+ process.exit(Number.isInteger(closed.exitCode) ? closed.exitCode : closed.action === 'halt' ? 1 : 0)
508
+ }
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ // interlock-ship-acp — the previous name of `interlock-run --host acp`.
3
+ //
4
+ // The ACP driver became a runner with pluggable host adapters, and ACP became
5
+ // one of them (`promote-interlock-run-with-host-adapters`, design D11). This
6
+ // shim keeps every existing invocation working for one release: it says the new
7
+ // name on stderr — never on stdout, which carries the run summary a caller may
8
+ // be parsing — and runs `interlock-run --host acp` with the same arguments and
9
+ // the same environment.
10
+ //
11
+ // Removed in the next minor version. `CHANGELOG.md` names the release.
12
+
13
+ import { spawn } from 'node:child_process'
14
+ import { dirname, join } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+
17
+ process.stderr.write(
18
+ 'interlock-ship-acp is now interlock-run --host acp — this shim is removed in the next minor version\n'
19
+ )
20
+
21
+ const runner = join(dirname(fileURLToPath(import.meta.url)), 'interlock-run')
22
+
23
+ // Executed as `node <runner>` rather than as an executable so it works
24
+ // regardless of the file mode on the checkout and of what is on PATH — the same
25
+ // reason `lib/host.mjs` invokes the policy CLI that way.
26
+ const child = spawn(process.execPath, [runner, '--host', 'acp', ...process.argv.slice(2)], {
27
+ stdio: 'inherit'
28
+ })
29
+
30
+ child.on('error', err => {
31
+ process.stderr.write(`interlock-ship-acp: could not start interlock-run: ${err.message}\n`)
32
+ process.exit(2)
33
+ })
34
+
35
+ // The runner's exit status is the contract; a shim that flattened it would turn
36
+ // a halt into a clean run for anyone still using the old name.
37
+ child.on('close', (code, signal) => {
38
+ process.exit(signal ? 1 : typeof code === 'number' ? code : 1)
39
+ })