@renzrollon/interlock 1.1.1 → 1.2.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 (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +94 -1
  3. package/CHANGELOG.md +135 -1
  4. package/bin/interlock +409 -49
  5. package/bin/interlock-run +119 -7
  6. package/hooks/guard-relaunch.mjs +115 -0
  7. package/hooks/guard-tests.mjs +51 -3
  8. package/hooks/hooks.json +1 -0
  9. package/hooks/mod.mjs +655 -0
  10. package/hooks/preflight.mjs +83 -13
  11. package/hooks/recorder.mjs +189 -0
  12. package/lib/agent-usage.mjs +524 -0
  13. package/lib/doctor.mjs +488 -28
  14. package/lib/eval-history.mjs +233 -0
  15. package/lib/evals-capture.mjs +5 -2
  16. package/lib/graph/cli.mjs +28 -5
  17. package/lib/graph/retrieve/context.mjs +8 -3
  18. package/lib/host/acp.mjs +77 -14
  19. package/lib/host/claude-cli.mjs +391 -21
  20. package/lib/host/claude-env.mjs +179 -0
  21. package/lib/host/codex.mjs +9 -1
  22. package/lib/host/effort.mjs +163 -0
  23. package/lib/host/model-map.mjs +43 -0
  24. package/lib/host/qwen.mjs +9 -1
  25. package/lib/host/registry.mjs +44 -10
  26. package/lib/host.mjs +46 -5
  27. package/lib/interrupted.mjs +167 -0
  28. package/lib/launch-ledger.mjs +198 -0
  29. package/lib/launch-rule.mjs +174 -0
  30. package/lib/limits.mjs +149 -41
  31. package/lib/meter-quiet.mjs +45 -0
  32. package/lib/plan-fingerprint.mjs +80 -11
  33. package/lib/prompts/implementer.mjs +1 -1
  34. package/lib/prompts/verify.mjs +24 -1
  35. package/lib/receipt.mjs +59 -8
  36. package/lib/report-html.mjs +89 -6
  37. package/lib/report.mjs +313 -12
  38. package/lib/resume-card.mjs +90 -2
  39. package/lib/run-log.mjs +192 -5
  40. package/lib/run.mjs +1177 -84
  41. package/lib/state-home.mjs +298 -0
  42. package/lib/verify.mjs +37 -0
  43. package/lib/waves.mjs +53 -16
  44. package/package.json +2 -1
  45. package/shared/TEST-PROFILE.md +10 -5
  46. package/skills/fix-tests/SKILL.md +1 -1
  47. package/types/index.d.ts +28 -0
  48. package/workflows/ship.js +75 -51
package/bin/interlock-run CHANGED
@@ -22,8 +22,10 @@
22
22
  // this file runs `git worktree add`; `run record-batch` folds
23
23
  // and removes them, and halts on a real collision (design D7).
24
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).
25
+ // the banners which host, which billing path, which guards are absent,
26
+ // which spawns could not have their model or their effort
27
+ // applied, and whether an exported effort override outranks
28
+ // the plan (design D8; dispatch-published-effort D2, D9, D16).
27
29
  //
28
30
  // Everything else — wave order, caps, verdicts, the review, the receipt — is
29
31
  // behind `runCli` on purpose, so a second host cannot quietly grow a second copy
@@ -36,8 +38,10 @@
36
38
  import { execFileSync } from 'node:child_process'
37
39
  import { mkdirSync, writeFileSync } from 'node:fs'
38
40
  import { dirname, join } from 'node:path'
41
+ import { laneEnv } from '../lib/host.mjs'
39
42
  import { createHost, drivesClaudeBinary, HOST_IDS } from '../lib/host/registry.mjs'
40
43
  import { MODEL_MAP_ENV } from '../lib/host/model-map.mjs'
44
+ import { EFFORT_LEVEL_ENV, effortLevelOverride } from '../lib/host/effort.mjs'
41
45
  import { CODEX_KEY_ENVS, onChatGptPlan } from '../lib/host/codex.mjs'
42
46
 
43
47
  const WORK = '.claude/ship'
@@ -222,6 +226,41 @@ const hostId =
222
226
  // because the adapter is the only party that saw the wire.
223
227
  const routing = []
224
228
 
229
+ // Every spawn that named an effort, and what the adapter managed to do with it.
230
+ // A list of its own beside `routing`, folded the same way: on a negotiated host
231
+ // the outcome differs per spawn, so the verdict is the adapter's event and never
232
+ // one this file derives from the host id or the step (design D2, D27).
233
+ const effortRouting = []
234
+
235
+ // What the host observed about each spawn of the CURRENT step, copied off the
236
+ // adapter's `spawn-done` / `spawn-failed` events as `{ label, ...host }` and
237
+ // handed to the CLI on its own channel, `--host-records`, beside — never inside
238
+ // — the results (design D7). This file reads no field of a record: which lane
239
+ // was stopped, denied or served by another model is the CLI's decision. An
240
+ // adapter that reports no host leaves the list empty and every lane unobserved.
241
+ let hostRecords = []
242
+
243
+ // A runner killed with SIGINT or SIGTERM closes the run through `stop()` like
244
+ // any other halt, so it leaves a receipt, a terminal trajectory event and a
245
+ // resume card instead of a run nobody can reconstruct. Registered before the
246
+ // host is created: a signal that lands during the adapter's synchronous probe is
247
+ // held until the first await — the wait on `run start` — and handled there,
248
+ // rather than killing the process by default. `stopping` makes it one close: a
249
+ // second signal, or a signal during a close already under way, starts nothing.
250
+ // In-flight lane processes are not killed here; each adapter's spawn timeout
251
+ // bounds them.
252
+ let stopping = false
253
+ function onSignal(signal) {
254
+ if (stopping) return
255
+ stopping = true
256
+ stop(`interlock-run received ${signal}`).catch(err => {
257
+ process.stderr.write(`interlock-run: the close after ${signal} failed: ${(err && err.message) || err}\n`)
258
+ process.exit(1)
259
+ })
260
+ }
261
+ process.once('SIGINT', () => onSignal('SIGINT'))
262
+ process.once('SIGTERM', () => onSignal('SIGTERM'))
263
+
225
264
  // Refusal comes BEFORE anything is created or written. A run that started and
226
265
  // then discovered it had no host would already have a manifest, a briefing and a
227
266
  // trajectory describing a run that never happened.
@@ -234,6 +273,7 @@ try {
234
273
  ...(hostId === 'acp' && typeof flags['acp-command'] === 'string'
235
274
  ? { command: flags['acp-command'] }
236
275
  : {}),
276
+ env: laneEnv(process.env),
237
277
  cwd: root,
238
278
  timeoutMs: Number.isInteger(Number.parseInt(String(flags['timeout-ms'] ?? ''), 10))
239
279
  ? Number.parseInt(String(flags['timeout-ms']), 10)
@@ -250,6 +290,25 @@ try {
250
290
  }
251
291
  return
252
292
  }
293
+ if (
294
+ event &&
295
+ (event.type === 'spawn-done' || event.type === 'spawn-failed') &&
296
+ event.host &&
297
+ typeof event.host === 'object'
298
+ ) {
299
+ hostRecords.push({ label: event.label, ...event.host })
300
+ }
301
+ if (event && event.type === 'effort-routing') {
302
+ effortRouting.push(event)
303
+ if (verbose) {
304
+ process.stderr.write(
305
+ `${hostId}: effort routing ${event.label}: ${event.requested} → ` +
306
+ (event.applied ? `${event.value} via ${event.via}` : `not applied (${event.reason})`) +
307
+ '\n'
308
+ )
309
+ }
310
+ return
311
+ }
253
312
  if (verbose) process.stderr.write(`${hostId}: ${JSON.stringify(event)}\n`)
254
313
  }
255
314
  })
@@ -268,13 +327,29 @@ const capabilities = host.capabilities || {}
268
327
  // them in one block with everything else the run degraded on (design D8).
269
328
  const banners = [`RUNNER HOST: ${hostId} (experimental)`]
270
329
 
271
- if (drivesClaudeBinary(hostId, process.env, typeof flags['acp-command'] === 'string' ? flags['acp-command'] : undefined)) {
330
+ const claudeBinary = drivesClaudeBinary(
331
+ hostId,
332
+ process.env,
333
+ typeof flags['acp-command'] === 'string' ? flags['acp-command'] : undefined
334
+ )
335
+ if (claudeBinary) {
272
336
  banners.push(
273
337
  `SUBSCRIPTION PATH: programmatic (${hostId}) — claude -p, the Agent SDK and ACP are the usage ` +
274
338
  `Anthropic has flagged for separate billing; /interlock:ship on the Workflow runtime is the ` +
275
339
  `exempted path, see docs/04-when-it-stops.md`
276
340
  )
277
341
  }
342
+ // The Claude CLI's effort variable outranks `--effort` and every per-agent
343
+ // effort, so while it is set the plan's effort is not in effect. It means
344
+ // nothing to a host that is not the Claude binary (D22). It is the operator's
345
+ // environment: bannered, never stripped from the child's (D26).
346
+ const effortOverride = claudeBinary ? effortLevelOverride(process.env) : ''
347
+ if (effortOverride) {
348
+ banners.push(
349
+ `EFFORT ROUTING OVERRIDDEN: ${EFFORT_LEVEL_ENV}=${effortOverride} — every agent runs at ` +
350
+ `that effort, so the per-step effort in the plan is not in effect`
351
+ )
352
+ }
278
353
  if (hostId === 'codex' && onChatGptPlan(process.env)) {
279
354
  banners.push(
280
355
  `CHATGPT PLAN PATH (codex): neither ${CODEX_KEY_ENVS.join(' nor ')} is set, so this run spends ` +
@@ -282,6 +357,12 @@ if (hostId === 'codex' && onChatGptPlan(process.env)) {
282
357
  `unattended volume to an API key`
283
358
  )
284
359
  }
360
+ // Whatever the adapter itself degraded on at creation — on the claude host, a
361
+ // mode that can prompt on a CLI that cannot be told not to (design D10). The
362
+ // adapter wrote the words; this file only carries them to the close.
363
+ for (const banner of Array.isArray(host.banners) ? host.banners : []) {
364
+ if (typeof banner === 'string' && banner) banners.push(banner)
365
+ }
285
366
  if (capabilities.hooks === false) {
286
367
  banners.push(
287
368
  `HOOKS NOT IN FORCE (${hostId}): this repository's PreToolUse guards are Claude Code's, so ` +
@@ -310,6 +391,27 @@ function routingBanners() {
310
391
  ]
311
392
  }
312
393
 
394
+ /**
395
+ * What the run has to say about effort routing, read off the adapter's events.
396
+ *
397
+ * The model twin's shape, for the same reason: a run on which every named
398
+ * effort was applied says so, and a run on which none was named says nothing.
399
+ * Each banner line states the level as requested, taken from the event, so a
400
+ * host that could not apply an effort cannot be read as one that ran at it.
401
+ */
402
+ function effortBanners() {
403
+ if (!effortRouting.length) return []
404
+ const unapplied = effortRouting.filter(event => event.applied === false)
405
+ if (!unapplied.length) {
406
+ return [`effort routing: applied on ${effortRouting.length}/${effortRouting.length} spawns`]
407
+ }
408
+ return [
409
+ `EFFORT ROUTING UNAVAILABLE (${hostId}): the plan's per-step effort assignment is not in effect ` +
410
+ `for the spawns below — each ran at the host's own default`,
411
+ ...unapplied.map(event => ` — ${event.label}: ${event.requested} requested, ${event.reason}`)
412
+ ]
413
+ }
414
+
313
415
  let resolvedChange = changeArg || '(unresolved)'
314
416
  let steps = 0
315
417
 
@@ -323,6 +425,9 @@ function write(rel, data) {
323
425
 
324
426
  /** Run the CLI through the host port and parse `--json` stdout when present. */
325
427
  async function cli(argv) {
428
+ // From the first close on, a signal starts no second one — whichever path
429
+ // reached the close: the loop's own continuation, or `stop()`.
430
+ if (argv[0] === 'run' && argv[1] === 'close') stopping = true
326
431
  const result = await host.runCli([...argv, '--json'])
327
432
  if (!result.stdout.trim()) return null
328
433
  try {
@@ -389,6 +494,9 @@ function spawnOne(s) {
389
494
  prompt: s.prompt,
390
495
  schema: s.schema,
391
496
  ...(s.model ? { model: s.model } : {}),
497
+ // Forwarded exactly as the step carries it, and only when it names one: a
498
+ // null effort reaches the host as no key at all, so it keeps its default.
499
+ ...(s.effort ? { effort: s.effort } : {}),
392
500
  ...(s.worktree && s.worktree.path ? { cwd: join(root, s.worktree.path) } : {}),
393
501
  type: s.type,
394
502
  tools: s.tools
@@ -442,9 +550,11 @@ while (step && step.then) {
442
550
  const worktreeProblem = createWorktrees(step)
443
551
  if (worktreeProblem) await stop(worktreeProblem)
444
552
 
445
- for (const s of step.spawns || []) log(` ${step.action}: ${s.label}`)
553
+ for (const s of step.spawns || []) log(` ${step.action}: ${s.title || s.label}`)
554
+ hostRecords = []
446
555
  const results = await spawnAll(step)
447
556
  write(`${WORK}/results.json`, results)
557
+ write(`${WORK}/host-records.json`, hostRecords)
448
558
  // The close is the one continuation that needs more than the spawn results:
449
559
  // it reads the banners only this host could raise. Appended here as well as
450
560
  // at `stop()`, so a CLEAN run reports them too — the path least likely to
@@ -455,6 +565,8 @@ while (step && step.then) {
455
565
  ...(isClose ? closeArgs() : []),
456
566
  '--results',
457
567
  `${WORK}/results.json`,
568
+ '--host-records',
569
+ `${WORK}/host-records.json`,
458
570
  '--root',
459
571
  '.'
460
572
  ])
@@ -488,13 +600,13 @@ async function stop(reason) {
488
600
  /** The flags the close reads for what only this host observed. */
489
601
  function closeArgs() {
490
602
  // 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.
603
+ // question it answers — did EVERY spawn get its model, and its effort — is
604
+ // only answerable once the last spawn has returned.
493
605
  //
494
606
  // `--notify` is unconditional: this driver knows nothing about topics,
495
607
  // servers or notification text. It requests the push; the CLI decides
496
608
  // whether one is configured and what it says (design D1).
497
- return ['--host-banners', write(`${WORK}/host-banners.json`, [...banners, ...routingBanners()]), '--notify']
609
+ return ['--host-banners', write(`${WORK}/host-banners.json`, [...banners, ...routingBanners(), ...effortBanners()]), '--notify']
498
610
  }
499
611
 
500
612
  /** Print the close's summary and exit on its status. */
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ // Launch guard: a second ship launch in one session, with no human prompt since
3
+ // the last, is denied (guard-ship-relaunch; spec: hooks/launch-guard).
4
+ //
5
+ // The most expensive recorded mistake was a parent chat calling Workflow again
6
+ // over leftover checkboxes — another 20+ agents, a full review cycle included —
7
+ // and the skill's sentence forbidding it was the only thing in the way. This
8
+ // bounds it outside the model. The prose and `evals/trampoline-launch` stay: the
9
+ // eval measures whether the model obeys, this bounds what happens when it does
10
+ // not.
11
+ //
12
+ // ONE SCRIPT, FOUR REGISTRATIONS, dispatching on `hook_event_name`:
13
+ //
14
+ // PreToolUse (Workflow) the only branch that may deny: a ship launch whose
15
+ // session ledger holds a launch newer than the last
16
+ // human prompt.
17
+ // PostToolUse (Workflow) records a ship launch the runtime accepted, then
18
+ // sweeps ledgers past the published age.
19
+ // UserPromptSubmit, record the time of a human prompt — only when the
20
+ // UserPromptExpansion session already has a ledger. The host's completion
21
+ // wake fires UserPromptSubmit too; it is not a human
22
+ // and is not counted (lib/launch-ledger.mjs).
23
+ //
24
+ // EVERY UNKNOWN ALLOWS: no session id, an unsafe one, a missing, malformed or
25
+ // stale ledger, a tool input that is not a ship launch, a tool that is not
26
+ // Workflow, an event with no branch, and the guard's own crash. A false deny
27
+ // costs one typed message; that is still a denial, so it is the only branch that
28
+ // can speak one. The recorder branches print no decision on any path; a ledger
29
+ // write that fails is a stderr line, because a guard that silently stopped
30
+ // recording is a guard that silently stopped denying.
31
+ //
32
+ // The rule and its words live in lib/launch-ledger.mjs. This file is plumbing.
33
+
34
+ import {
35
+ decideLaunch,
36
+ isAcceptedLaunch,
37
+ isHumanPrompt,
38
+ isShipLaunch,
39
+ readLedger,
40
+ recordLaunch,
41
+ recordPrompt,
42
+ sweepStale
43
+ } from '../lib/launch-ledger.mjs'
44
+ import { readEvent, allow, deny, toolName, toolInput, projectRoot } from './_shared.mjs'
45
+
46
+ const GUARD = 'guard-relaunch'
47
+
48
+ /** The session id as the host sent it, or '' — an unsafe one is refused by the ledger module. */
49
+ const sessionOf = event => (typeof event.session_id === 'string' ? event.session_id.trim() : '')
50
+
51
+ function onPreToolUse(event) {
52
+ if (toolName(event) !== 'Workflow') allow()
53
+ const sessionId = sessionOf(event)
54
+ if (!sessionId) allow()
55
+ const root = projectRoot(event)
56
+
57
+ const problems = []
58
+ const ledger = readLedger(root, sessionId, { onProblem: message => problems.push(message) })
59
+ if (!isShipLaunch(toolInput(event), ledger)) allow()
60
+ if (problems.length) allow(`${GUARD}: ${problems.join('; ')} — allowing`)
61
+
62
+ const verdict = decideLaunch(ledger)
63
+ if (verdict.decision !== 'deny') allow()
64
+ deny(verdict.reason, {
65
+ guard: GUARD,
66
+ sessionId,
67
+ lastLaunchAt: verdict.lastLaunchAt,
68
+ lastHumanPromptAt: verdict.lastHumanPromptAt
69
+ })
70
+ }
71
+
72
+ function onPostToolUse(event) {
73
+ if (toolName(event) !== 'Workflow') return
74
+ const sessionId = sessionOf(event)
75
+ if (!sessionId) return
76
+ const root = projectRoot(event)
77
+
78
+ if (!isShipLaunch(toolInput(event), readLedger(root, sessionId))) return
79
+ if (!isAcceptedLaunch(event.tool_response)) return
80
+ const written = recordLaunch(root, sessionId, event.tool_response)
81
+ if (!written.written) {
82
+ process.stderr.write(`${GUARD}: ${written.reason}\n`)
83
+ return
84
+ }
85
+ sweepStale(root, { keep: sessionId })
86
+ }
87
+
88
+ function onPrompt(event) {
89
+ const sessionId = sessionOf(event)
90
+ if (!sessionId || !isHumanPrompt(event.prompt)) return
91
+ const written = recordPrompt(projectRoot(event), sessionId)
92
+ // Only a ledger that exists and failed to take the write is worth a line;
93
+ // "no ledger for this session" is every prompt in every repository.
94
+ if (!written.written && written.reason && written.reason.startsWith('launch ledger not written')) {
95
+ process.stderr.write(`${GUARD}: ${written.reason}\n`)
96
+ }
97
+ }
98
+
99
+ const BRANCHES = {
100
+ PreToolUse: onPreToolUse,
101
+ PostToolUse: onPostToolUse,
102
+ UserPromptSubmit: onPrompt,
103
+ UserPromptExpansion: onPrompt
104
+ }
105
+
106
+ try {
107
+ const event = await readEvent()
108
+ const branch = BRANCHES[event.hook_event_name]
109
+ if (branch) branch(event)
110
+ } catch (err) {
111
+ // The guard's own crash must never block a launch or a prompt. Allow, and
112
+ // leave a diagnostic on stderr rather than a wedged session.
113
+ allow(`${GUARD}: internal error, allowing by default: ${(err && err.message) || String(err)}`)
114
+ }
115
+ process.exit(0)
@@ -14,8 +14,19 @@
14
14
  // whenever the marker is missing would break ordinary TDD the moment the plugin
15
15
  // is installed — a cost strictly larger than the guard's benefit, which exists
16
16
  // only inside a ship run's remediation window.
17
+ //
18
+ // A linked worktree has no test profile of its own. `run start` records the one
19
+ // it found — the root's, else the main checkout's — as the manifest's
20
+ // `testProfilePath`, and when the root has none the guard reads that file, so a
21
+ // worktree run keeps its profile-derived test roots. It is read from the
22
+ // manifest, never found by running version control. A recorded path that cannot
23
+ // be read leaves the `.test.`/`.spec.` filename rule alone: the run had a
24
+ // profile, so test files exist, and the filename shape needs no roots. A
25
+ // manifest that records no path changes nothing.
17
26
 
18
- import { readStage } from '../lib/ship-stage.mjs'
27
+ import { readFileSync } from 'node:fs'
28
+ import { join, resolve } from 'node:path'
29
+ import { readStage, SHIP_DIR } from '../lib/ship-stage.mjs'
19
30
  import {
20
31
  readEvent,
21
32
  allow,
@@ -32,6 +43,33 @@ import {
32
43
  const GUARD = 'guard-tests'
33
44
  const BLOCKED_STAGES = new Set(['remediation', 'fix-tests'])
34
45
 
46
+ /**
47
+ * The test profile the run recorded at start, read through the manifest's
48
+ * `testProfilePath`. `recorded: false` when there is no manifest or it records
49
+ * no path — the guard then behaves exactly as it did without one. `profile:
50
+ * null` with `recorded: true`, and the reason, when the path names nothing
51
+ * readable as JSON.
52
+ *
53
+ * @returns {{ recorded: boolean, profile: object|null, reason?: string }}
54
+ */
55
+ function recordedTestProfile(root) {
56
+ let manifest
57
+ try {
58
+ manifest = JSON.parse(readFileSync(join(root, SHIP_DIR, 'run.json'), 'utf8'))
59
+ } catch {
60
+ return { recorded: false, profile: null }
61
+ }
62
+ const path = manifest && typeof manifest === 'object' ? manifest.testProfilePath : null
63
+ if (typeof path !== 'string' || !path) return { recorded: false, profile: null }
64
+ try {
65
+ const value = JSON.parse(readFileSync(resolve(root, path), 'utf8'))
66
+ if (value && typeof value === 'object') return { recorded: true, profile: value }
67
+ return { recorded: true, profile: null, reason: `${path}: not a profile object` }
68
+ } catch (err) {
69
+ return { recorded: true, profile: null, reason: `${path}: ${(err && err.message) || String(err)}` }
70
+ }
71
+ }
72
+
35
73
  try {
36
74
  const event = await readEvent()
37
75
  const tool = toolName(event)
@@ -44,8 +82,18 @@ try {
44
82
  const target = editTargetPath(event)
45
83
  if (!target) allow() // no concrete path to protect
46
84
 
47
- const { roots, hasProfile } = testRootsFromProfile(loadTestProfile(root))
48
- if (!hasProfile) allow() // no profile → nothing names where tests live → allow
85
+ const fromRoot = testRootsFromProfile(loadTestProfile(root))
86
+ let roots = fromRoot.roots
87
+ if (!fromRoot.hasProfile) {
88
+ const recorded = recordedTestProfile(root)
89
+ if (!recorded.recorded) allow() // no profile, none recorded → nothing names where tests live → allow
90
+ // The run's profile, derived exactly as a root profile is; unreadable → no
91
+ // roots, and the filename rule alone — said, not silent.
92
+ if (!recorded.profile) {
93
+ process.stderr.write(`${GUARD}: the test profile the run recorded could not be read, so only the filename rule applies: ${recorded.reason}\n`)
94
+ }
95
+ roots = testRootsFromProfile(recorded.profile).roots
96
+ }
49
97
 
50
98
  if (!isTestPath(target, roots, root)) allow() // not a test file → allow (source repair is expected)
51
99
 
@@ -0,0 +1 @@
1
+ { "hooks": {}, "modules": ["./mod.mjs"] }