@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
package/lib/host.mjs
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// The workflow host port (add-interlock-acp-host §1).
|
|
2
|
+
//
|
|
3
|
+
// Every guarantee in this repository comes from `bin/interlock` — which wave
|
|
4
|
+
// runs next, whether accumulated failures have exhausted the budget, whether a
|
|
5
|
+
// red unit suite blocks a commit. Exactly one guarantee came from somewhere
|
|
6
|
+
// else: the *spawn*. `workflows/ship.js` runs on the Claude Code dynamic
|
|
7
|
+
// workflow runtime and reaches a model through that runtime's `agent()` and
|
|
8
|
+
// `pipeline()` globals, which exist nowhere else. That is the whole portability
|
|
9
|
+
// hole, and it is three functions wide:
|
|
10
|
+
//
|
|
11
|
+
// spawn one labelled agent, given a prompt, a model and a schema
|
|
12
|
+
// mapPipeline the same, in parallel, across a batch the planner sized
|
|
13
|
+
// runCli execute `interlock` and hand back exit code plus stdout
|
|
14
|
+
//
|
|
15
|
+
// A host implements those three and nothing else. It does NOT decide wave
|
|
16
|
+
// order, it does NOT judge a verify result, it does NOT apply a cap or a review
|
|
17
|
+
// gate — those live behind `runCli` on purpose, so a second host cannot quietly
|
|
18
|
+
// grow a second copy of the rules. `test/spine/host.test.mjs` holds that line by
|
|
19
|
+
// driving a real batch through a host that cannot talk to a model at all: the
|
|
20
|
+
// halt still comes out of the CLI's exit status.
|
|
21
|
+
//
|
|
22
|
+
// `workflows/ship.js` deliberately does not import this module. The workflow
|
|
23
|
+
// runtime rejects a script containing `import()` before the run starts, so the
|
|
24
|
+
// script keeps using the runtime's native globals; the port is for the ACP
|
|
25
|
+
// driver (`bin/interlock-ship-acp`) and for tests. The two drivers share the
|
|
26
|
+
// CLI, which is the same split — script versus policy — the repo already had.
|
|
27
|
+
|
|
28
|
+
import { spawn as spawnProcess } from 'node:child_process'
|
|
29
|
+
import { dirname, join } from 'node:path'
|
|
30
|
+
import { fileURLToPath } from 'node:url'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Absolute path to the policy engine. Resolved from this module's own location
|
|
34
|
+
* rather than from PATH or a caller-supplied string: a host that could point
|
|
35
|
+
* `runCli` at some other binary is a host that could reimplement the rules and
|
|
36
|
+
* still look like it was obeying them.
|
|
37
|
+
*/
|
|
38
|
+
export const INTERLOCK_BIN = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'interlock')
|
|
39
|
+
|
|
40
|
+
/** The three ports a host must provide. Read by `assertWorkflowHost`. */
|
|
41
|
+
export const HOST_PORTS = ['spawn', 'mapPipeline', 'runCli']
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Plugin agent ids and tool allowlists for a slim spawn prefix.
|
|
45
|
+
*
|
|
46
|
+
* Claude Code's workflow `agent()` inherits the parent tool catalog and Skill
|
|
47
|
+
* listing unless the call names a type and an allowlist. `workflows/ship.js`
|
|
48
|
+
* cannot import this module (the runtime rejects `import()`), so it restates
|
|
49
|
+
* the same four literals. `test/spine/plugin-agents.test.mjs` fails if they
|
|
50
|
+
* drift. `kind === 'ping'` is the mechanical CLI path; everything else is a
|
|
51
|
+
* worker.
|
|
52
|
+
*/
|
|
53
|
+
export const PING_AGENT = 'interlock:ping'
|
|
54
|
+
export const WORKER_AGENT = 'interlock:worker'
|
|
55
|
+
export const PING_TOOLS = Object.freeze(['Bash', 'Read', 'Write'])
|
|
56
|
+
export const WORKER_TOOLS = Object.freeze(['Read', 'Write', 'Edit', 'Grep', 'Glob', 'Bash'])
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} [kind]
|
|
60
|
+
* @returns {{ type: string, tools: string[] }}
|
|
61
|
+
*/
|
|
62
|
+
export function spawnPrefix(kind) {
|
|
63
|
+
return kind === 'ping'
|
|
64
|
+
? { type: PING_AGENT, tools: [...PING_TOOLS] }
|
|
65
|
+
: { type: WORKER_AGENT, tools: [...WORKER_TOOLS] }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @typedef {object} SpawnRequest
|
|
70
|
+
* @property {string} label stable name for the agent (task id, step name)
|
|
71
|
+
* @property {string} prompt the full prompt text
|
|
72
|
+
* @property {string} [model] model slug the planner assigned, if the host can honour it
|
|
73
|
+
* @property {object} [schema] JSON Schema the result must satisfy
|
|
74
|
+
* @property {string} [type] plugin agent id (`interlock:ping` / `interlock:worker`)
|
|
75
|
+
* @property {string[]} [tools] tools allowlist; omitting it inherits the parent catalog
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {object} CliResult
|
|
80
|
+
* @property {number} code exit status; the loop branches on this, never on prose
|
|
81
|
+
* @property {string} stdout
|
|
82
|
+
* @property {string} stderr
|
|
83
|
+
* @property {string|null} [signal]
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @typedef {object} WorkflowHost
|
|
88
|
+
* @property {(req: SpawnRequest) => Promise<object|null>} spawn
|
|
89
|
+
* @property {(tasks: object[], fn: (task: object, index: number) => Promise<object|null>) => Promise<Array<object|null>>} mapPipeline
|
|
90
|
+
* @property {(argv: string[], opts?: { input?: string }) => Promise<CliResult>} runCli
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Fail loudly at startup rather than three waves in, when a half-built host
|
|
95
|
+
* turns out to be missing the one port the loop needs next.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} host
|
|
98
|
+
* @param {string} [label]
|
|
99
|
+
* @returns {WorkflowHost} the same object, for chaining
|
|
100
|
+
*/
|
|
101
|
+
export function assertWorkflowHost(host, label = 'host') {
|
|
102
|
+
if (!host || typeof host !== 'object') {
|
|
103
|
+
throw new Error(`${label} is not a workflow host: expected an object with ${HOST_PORTS.join(', ')}`)
|
|
104
|
+
}
|
|
105
|
+
const missing = HOST_PORTS.filter(port => typeof host[port] !== 'function')
|
|
106
|
+
if (missing.length) {
|
|
107
|
+
throw new Error(`${label} is missing workflow host port(s): ${missing.join(', ')}`)
|
|
108
|
+
}
|
|
109
|
+
return host
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Execute `interlock` and resolve with its exit code and output.
|
|
114
|
+
*
|
|
115
|
+
* Never rejects on a non-zero exit — a non-zero exit is the answer, not an
|
|
116
|
+
* error, and a host that threw on it would turn a halt into a crash. It rejects
|
|
117
|
+
* only when the process could not be started at all. A run killed by a signal
|
|
118
|
+
* resolves with code 1, because the loop's question is "may I continue", and the
|
|
119
|
+
* answer to that for a killed CLI is no.
|
|
120
|
+
*
|
|
121
|
+
* Invoked as `node <bin/interlock>` rather than as an executable so it works
|
|
122
|
+
* regardless of the file mode on the checkout and of what is on PATH.
|
|
123
|
+
*
|
|
124
|
+
* @param {string[]} argv subcommand and flags, e.g. ['wave-state', 'next', '--json']
|
|
125
|
+
* @param {object} [opts]
|
|
126
|
+
* @param {string} [opts.cwd] repo root the CLI runs in
|
|
127
|
+
* @param {object} [opts.env]
|
|
128
|
+
* @param {string} [opts.input] stdin, for the CLI's `-` file arguments
|
|
129
|
+
* @param {number} [opts.timeoutMs]
|
|
130
|
+
* @returns {Promise<CliResult>}
|
|
131
|
+
*/
|
|
132
|
+
export function runCli(argv, { cwd = process.cwd(), env = process.env, input, timeoutMs } = {}) {
|
|
133
|
+
const args = (Array.isArray(argv) ? argv : []).map(String)
|
|
134
|
+
if (!args.length) {
|
|
135
|
+
return Promise.reject(new Error('runCli needs at least one interlock subcommand'))
|
|
136
|
+
}
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const child = spawnProcess(process.execPath, [INTERLOCK_BIN, ...args], {
|
|
139
|
+
cwd,
|
|
140
|
+
env,
|
|
141
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
142
|
+
})
|
|
143
|
+
let stdout = ''
|
|
144
|
+
let stderr = ''
|
|
145
|
+
let settled = false
|
|
146
|
+
const timer =
|
|
147
|
+
Number.isInteger(timeoutMs) && timeoutMs > 0
|
|
148
|
+
? setTimeout(() => child.kill('SIGKILL'), timeoutMs)
|
|
149
|
+
: null
|
|
150
|
+
|
|
151
|
+
child.stdout.setEncoding('utf8')
|
|
152
|
+
child.stderr.setEncoding('utf8')
|
|
153
|
+
child.stdout.on('data', chunk => {
|
|
154
|
+
stdout += chunk
|
|
155
|
+
})
|
|
156
|
+
child.stderr.on('data', chunk => {
|
|
157
|
+
stderr += chunk
|
|
158
|
+
})
|
|
159
|
+
child.on('error', err => {
|
|
160
|
+
if (settled) return
|
|
161
|
+
settled = true
|
|
162
|
+
if (timer) clearTimeout(timer)
|
|
163
|
+
reject(err)
|
|
164
|
+
})
|
|
165
|
+
child.on('close', (code, signal) => {
|
|
166
|
+
if (settled) return
|
|
167
|
+
settled = true
|
|
168
|
+
if (timer) clearTimeout(timer)
|
|
169
|
+
resolve({
|
|
170
|
+
code: typeof code === 'number' ? code : 1,
|
|
171
|
+
stdout,
|
|
172
|
+
stderr: signal ? `${stderr}interlock: killed by ${signal}\n` : stderr,
|
|
173
|
+
signal: signal || null
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
child.stdin.end(input === undefined ? '' : String(input))
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Run a vendor CLI to completion with the prompt on stdin, and hand back
|
|
182
|
+
* everything it said.
|
|
183
|
+
*
|
|
184
|
+
* Every adapter under `lib/host/` needs exactly this and nothing more: a
|
|
185
|
+
* subprocess, a prompt written to stdin (design D2 — never argv, so a briefing
|
|
186
|
+
* with inlined rubrics cannot hit an argv limit and never appears in a process
|
|
187
|
+
* listing), and the exit code plus both streams. Three copies of it would be
|
|
188
|
+
* three chances for one adapter to start treating a signal, a timeout or a
|
|
189
|
+
* non-zero exit differently from the others, and the difference would surface as
|
|
190
|
+
* a run that reported a spawn as failed on one host and empty on another.
|
|
191
|
+
*
|
|
192
|
+
* Never rejects for anything the CLI did. A process that could not be started at
|
|
193
|
+
* all resolves with `code: null` and the spawn error on stderr, because the
|
|
194
|
+
* caller's contract is a `null` spawn result either way.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} command
|
|
197
|
+
* @param {string[]} args
|
|
198
|
+
* @param {object} [opts]
|
|
199
|
+
* @param {string} [opts.cwd]
|
|
200
|
+
* @param {object} [opts.env]
|
|
201
|
+
* @param {string} [opts.input] the prompt, written to stdin and closed
|
|
202
|
+
* @param {number} [opts.timeoutMs] 0 or absent disables the wall clock
|
|
203
|
+
* @returns {Promise<{code: number|null, stdout: string, stderr: string, signal: string|null, timedOut: boolean}>}
|
|
204
|
+
*/
|
|
205
|
+
export function execCapture(command, args = [], { cwd, env, input, timeoutMs } = {}) {
|
|
206
|
+
return new Promise(resolve => {
|
|
207
|
+
let child
|
|
208
|
+
try {
|
|
209
|
+
child = spawnProcess(command, Array.isArray(args) ? args.map(String) : [], {
|
|
210
|
+
cwd,
|
|
211
|
+
env,
|
|
212
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
213
|
+
})
|
|
214
|
+
} catch (err) {
|
|
215
|
+
resolve({ code: null, stdout: '', stderr: `${command}: ${(err && err.message) || err}\n`, signal: null, timedOut: false })
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let stdout = ''
|
|
220
|
+
let stderr = ''
|
|
221
|
+
let settled = false
|
|
222
|
+
let timedOut = false
|
|
223
|
+
const budget = Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : 0
|
|
224
|
+
const timer = budget
|
|
225
|
+
? setTimeout(() => {
|
|
226
|
+
timedOut = true
|
|
227
|
+
child.kill('SIGKILL')
|
|
228
|
+
}, budget)
|
|
229
|
+
: null
|
|
230
|
+
|
|
231
|
+
const done = value => {
|
|
232
|
+
if (settled) return
|
|
233
|
+
settled = true
|
|
234
|
+
if (timer) clearTimeout(timer)
|
|
235
|
+
resolve(value)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
child.stdout.setEncoding('utf8')
|
|
239
|
+
child.stderr.setEncoding('utf8')
|
|
240
|
+
child.stdout.on('data', chunk => {
|
|
241
|
+
stdout += chunk
|
|
242
|
+
})
|
|
243
|
+
child.stderr.on('data', chunk => {
|
|
244
|
+
stderr += chunk
|
|
245
|
+
})
|
|
246
|
+
child.on('error', err => {
|
|
247
|
+
done({ code: null, stdout, stderr: `${stderr}${command}: ${(err && err.message) || err}\n`, signal: null, timedOut })
|
|
248
|
+
})
|
|
249
|
+
child.on('close', (code, signal) => {
|
|
250
|
+
done({
|
|
251
|
+
code: typeof code === 'number' ? code : null,
|
|
252
|
+
stdout,
|
|
253
|
+
stderr: timedOut ? `${stderr}${command}: killed after ${budget}ms\n` : stderr,
|
|
254
|
+
signal: signal || null,
|
|
255
|
+
timedOut
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// A stdin the child never reads closes with EPIPE, which is the child's
|
|
260
|
+
// choice and not a transport failure.
|
|
261
|
+
child.stdin.on('error', () => {})
|
|
262
|
+
child.stdin.end(input === undefined ? '' : String(input))
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Run every item of a batch concurrently. The planner already decided how wide
|
|
268
|
+
* a batch may be — splitting colliding tasks into later batches of the same
|
|
269
|
+
* wave — so a host that throttled here would be overriding a policy decision it
|
|
270
|
+
* does not own.
|
|
271
|
+
*
|
|
272
|
+
* A task that throws resolves to `null`, matching the workflow runtime's
|
|
273
|
+
* `agent()` contract: a stopped or unrecoverable agent is a task failure the
|
|
274
|
+
* loop must report, not an exception that unwinds the run.
|
|
275
|
+
*
|
|
276
|
+
* @param {object[]} items
|
|
277
|
+
* @param {(item: object, index: number) => Promise<object|null>} fn
|
|
278
|
+
* @returns {Promise<Array<object|null>>}
|
|
279
|
+
*/
|
|
280
|
+
export function mapPipeline(items, fn) {
|
|
281
|
+
const list = Array.isArray(items) ? items : []
|
|
282
|
+
return Promise.all(
|
|
283
|
+
list.map((item, index) =>
|
|
284
|
+
Promise.resolve()
|
|
285
|
+
.then(() => fn(item, index))
|
|
286
|
+
.then(result => (result === undefined ? null : result))
|
|
287
|
+
.catch(() => null)
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Recover the JSON object an agent was asked to return from whatever it
|
|
294
|
+
* actually said. Hosts whose transport has no schema enforcement (ACP returns
|
|
295
|
+
* streamed text) need this; `null` on failure keeps them on the same contract
|
|
296
|
+
* as a runtime that validates the schema itself.
|
|
297
|
+
*
|
|
298
|
+
* @param {string} text
|
|
299
|
+
* @returns {object|null}
|
|
300
|
+
*/
|
|
301
|
+
export function parseAgentJson(text) {
|
|
302
|
+
if (typeof text !== 'string') return null
|
|
303
|
+
const trimmed = text.trim()
|
|
304
|
+
if (!trimmed) return null
|
|
305
|
+
|
|
306
|
+
const fenced = /```(?:json)?\s*\n([\s\S]*?)```/i.exec(trimmed)
|
|
307
|
+
const candidates = [trimmed]
|
|
308
|
+
if (fenced) candidates.push(fenced[1].trim())
|
|
309
|
+
|
|
310
|
+
for (const candidate of candidates) {
|
|
311
|
+
try {
|
|
312
|
+
const parsed = JSON.parse(candidate)
|
|
313
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed
|
|
314
|
+
} catch {
|
|
315
|
+
// fall through to the brace scan
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Last resort: the first balanced object in the text. Agents prepend prose
|
|
320
|
+
// more often than they malform JSON, and a run should not halt over a
|
|
321
|
+
// greeting. String-aware so a brace inside a summary does not end the scan.
|
|
322
|
+
for (const candidate of candidates) {
|
|
323
|
+
const start = candidate.indexOf('{')
|
|
324
|
+
if (start === -1) continue
|
|
325
|
+
let depth = 0
|
|
326
|
+
let inString = false
|
|
327
|
+
let escaped = false
|
|
328
|
+
for (let i = start; i < candidate.length; i++) {
|
|
329
|
+
const ch = candidate[i]
|
|
330
|
+
if (inString) {
|
|
331
|
+
if (escaped) escaped = false
|
|
332
|
+
else if (ch === '\\') escaped = true
|
|
333
|
+
else if (ch === '"') inString = false
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
if (ch === '"') inString = true
|
|
337
|
+
else if (ch === '{') depth++
|
|
338
|
+
else if (ch === '}') {
|
|
339
|
+
depth--
|
|
340
|
+
if (depth === 0) {
|
|
341
|
+
try {
|
|
342
|
+
const parsed = JSON.parse(candidate.slice(start, i + 1))
|
|
343
|
+
if (parsed && typeof parsed === 'object') return parsed
|
|
344
|
+
} catch {
|
|
345
|
+
// keep scanning; a later balanced object may parse
|
|
346
|
+
}
|
|
347
|
+
break
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return null
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* A host for tests: the spawn side is a stub, the CLI side is the real binary.
|
|
357
|
+
*
|
|
358
|
+
* That asymmetry is the point. Stubbing `runCli` too would let a test assert
|
|
359
|
+
* that the loop halted while proving nothing about *who decided* — and "the
|
|
360
|
+
* host must not compute halt reasons itself" is precisely the requirement these
|
|
361
|
+
* tests exist to hold. So there is no `cli` override: `runCli` always execs
|
|
362
|
+
* `bin/interlock`.
|
|
363
|
+
*
|
|
364
|
+
* @param {object} [opts]
|
|
365
|
+
* @param {string} [opts.cwd] repo root for the CLI
|
|
366
|
+
* @param {object} [opts.env]
|
|
367
|
+
* @param {(req: SpawnRequest, index: number) => (object|null|Promise<object|null>)} [opts.spawn]
|
|
368
|
+
* stub result per spawn; omitted means every agent returns null
|
|
369
|
+
* @returns {WorkflowHost & { spawns: SpawnRequest[], cliCalls: string[][] }}
|
|
370
|
+
*/
|
|
371
|
+
export function createFakeHost({ cwd = process.cwd(), env = process.env, spawn } = {}) {
|
|
372
|
+
const spawns = []
|
|
373
|
+
const cliCalls = []
|
|
374
|
+
|
|
375
|
+
const host = {
|
|
376
|
+
spawns,
|
|
377
|
+
cliCalls,
|
|
378
|
+
async spawn(req) {
|
|
379
|
+
const request = req && typeof req === 'object' ? req : {}
|
|
380
|
+
spawns.push(request)
|
|
381
|
+
if (typeof spawn !== 'function') return null
|
|
382
|
+
const result = await spawn(request, spawns.length - 1)
|
|
383
|
+
return result === undefined ? null : result
|
|
384
|
+
},
|
|
385
|
+
mapPipeline,
|
|
386
|
+
runCli(argv, opts = {}) {
|
|
387
|
+
cliCalls.push((Array.isArray(argv) ? argv : []).map(String))
|
|
388
|
+
return runCli(argv, { cwd, env, ...opts })
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return assertWorkflowHost(host, 'fake host')
|
|
393
|
+
}
|