@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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +58 -0
- package/CHANGELOG.md +380 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/agents/ping.md +8 -0
- package/agents/worker.md +8 -0
- package/bin/interlock +2655 -0
- package/bin/interlock-graph +6 -0
- package/bin/interlock-run +508 -0
- package/bin/interlock-ship-acp +39 -0
- package/hooks/_shared.mjs +162 -0
- package/hooks/guard-commit.mjs +77 -0
- package/hooks/guard-tasks.mjs +107 -0
- package/hooks/guard-tests.mjs +63 -0
- package/hooks/preflight.mjs +107 -0
- package/lib/artifacts.mjs +316 -0
- package/lib/autonomy.mjs +149 -0
- package/lib/conformance.mjs +132 -0
- package/lib/doctor.mjs +1176 -0
- package/lib/drift.mjs +409 -0
- package/lib/eval-history.mjs +304 -0
- package/lib/evals-calibrate.mjs +317 -0
- package/lib/evals-capture.mjs +437 -0
- package/lib/evals-promote.mjs +257 -0
- package/lib/evals-triage.mjs +216 -0
- package/lib/findings.mjs +340 -0
- package/lib/graph/cli.mjs +266 -0
- package/lib/graph/extract/config-json.mjs +74 -0
- package/lib/graph/extract/create-extractor.mjs +35 -0
- package/lib/graph/extract/memory.mjs +99 -0
- package/lib/graph/extract/openspec.mjs +80 -0
- package/lib/graph/extract/python.mjs +137 -0
- package/lib/graph/extract/regex-fallback.mjs +187 -0
- package/lib/graph/extract/shell.mjs +71 -0
- package/lib/graph/extract/typescript.mjs +217 -0
- package/lib/graph/extract/walk.mjs +272 -0
- package/lib/graph/format.mjs +51 -0
- package/lib/graph/graph/build.mjs +374 -0
- package/lib/graph/graph/query.mjs +249 -0
- package/lib/graph/graph/report.mjs +122 -0
- package/lib/graph/graph/update.mjs +47 -0
- package/lib/graph/paths.mjs +66 -0
- package/lib/graph/retrieve/context.mjs +86 -0
- package/lib/graph/retrieve/docs-index.mjs +116 -0
- package/lib/graph/retrieve/markdown.mjs +246 -0
- package/lib/host/acp.mjs +551 -0
- package/lib/host/claude-cli.mjs +285 -0
- package/lib/host/codex.mjs +231 -0
- package/lib/host/model-map.mjs +129 -0
- package/lib/host/qwen.mjs +176 -0
- package/lib/host/registry.mjs +213 -0
- package/lib/host.mjs +393 -0
- package/lib/ledger.mjs +444 -0
- package/lib/limits.mjs +525 -0
- package/lib/locators.mjs +99 -0
- package/lib/merge-lanes.mjs +148 -0
- package/lib/metrics.mjs +99 -0
- package/lib/notify.mjs +229 -0
- package/lib/outcomes.mjs +560 -0
- package/lib/plan-fingerprint.mjs +570 -0
- package/lib/project-slug.mjs +18 -0
- package/lib/prompts/commit.mjs +26 -0
- package/lib/prompts/dimensions.mjs +70 -0
- package/lib/prompts/handoff.mjs +86 -0
- package/lib/prompts/implementer.mjs +150 -0
- package/lib/prompts/index.mjs +250 -0
- package/lib/prompts/planner.mjs +70 -0
- package/lib/prompts/remediate.mjs +137 -0
- package/lib/prompts/replan.mjs +23 -0
- package/lib/prompts/review.mjs +107 -0
- package/lib/prompts/schemas.mjs +206 -0
- package/lib/prompts/stage.mjs +48 -0
- package/lib/prompts/verify.mjs +88 -0
- package/lib/ready.mjs +812 -0
- package/lib/receipt.mjs +434 -0
- package/lib/remediate.mjs +275 -0
- package/lib/report-html.mjs +623 -0
- package/lib/report.mjs +1124 -0
- package/lib/review-core.mjs +713 -0
- package/lib/risk.mjs +370 -0
- package/lib/run-log.mjs +1004 -0
- package/lib/run-paths.mjs +239 -0
- package/lib/run.mjs +2921 -0
- package/lib/ship-stage.mjs +223 -0
- package/lib/spill.mjs +150 -0
- package/lib/surface.mjs +195 -0
- package/lib/verify.mjs +1074 -0
- package/lib/waves.mjs +2661 -0
- package/package.json +40 -0
- package/shared/CONTEXT-HYGIENE.md +84 -0
- package/shared/DECISION-LEDGER.md +141 -0
- package/shared/EXPLORE-BRIEF.md +96 -0
- package/shared/INVARIANT-SWEEP.md +57 -0
- package/shared/SESSION-SHAPES.md +261 -0
- package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
- package/shared/TEST-PROFILE.md +204 -0
- package/shared/TOOL-ECONOMY.md +155 -0
- package/skills/bootstrap/SKILL.md +226 -0
- package/skills/commit/SKILL.md +99 -0
- package/skills/dispatch/SKILL.md +78 -0
- package/skills/docs-digest/SKILL.md +109 -0
- package/skills/evals/SKILL.md +160 -0
- package/skills/explain-code/SKILL.md +42 -0
- package/skills/explain-code/commit-teachin.md +136 -0
- package/skills/explain-code/module-walkthrough.md +358 -0
- package/skills/explore/SKILL.md +159 -0
- package/skills/fix-tests/SKILL.md +120 -0
- package/skills/graph/SKILL.md +136 -0
- package/skills/manual-test-plan/SKILL.md +121 -0
- package/skills/mr/SKILL.md +170 -0
- package/skills/report/SKILL.md +121 -0
- package/skills/review-artifacts/SKILL.md +127 -0
- package/skills/review-code/REVIEW.template.md +49 -0
- package/skills/review-code/SKILL.md +156 -0
- package/skills/review-code/dimensions/architecture.md +10 -0
- package/skills/review-code/dimensions/devops.md +12 -0
- package/skills/review-code/dimensions/language.md +12 -0
- package/skills/review-code/dimensions/qa.md +12 -0
- package/skills/review-code/dimensions/security.md +12 -0
- package/skills/review-code/dimensions/technical-lead.md +19 -0
- package/skills/ship/SKILL.md +67 -0
- package/skills/spec/SKILL.md +264 -0
- package/skills/spec/continuity.md +66 -0
- 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
|
+
})
|