@renzrollon/interlock 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +58 -0
  3. package/CHANGELOG.md +380 -0
  4. package/LICENSE +21 -0
  5. package/README.md +358 -0
  6. package/agents/ping.md +8 -0
  7. package/agents/worker.md +8 -0
  8. package/bin/interlock +2655 -0
  9. package/bin/interlock-graph +6 -0
  10. package/bin/interlock-run +508 -0
  11. package/bin/interlock-ship-acp +39 -0
  12. package/hooks/_shared.mjs +162 -0
  13. package/hooks/guard-commit.mjs +77 -0
  14. package/hooks/guard-tasks.mjs +107 -0
  15. package/hooks/guard-tests.mjs +63 -0
  16. package/hooks/preflight.mjs +107 -0
  17. package/lib/artifacts.mjs +316 -0
  18. package/lib/autonomy.mjs +149 -0
  19. package/lib/conformance.mjs +132 -0
  20. package/lib/doctor.mjs +1176 -0
  21. package/lib/drift.mjs +409 -0
  22. package/lib/eval-history.mjs +304 -0
  23. package/lib/evals-calibrate.mjs +317 -0
  24. package/lib/evals-capture.mjs +437 -0
  25. package/lib/evals-promote.mjs +257 -0
  26. package/lib/evals-triage.mjs +216 -0
  27. package/lib/findings.mjs +340 -0
  28. package/lib/graph/cli.mjs +266 -0
  29. package/lib/graph/extract/config-json.mjs +74 -0
  30. package/lib/graph/extract/create-extractor.mjs +35 -0
  31. package/lib/graph/extract/memory.mjs +99 -0
  32. package/lib/graph/extract/openspec.mjs +80 -0
  33. package/lib/graph/extract/python.mjs +137 -0
  34. package/lib/graph/extract/regex-fallback.mjs +187 -0
  35. package/lib/graph/extract/shell.mjs +71 -0
  36. package/lib/graph/extract/typescript.mjs +217 -0
  37. package/lib/graph/extract/walk.mjs +272 -0
  38. package/lib/graph/format.mjs +51 -0
  39. package/lib/graph/graph/build.mjs +374 -0
  40. package/lib/graph/graph/query.mjs +249 -0
  41. package/lib/graph/graph/report.mjs +122 -0
  42. package/lib/graph/graph/update.mjs +47 -0
  43. package/lib/graph/paths.mjs +66 -0
  44. package/lib/graph/retrieve/context.mjs +86 -0
  45. package/lib/graph/retrieve/docs-index.mjs +116 -0
  46. package/lib/graph/retrieve/markdown.mjs +246 -0
  47. package/lib/host/acp.mjs +551 -0
  48. package/lib/host/claude-cli.mjs +285 -0
  49. package/lib/host/codex.mjs +231 -0
  50. package/lib/host/model-map.mjs +129 -0
  51. package/lib/host/qwen.mjs +176 -0
  52. package/lib/host/registry.mjs +213 -0
  53. package/lib/host.mjs +393 -0
  54. package/lib/ledger.mjs +444 -0
  55. package/lib/limits.mjs +525 -0
  56. package/lib/locators.mjs +99 -0
  57. package/lib/merge-lanes.mjs +148 -0
  58. package/lib/metrics.mjs +99 -0
  59. package/lib/notify.mjs +229 -0
  60. package/lib/outcomes.mjs +560 -0
  61. package/lib/plan-fingerprint.mjs +570 -0
  62. package/lib/project-slug.mjs +18 -0
  63. package/lib/prompts/commit.mjs +26 -0
  64. package/lib/prompts/dimensions.mjs +70 -0
  65. package/lib/prompts/handoff.mjs +86 -0
  66. package/lib/prompts/implementer.mjs +150 -0
  67. package/lib/prompts/index.mjs +250 -0
  68. package/lib/prompts/planner.mjs +70 -0
  69. package/lib/prompts/remediate.mjs +137 -0
  70. package/lib/prompts/replan.mjs +23 -0
  71. package/lib/prompts/review.mjs +107 -0
  72. package/lib/prompts/schemas.mjs +206 -0
  73. package/lib/prompts/stage.mjs +48 -0
  74. package/lib/prompts/verify.mjs +88 -0
  75. package/lib/ready.mjs +812 -0
  76. package/lib/receipt.mjs +434 -0
  77. package/lib/remediate.mjs +275 -0
  78. package/lib/report-html.mjs +623 -0
  79. package/lib/report.mjs +1124 -0
  80. package/lib/review-core.mjs +713 -0
  81. package/lib/risk.mjs +370 -0
  82. package/lib/run-log.mjs +1004 -0
  83. package/lib/run-paths.mjs +239 -0
  84. package/lib/run.mjs +2921 -0
  85. package/lib/ship-stage.mjs +223 -0
  86. package/lib/spill.mjs +150 -0
  87. package/lib/surface.mjs +195 -0
  88. package/lib/verify.mjs +1074 -0
  89. package/lib/waves.mjs +2661 -0
  90. package/package.json +40 -0
  91. package/shared/CONTEXT-HYGIENE.md +84 -0
  92. package/shared/DECISION-LEDGER.md +141 -0
  93. package/shared/EXPLORE-BRIEF.md +96 -0
  94. package/shared/INVARIANT-SWEEP.md +57 -0
  95. package/shared/SESSION-SHAPES.md +261 -0
  96. package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
  97. package/shared/TEST-PROFILE.md +204 -0
  98. package/shared/TOOL-ECONOMY.md +155 -0
  99. package/skills/bootstrap/SKILL.md +226 -0
  100. package/skills/commit/SKILL.md +99 -0
  101. package/skills/dispatch/SKILL.md +78 -0
  102. package/skills/docs-digest/SKILL.md +109 -0
  103. package/skills/evals/SKILL.md +160 -0
  104. package/skills/explain-code/SKILL.md +42 -0
  105. package/skills/explain-code/commit-teachin.md +136 -0
  106. package/skills/explain-code/module-walkthrough.md +358 -0
  107. package/skills/explore/SKILL.md +159 -0
  108. package/skills/fix-tests/SKILL.md +120 -0
  109. package/skills/graph/SKILL.md +136 -0
  110. package/skills/manual-test-plan/SKILL.md +121 -0
  111. package/skills/mr/SKILL.md +170 -0
  112. package/skills/report/SKILL.md +121 -0
  113. package/skills/review-artifacts/SKILL.md +127 -0
  114. package/skills/review-code/REVIEW.template.md +49 -0
  115. package/skills/review-code/SKILL.md +156 -0
  116. package/skills/review-code/dimensions/architecture.md +10 -0
  117. package/skills/review-code/dimensions/devops.md +12 -0
  118. package/skills/review-code/dimensions/language.md +12 -0
  119. package/skills/review-code/dimensions/qa.md +12 -0
  120. package/skills/review-code/dimensions/security.md +12 -0
  121. package/skills/review-code/dimensions/technical-lead.md +19 -0
  122. package/skills/ship/SKILL.md +67 -0
  123. package/skills/spec/SKILL.md +264 -0
  124. package/skills/spec/continuity.md +66 -0
  125. package/workflows/ship.js +565 -0
package/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
+ }