@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 CHANGED
@@ -14,9 +14,22 @@
14
14
  // own — only the agents it spawns run commands. So a policy module that is not
15
15
  // wired to a subcommand here is a policy the model will improvise in prose.
16
16
 
17
- import { execFileSync } from 'node:child_process'
18
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
19
- import { dirname, join } from 'node:path'
17
+ import { execFileSync, spawnSync } from 'node:child_process'
18
+ import {
19
+ appendFileSync,
20
+ copyFileSync,
21
+ existsSync,
22
+ mkdirSync,
23
+ mkdtempSync,
24
+ readdirSync,
25
+ readFileSync,
26
+ realpathSync,
27
+ renameSync,
28
+ rmSync,
29
+ writeFileSync
30
+ } from 'node:fs'
31
+ import { dirname, isAbsolute, join, relative, sep } from 'node:path'
32
+ import { constants as osConstants, cpus, tmpdir } from 'node:os'
20
33
  import { performance } from 'node:perf_hooks'
21
34
  import {
22
35
  planWaves,
@@ -33,8 +46,11 @@ import {
33
46
  } from '../lib/waves.mjs'
34
47
  import {
35
48
  CLASSIFIED_PATH,
49
+ LAST_STEP_PATH,
50
+ VERIFY_TIMINGS_PATH,
36
51
  checkSequence,
37
52
  readManifest,
53
+ relayStep,
38
54
  runClassified,
39
55
  runClose,
40
56
  runCommit,
@@ -45,9 +61,13 @@ import {
45
61
  runReplan,
46
62
  runReviewed,
47
63
  runStart,
48
- runVerifyFinal
64
+ runVerifyFinal,
65
+ writeManifest
49
66
  } from '../lib/run.mjs'
50
67
  import { haltStep } from '../lib/run.mjs'
68
+ import { explicitStateHome, resolveStateHome } from '../lib/state-home.mjs'
69
+ import { probeClaudeEffort, probeClaudeVersion } from '../lib/host/claude-cli.mjs'
70
+ import { observeClaudeEnv, runtimeSlotsOf } from '../lib/host/claude-env.mjs'
51
71
  import { composeCheckpointMessage, postNtfy, readNotifyConfig, NOTIFY_TOPIC_ENV } from '../lib/notify.mjs'
52
72
  import { classifySurface } from '../lib/surface.mjs'
53
73
  import { evaluateGate, formatGate, TOLERANCE_BAND } from '../lib/findings.mjs'
@@ -172,6 +192,9 @@ Usage:
172
192
  interlock run close [--halt <reason>] names, writes the results, calls
173
193
  [--host-banners <f>]
174
194
  [--host-observed <f>]
195
+ (record-batch, judge, reviewed, remediated, commit, replan and close also
196
+ take --host-records <f>: the runner's per-spawn host observations, beside
197
+ --results and never inside them)
175
198
  \`then.argv\`, and repeats until
176
199
  \`then\` is null. It decides nothing:
177
200
  every branch the loop has (apply-only,
@@ -267,6 +290,8 @@ Usage:
267
290
  interlock verify unit --result <file> Judge a unit result on its own
268
291
  interlock verify spill --run-id <id> --kind <k> --input <f>
269
292
  Spill oversized stdout/stderr to disk
293
+ interlock verify exec --kind <k> --command <cmd>
294
+ Run one planned check and time it
270
295
  interlock verify cluster --failures <f> Group failures by root-cause signature
271
296
  interlock verify repair --state <file> Decide repair / halt / accept next
272
297
  interlock plan fingerprint [change] Fingerprint a change's planning inputs
@@ -288,6 +313,10 @@ Usage:
288
313
  Common options:
289
314
  --json Emit JSON instead of human-readable text
290
315
  --root <dir> Repo root (default: cwd)
316
+ --state-home <dir> Where corpora (trajectories, outcomes, metrics, resume
317
+ cards, interrupted notes, autonomy ledger) are read and
318
+ written; default INTERLOCK_STATE_HOME, else the main
319
+ checkout when --root is a linked worktree, else --root
291
320
  Any <file> argument accepts "-" to read that input from stdin. Only one input
292
321
  per invocation may be "-", since stdin can only be drained once.
293
322
 
@@ -645,6 +674,17 @@ verify spill:
645
674
  head/tail slice bounded by \`interlock limits\`' verify preview budget.
646
675
  Always exits 0.
647
676
 
677
+ verify exec:
678
+ --kind <k> The planned step this runs, e.g. typecheck/unit/lint (required)
679
+ --command <cmd> The planned command, verbatim; run through the shell (required)
680
+ --cwd <dir> Directory to run it in (default: the current one)
681
+ Runs the command with its stdout, stderr and exit code passed through
682
+ unchanged, and appends { kind, exitCode, durationMs, command, at } to
683
+ .claude/ship/verify-timings.jsonl. \`run judge\` folds those durations into
684
+ the inter-wave verify budget, so the budget counts the checks themselves
685
+ rather than the relay and agent time around them. A failed append is a
686
+ stderr warning; the exit code is always the command's.
687
+
648
688
  plan:
649
689
  fingerprint [change] Hash the inputs a plan for this change is derived from:
650
690
  proposal.md, design.md, tasks.md (with checkbox markers
@@ -828,6 +868,51 @@ function readStdin() {
828
868
  }
829
869
  }
830
870
 
871
+ /**
872
+ * The explicit state home, if one was given: `--state-home <dir>`, else
873
+ * `INTERLOCK_STATE_HOME` (ship/state-home). `--root` keeps naming the working
874
+ * tree; this names where its corpora go.
875
+ */
876
+ function explicitHome(flags) {
877
+ return explicitStateHome({
878
+ flag: typeof flags['state-home'] === 'string' ? flags['state-home'] : undefined,
879
+ env: process.env
880
+ })
881
+ }
882
+
883
+ /**
884
+ * The state home a run starts with: the explicit value, else the resolver. Never
885
+ * a manifest already at the root — that is the PREVIOUS run's.
886
+ */
887
+ function startStateHome(root, flags) {
888
+ return resolveStateHome(root, { explicit: explicitHome(flags) || undefined, env: {} })
889
+ }
890
+
891
+ /**
892
+ * The state home a corpus command reads and writes (design D14): the home the
893
+ * live run at this root recorded, so a lane's `wave-state` append or a report
894
+ * run mid-run never disagrees with the run's own; else the explicit value; else
895
+ * the resolver. Resolved once per invocation and handed to every corpus reader
896
+ * and writer the command calls.
897
+ *
898
+ * @returns {{home: string, surface: string, reason: string|null, resolvedFrom: string}}
899
+ */
900
+ function corpusHome(root, flags) {
901
+ const explicit = explicitHome(flags)
902
+ if (!explicit) {
903
+ const manifest = readManifest(root)
904
+ if (manifest && typeof manifest.stateHome === 'string' && manifest.stateHome) {
905
+ return {
906
+ home: manifest.stateHome,
907
+ surface: typeof manifest.surface === 'string' ? manifest.surface : 'unknown',
908
+ reason: typeof manifest.stateHomeReason === 'string' ? manifest.stateHomeReason : null,
909
+ resolvedFrom: typeof manifest.stateHomeFrom === 'string' ? manifest.stateHomeFrom : 'git'
910
+ }
911
+ }
912
+ }
913
+ return resolveStateHome(root, { explicit: explicit || undefined, env: {} })
914
+ }
915
+
831
916
  function readInput(value, what) {
832
917
  if (!value || value === true) die(`${what} is required`)
833
918
  const raw = value === '-' ? readStdin() : readFileSync(value, 'utf8')
@@ -877,6 +962,51 @@ function emit(json, obj, text) {
877
962
  process.stdout.write(json ? JSON.stringify(obj, null, 2) + '\n' : text)
878
963
  }
879
964
 
965
+ /**
966
+ * Print a `run` step, shaped for the host that will read it, and keep a copy of
967
+ * the exact bytes printed.
968
+ *
969
+ * `relay: true` routes the step through `relayStep`, which slims it for the
970
+ * Workflow host — the one host whose stdout is retyped by a model rather than
971
+ * read by a process. The close does not come through here at all: its summary
972
+ * is what it is for, and it must not overwrite the copy a halt points at.
973
+ *
974
+ * The copy at LAST_STEP_PATH is forensic, not a corpus: when a relay's copy
975
+ * does not parse, the halt points here instead of at a transcript. A failed
976
+ * write is spoken on stderr and never touches the exit code — losing the copy
977
+ * must not lose the step.
978
+ */
979
+ /**
980
+ * The machine's CPU count, or null when the platform will not say. The vendor
981
+ * reduces the Workflow runtime's concurrency on small machines by an amount it
982
+ * does not publish, so this is recorded beside the default and never turned
983
+ * into a number of its own.
984
+ */
985
+ function cpuCount() {
986
+ try {
987
+ const n = cpus().length
988
+ return Number.isInteger(n) && n > 0 ? n : null
989
+ } catch {
990
+ return null
991
+ }
992
+ }
993
+
994
+ function emitRunStep(json, root, step, text, { relay = false } = {}) {
995
+ if (!json) {
996
+ process.stdout.write(text)
997
+ return
998
+ }
999
+ const shaped = relay ? relayStep(readManifest(root), step) : step
1000
+ const out = JSON.stringify(shaped, null, 2) + '\n'
1001
+ try {
1002
+ mkdirSync(join(root, dirname(LAST_STEP_PATH)), { recursive: true })
1003
+ writeFileSync(join(root, LAST_STEP_PATH), out)
1004
+ } catch (err) {
1005
+ process.stderr.write(`interlock: ${LAST_STEP_PATH} not written: ${(err && err.message) || err}\n`)
1006
+ }
1007
+ process.stdout.write(out)
1008
+ }
1009
+
880
1010
  /**
881
1011
  * record-* / replan: default stdout is the new state. --write-state writes that
882
1012
  * state to a file and stdout becomes nextStep() — the shape `wave-state next`
@@ -934,13 +1064,18 @@ function changeFor(state, flagChange) {
934
1064
  return changeOf(state) || flag
935
1065
  }
936
1066
 
937
- // `wave-state next` / `--write-state` both surface the state machine's next
938
- // step. A `run-batch`/`test-wave` step carries `tasks[]` (the current batch, as
939
- // LANES) and `remainingBatches` (rest of the wave, as batches of lanes). ship.js
940
- // runs remainingBatches sequentially before the next record ping, so the
941
- // reconstructable spawn list is remainingBatches — not only tasks[]. Mid-wave
942
- // record-batch --write-state is cursor catch-up and MUST NOT duplicate those
943
- // events.
1067
+ // The `wave-state` protocol: `wave-state next` / `--write-state` both surface
1068
+ // the state machine's next step. A `run-batch`/`test-wave` step carries
1069
+ // `tasks[]` (the current batch, as LANES) and `remainingBatches` (rest of the
1070
+ // wave, as batches of lanes). A caller of that protocol runs remainingBatches
1071
+ // sequentially before its next record call, so the reconstructable spawn list
1072
+ // is remainingBatches — not only tasks[]. Mid-wave record-batch --write-state
1073
+ // is cursor catch-up and MUST NOT duplicate those events.
1074
+ //
1075
+ // The `run` program is NOT that protocol: it emits one step per batch, so it
1076
+ // passes the batch's own `lanes` and gets exactly those logged (see
1077
+ // `logLaneSpawns` in lib/run.mjs). Logging a whole wave there wrote spawn events
1078
+ // for lanes a halted run never dispatched.
944
1079
  //
945
1080
  // One spawn per LANE, because a lane is one agent. Counting per task would
946
1081
  // report four spawns for a wave that ran two, which is the fold this change
@@ -960,13 +1095,21 @@ function spawnLanesOf(step) {
960
1095
  // Takes the run's `state` rather than a runId, so the change name is resolved
961
1096
  // from it the same way `logWaveMutation` does instead of being threaded in by a
962
1097
  // caller who may not have been given one.
963
- function logAgentSpawns(root, { state, change, step, source }) {
964
- if (!step || (step.action !== 'run-batch' && step.action !== 'test-wave')) return true
965
- if (source === 'record-batch' && Number(step.batchIndex) > 0) return true
1098
+ function logAgentSpawns(root, { state, change, step, source, lanes }) {
1099
+ const explicit = Array.isArray(lanes)
1100
+ if (!explicit) {
1101
+ if (!step || (step.action !== 'run-batch' && step.action !== 'test-wave')) return true
1102
+ if (source === 'record-batch' && Number(step.batchIndex) > 0) return true
1103
+ }
966
1104
  const runId = runIdOf(state)
967
1105
  const changeName = changeFor(state, change)
1106
+ const dispatched = explicit
1107
+ ? lanes
1108
+ .map(lane => (Array.isArray(lane) ? lane.filter(t => t && typeof t === 'object') : []))
1109
+ .filter(lane => lane.length)
1110
+ : spawnLanesOf(step)
968
1111
  let ok = true
969
- for (const lane of spawnLanesOf(step)) {
1112
+ for (const lane of dispatched) {
970
1113
  const spawn = appendRunLogEvent(root, {
971
1114
  runId,
972
1115
  change: changeName,
@@ -1205,6 +1348,127 @@ function observedChangedPaths(root) {
1205
1348
  return paths
1206
1349
  }
1207
1350
 
1351
+ // The identity a shared-tree snapshot is committed under. It is nobody's: it is
1352
+ // set in the environment of the one `commit-tree` call and never in any config,
1353
+ // because `commit-tree` refuses to write without an identity, a CI checkout
1354
+ // often has none, and the user's name does not belong on an object they did not
1355
+ // author.
1356
+ const SNAPSHOT_IDENTITY = {
1357
+ GIT_AUTHOR_NAME: 'Interlock',
1358
+ GIT_AUTHOR_EMAIL: 'interlock@localhost',
1359
+ GIT_COMMITTER_NAME: 'Interlock',
1360
+ GIT_COMMITTER_EMAIL: 'interlock@localhost'
1361
+ }
1362
+
1363
+ /**
1364
+ * Every linked worktree of the repository that lies inside `root`, as a
1365
+ * root-relative path with forward slashes. Read from git at capture time rather
1366
+ * than kept as a list, so a host that moves where it puts its worktrees is
1367
+ * still covered. Both sides are resolved through `realpathSync` because git
1368
+ * reports real paths and `root` may arrive through a symlink (macOS's `/tmp`).
1369
+ */
1370
+ function linkedWorktreesInside(root, porcelain) {
1371
+ const top = realpathSync(root)
1372
+ const inside = []
1373
+ for (const line of porcelain.split('\n')) {
1374
+ if (!line.startsWith('worktree ')) continue
1375
+ let path = line.slice('worktree '.length)
1376
+ try {
1377
+ path = realpathSync(path)
1378
+ } catch {
1379
+ // A worktree whose directory is gone is compared as git listed it.
1380
+ }
1381
+ const rel = relative(top, path)
1382
+ if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) continue
1383
+ inside.push(rel.split(sep).join('/'))
1384
+ }
1385
+ return inside
1386
+ }
1387
+
1388
+ /**
1389
+ * The commit an isolated batch's lanes fork from: the shared tree as it stands,
1390
+ * with every tracked edit, every deletion and every untracked file the ignore
1391
+ * rules let through — so it holds every earlier fold of the run, which HEAD
1392
+ * never does (the ship commit is the run's last step).
1393
+ *
1394
+ * Built through a temporary index and plumbing only. No ref moves, the
1395
+ * repository's own index is never opened for writing, and no working-tree file
1396
+ * changes. The commit is pinned by nothing: the lane worktrees forked from it
1397
+ * keep it reachable until the fold, and git's own expiry takes it after.
1398
+ *
1399
+ * - The index is seeded from HEAD, so a tracked-but-ignored file keeps its
1400
+ * committed content instead of dropping out of the tree.
1401
+ * - Interlock's run state and every linked worktree inside the root are left
1402
+ * out. Staged, a worktree becomes an embedded-repository entry; a lane's
1403
+ * checkout holds an empty directory there; and the fold reads that as a
1404
+ * deletion of a sibling lane's worktree.
1405
+ * - A tree identical to HEAD's returns HEAD itself and creates no object.
1406
+ *
1407
+ * @param {string} root
1408
+ * @param {string} [change] named in the snapshot's message, for whoever finds it
1409
+ * @returns {string|null} null on any failure — `decorate` halts on it rather
1410
+ * than forking lanes from a base nobody captured
1411
+ */
1412
+ function snapshotTree(root, change) {
1413
+ let dir = null
1414
+ try {
1415
+ dir = mkdtempSync(join(tmpdir(), 'interlock-snapshot-'))
1416
+ const env = { ...process.env, GIT_INDEX_FILE: join(dir, 'index') }
1417
+ const git = (args, extraEnv = {}) =>
1418
+ execFileSync('git', args, {
1419
+ cwd: root,
1420
+ encoding: 'utf8',
1421
+ env: { ...env, ...extraEnv },
1422
+ stdio: ['ignore', 'pipe', 'ignore']
1423
+ }).trim()
1424
+
1425
+ git(['read-tree', 'HEAD'])
1426
+ const worktrees = linkedWorktreesInside(root, git(['worktree', 'list', '--porcelain']))
1427
+ git([
1428
+ 'add',
1429
+ '-A',
1430
+ '--',
1431
+ '.',
1432
+ ':(exclude).claude/ship',
1433
+ ...worktrees.map(rel => `:(exclude,literal)${rel}`)
1434
+ ])
1435
+ const tree = git(['write-tree'])
1436
+ const head = git(['rev-parse', 'HEAD'])
1437
+ if (tree === git(['rev-parse', 'HEAD^{tree}'])) return head || null
1438
+ // --no-gpg-sign: `commit.gpgSign` would otherwise ask a signing agent to
1439
+ // sign an object nobody will ever push.
1440
+ const message = `interlock: shared-tree snapshot (${change || 'unknown change'})`
1441
+ return git(['commit-tree', '--no-gpg-sign', tree, '-p', head, '-m', message], SNAPSHOT_IDENTITY) || null
1442
+ } catch {
1443
+ return null
1444
+ } finally {
1445
+ if (dir) rmSync(dir, { recursive: true, force: true })
1446
+ }
1447
+ }
1448
+
1449
+ /**
1450
+ * The commit a lane worktree is checked out at, as git reports it from inside
1451
+ * the worktree — never the base the lane's agent says it forked from, because
1452
+ * that report is the thing being audited. `null` when git cannot answer,
1453
+ * including when the directory is gone.
1454
+ *
1455
+ * @param {string} worktreePath
1456
+ * @returns {string|null}
1457
+ */
1458
+ function worktreeHead(worktreePath) {
1459
+ if (typeof worktreePath !== 'string' || !worktreePath || !existsSync(worktreePath)) return null
1460
+ try {
1461
+ return (
1462
+ execFileSync('git', ['-C', worktreePath, 'rev-parse', 'HEAD'], {
1463
+ encoding: 'utf8',
1464
+ stdio: ['ignore', 'pipe', 'ignore']
1465
+ }).trim() || null
1466
+ )
1467
+ } catch {
1468
+ return null
1469
+ }
1470
+ }
1471
+
1208
1472
  /**
1209
1473
  * A lane worktree's actual writes relative to the shared base commit, as git
1210
1474
  * sees them — never a prediction. `null` means git could not answer (bad
@@ -1582,7 +1846,7 @@ async function main() {
1582
1846
  if (flags.metrics !== undefined) {
1583
1847
  if (flags.metrics === true) die('--metrics requires a change name')
1584
1848
  const surviving = result.total + result.malformed.length
1585
- metrics = writeReviewMetrics(root, {
1849
+ metrics = writeReviewMetrics(corpusHome(root, flags).home, {
1586
1850
  change: String(flags.metrics),
1587
1851
  counts: {
1588
1852
  raised: result.dismissedCount + result.droppedByQuality + surviving,
@@ -1635,6 +1899,7 @@ async function main() {
1635
1899
  // would encode. Every value here is one `if` away from a threshold; the
1636
1900
  // absence of that `if` is the feature. See the header of lib/report.mjs.
1637
1901
  const report = buildReport(root, {
1902
+ stateHome: corpusHome(root, flags),
1638
1903
  since: typeof flags.since === 'string' ? flags.since : undefined,
1639
1904
  change: typeof flags.change === 'string' ? flags.change : undefined,
1640
1905
  maxRuns: num(flags, 'max-runs')
@@ -1698,23 +1963,27 @@ async function main() {
1698
1963
  // `limits`: LIMITS holds positive-integer loop counts (its invariant test
1699
1964
  // asserts it), whereas the lane-cap table is a tier→cap map and the solo
1700
1965
  // envelope is a separate object — the same reason `effort` is emitted apart.
1966
+ // The slot count this machine's runtime will honour, as far as this
1967
+ // process can see it — beside the vendor fact, never instead of it.
1968
+ const runtimeObserved = runtimeSlotsOf(observeClaudeEnv(process.env, { cpuCount: cpuCount() }))
1701
1969
  emit(
1702
1970
  json,
1703
1971
  {
1704
1972
  limits: LIMITS,
1705
- runtime: RUNTIME,
1973
+ runtime: { ...RUNTIME, observed: runtimeObserved },
1706
1974
  evals: EVAL_CAPS,
1707
1975
  // Its own group, beside `evals` rather than inside it: the eval caps
1708
1976
  // are scalars a reader compares a running total against, and this is
1709
1977
  // the table that produced the total. Its `id` is what a recorded
1710
- // result row is keyed against.
1978
+ // result row is keyed against. Emitted whole, so each model's own
1979
+ // `cacheMultipliers.read` entry and both write tiers travel with it.
1711
1980
  prices: MODEL_PRICES,
1712
1981
  report: REPORT_CAPS,
1713
1982
  effort: EFFORT,
1714
1983
  laneCaps: LANE_CAPS,
1715
1984
  solo: SOLO
1716
1985
  },
1717
- formatLimits()
1986
+ formatLimits({ runtimeObserved })
1718
1987
  )
1719
1988
  break
1720
1989
  }
@@ -1779,7 +2048,7 @@ async function main() {
1779
2048
  // Read-only by construction — see the header of lib/doctor.mjs. The exit
1780
2049
  // code is the whole point: a preflight that only prints is a preflight a
1781
2050
  // zero-touch run walks straight past.
1782
- const report = diagnose(root)
2051
+ const report = diagnose(root, { cpuCount: cpuCount(), stateHome: explicitHome(flags) || undefined })
1783
2052
  emit(json, report, formatDoctor(report))
1784
2053
  if (!report.ok) process.exit(1)
1785
2054
  break
@@ -1798,6 +2067,7 @@ async function main() {
1798
2067
  const outDir = typeof flags.out === 'string' ? flags.out : ''
1799
2068
  if (!outDir) die('evals capture requires --out <dir>')
1800
2069
  const result = captureCase(root, {
2070
+ stateHome: corpusHome(root, flags).home,
1801
2071
  runId,
1802
2072
  taskId: typeof flags.task === 'string' ? flags.task : undefined,
1803
2073
  outDir
@@ -1911,7 +2181,7 @@ async function main() {
1911
2181
  let metrics = null
1912
2182
  if (flags.metrics !== undefined) {
1913
2183
  if (flags.metrics === true) die('--metrics requires a change name')
1914
- metrics = writeReviewMetrics(root, { change: String(flags.metrics), counts: result.counts })
2184
+ metrics = writeReviewMetrics(corpusHome(root, flags).home, { change: String(flags.metrics), counts: result.counts })
1915
2185
  if (!metrics.written) warn(`metrics not written: ${metrics.reason}`)
1916
2186
  }
1917
2187
 
@@ -2029,10 +2299,11 @@ async function main() {
2029
2299
  // trusting the untrusted source exactly when the trusted one is
2030
2300
  // missing would defeat the partition on the runs most likely to need
2031
2301
  // it.
2302
+ const home = corpusHome(root, flags).home
2032
2303
  const receipt = runId
2033
- ? queryRunLog(root, runId, { type: 'run-receipt' }).records.pop() || null
2304
+ ? queryRunLog(home, runId, { type: 'run-receipt' }).records.pop() || null
2034
2305
  : null
2035
- const written = appendOutcome(root, { ...reportedHalf, observed: observedFromReceipt(receipt) })
2306
+ const written = appendOutcome(home, { ...reportedHalf, observed: observedFromReceipt(receipt) })
2036
2307
  if (carriedObserved) {
2037
2308
  warn(
2038
2309
  'outcome record carried an "observed" group: refused. Observed values are derived ' +
@@ -2054,7 +2325,7 @@ async function main() {
2054
2325
  `${written.written ? `recorded → ${written.path}` : `not recorded: ${written.reason}`}\n`
2055
2326
  )
2056
2327
  } else if (sub === 'list' || sub === undefined) {
2057
- const result = readOutcomes(root)
2328
+ const result = readOutcomes(corpusHome(root, flags).home)
2058
2329
  emit(json, result, formatOutcomes(result))
2059
2330
  } else {
2060
2331
  die(`unknown outcomes subcommand: ${sub}`)
@@ -2064,9 +2335,10 @@ async function main() {
2064
2335
 
2065
2336
  case 'run-log': {
2066
2337
  const sub = positional[1]
2338
+ const logHome = corpusHome(root, flags).home
2067
2339
  if (sub === 'append') {
2068
2340
  const record = readInput(flags.event, '--event <file|->')
2069
- const { written, path, reason } = appendRunLogEvent(root, record)
2341
+ const { written, path, reason } = appendRunLogEvent(logHome, record)
2070
2342
  if (!written) {
2071
2343
  // Never fatal: losing a trajectory line must not fail the run that
2072
2344
  // produced it. Say so on stderr and exit 0.
@@ -2075,17 +2347,17 @@ async function main() {
2075
2347
  emit(json, { written, path, reason }, `${written ? `recorded → ${path}` : `not recorded: ${reason}`}\n`)
2076
2348
  } else if (sub === 'list') {
2077
2349
  const change = typeof flags.change === 'string' ? flags.change : undefined
2078
- const runs = listRunLogs(root, { change })
2350
+ const runs = listRunLogs(logHome, { change })
2079
2351
  emit(json, { runs }, formatRunLogList(runs))
2080
2352
  } else if (sub === 'show') {
2081
2353
  const runId = positional[2]
2082
2354
  if (!runId) die('run-log show requires a runId')
2083
- const result = readRunLog(root, runId)
2355
+ const result = readRunLog(logHome, runId)
2084
2356
  emit(json, result, formatRunLog(result))
2085
2357
  } else if (sub === 'query') {
2086
2358
  const runId = typeof flags.run === 'string' ? flags.run : undefined
2087
2359
  if (!runId) die('--run is required')
2088
- const result = queryRunLog(root, runId, {
2360
+ const result = queryRunLog(logHome, runId, {
2089
2361
  type: typeof flags.type === 'string' ? flags.type : undefined,
2090
2362
  halted: flags.halted === true
2091
2363
  })
@@ -2093,7 +2365,7 @@ async function main() {
2093
2365
  } else if (sub === 'check') {
2094
2366
  const runId = runIdFromFlags(flags)
2095
2367
  if (!runId) die('run-log check requires --state <file> (with a runId) or --run-id <id>')
2096
- const result = checkRunLog(root, runId)
2368
+ const result = checkRunLog(logHome, runId)
2097
2369
  emit(json, result, formatRunLogCheck(result))
2098
2370
  if (!result.ok) process.exit(1)
2099
2371
  } else {
@@ -2205,7 +2477,7 @@ async function main() {
2205
2477
  }
2206
2478
  const opts = { context, ...verifyJudgeOpts(flags) }
2207
2479
  const verdict = judgeVerification(plan, results, opts)
2208
- const logOk = logVerifyJudgement(root, {
2480
+ const logOk = logVerifyJudgement(corpusHome(root, flags).home, {
2209
2481
  runId: runIdFromFlags(flags),
2210
2482
  // Same precedence as every wave-state append site: the state names the
2211
2483
  // run, the flag is only the pre-state-name fallback.
@@ -2237,6 +2509,42 @@ async function main() {
2237
2509
  result,
2238
2510
  `spilled ${result.bytes} byte(s) → ${result.locator}${result.truncated ? ' (truncated preview)' : ''}\n`
2239
2511
  )
2512
+ } else if (sub === 'exec') {
2513
+ const kind = typeof flags.kind === 'string' ? flags.kind.trim() : ''
2514
+ if (!kind) die('--kind is required')
2515
+ const command = typeof flags.command === 'string' ? flags.command : ''
2516
+ if (!command.trim()) die('--command is required')
2517
+ const cwd = typeof flags.cwd === 'string' && flags.cwd.trim() ? flags.cwd : undefined
2518
+ // Timed here, around the command and nothing else: the inter-wave budget
2519
+ // used to be clocked from the step's dispatch to its judgement, which
2520
+ // charged two relays and the verify agent's own turns to "verification"
2521
+ // and spent the whole budget on the first checkpoint of every Workflow run.
2522
+ const started = performance.now()
2523
+ const ran = spawnSync(command, { shell: true, stdio: 'inherit', cwd })
2524
+ const durationMs = Math.round(performance.now() - started)
2525
+ let exitCode
2526
+ if (ran.error) {
2527
+ process.stderr.write(`interlock: verify exec could not run the command: ${ran.error.message}\n`)
2528
+ exitCode = 127
2529
+ } else if (typeof ran.status === 'number') {
2530
+ exitCode = ran.status
2531
+ } else {
2532
+ const signal = ran.signal && osConstants.signals[ran.signal]
2533
+ exitCode = Number.isInteger(signal) ? 128 + signal : 1
2534
+ }
2535
+ try {
2536
+ mkdirSync(join(root, dirname(VERIFY_TIMINGS_PATH)), { recursive: true })
2537
+ appendFileSync(
2538
+ join(root, VERIFY_TIMINGS_PATH),
2539
+ JSON.stringify({ kind, exitCode, durationMs, command, at: new Date().toISOString() }) + '\n'
2540
+ )
2541
+ } catch (err) {
2542
+ // A timing is a budget input, not a gate: losing it costs the budget
2543
+ // its precision (the judge falls back to the round trip, and says
2544
+ // so), never the check its result.
2545
+ process.stderr.write(`interlock: verify timing not recorded: ${(err && err.message) || err}\n`)
2546
+ }
2547
+ process.exit(exitCode)
2240
2548
  } else if (sub === 'cluster') {
2241
2549
  const failures = readInput(flags.failures, '--failures <file|->')
2242
2550
  if (!Array.isArray(failures)) {
@@ -2254,7 +2562,7 @@ async function main() {
2254
2562
  if (action.action === 'halt') process.exit(1)
2255
2563
  } else {
2256
2564
  die(
2257
- `unknown verify subcommand: ${sub || '(none)'} — expected plan|judge|unit|spill|cluster|repair`
2565
+ `unknown verify subcommand: ${sub || '(none)'} — expected plan|judge|unit|spill|cluster|repair|exec`
2258
2566
  )
2259
2567
  }
2260
2568
  break
@@ -2381,11 +2689,13 @@ async function main() {
2381
2689
  stdio: ['ignore', 'pipe', 'ignore']
2382
2690
  }).trim() || null
2383
2691
  } catch {
2384
- // An unreadable base is null, never a guess: `decorate` halts on
2385
- // it rather than folding a batch against a commit nobody read.
2692
+ // An unreadable base is null, never a guess: `record-batch` halts
2693
+ // on it rather than folding a batch against a commit nobody read.
2386
2694
  return null
2387
2695
  }
2388
2696
  },
2697
+ snapshotTree,
2698
+ worktreeHead,
2389
2699
  logWaveMutation,
2390
2700
  logAgentSpawns,
2391
2701
  observedChangedPaths,
@@ -2417,6 +2727,11 @@ async function main() {
2417
2727
 
2418
2728
  const results = flags.results ? readInput(flags.results, '--results <file|->') : []
2419
2729
  const resultList = Array.isArray(results) ? results : [results]
2730
+ // The host's own observation of each spawn, beside the agents' results and
2731
+ // never inside them: a result is the agent's report, a host record is what
2732
+ // the host saw (design D7). Only the runner sends one; the CLI decides.
2733
+ const records = flags['host-records'] ? readInput(flags['host-records'], '--host-records <file|->') : []
2734
+ const hostRecords = Array.isArray(records) ? records : [records]
2420
2735
 
2421
2736
  let step
2422
2737
  if (sub === 'start') {
@@ -2434,7 +2749,7 @@ async function main() {
2434
2749
  : ''
2435
2750
  }`
2436
2751
  )
2437
- emit(json, step, formatRunStep(step))
2752
+ emitRunStep(json, root, step, formatRunStep(step))
2438
2753
  break
2439
2754
  }
2440
2755
  const name = resolved.name
@@ -2450,10 +2765,35 @@ async function main() {
2450
2765
  die(`--host-capabilities must be a JSON object of the host's declared capabilities: ${err.message}`)
2451
2766
  }
2452
2767
  }
2768
+ const hostId = typeof flags.host === 'string' ? flags.host : undefined
2769
+ // The Workflow host used to record `effort: flag` because the runtime's
2770
+ // `agent()` option cannot be watched from this process. The installed
2771
+ // CLI's help can. A caller that already named the capability keeps it.
2772
+ let workflowEffortProbe = null
2773
+ if (
2774
+ hostId === 'workflow' &&
2775
+ !(hostCapabilities && Object.prototype.hasOwnProperty.call(hostCapabilities, 'effort'))
2776
+ ) {
2777
+ workflowEffortProbe = probeClaudeEffort(process.env)
2778
+ hostCapabilities = { ...(hostCapabilities || {}), effort: workflowEffortProbe.capability }
2779
+ }
2780
+ // The model-routing variables, the slot override and the host version,
2781
+ // read from THIS process's environment — the Workflow driver forwards
2782
+ // what `runStart` decides from them and reads none of them itself.
2783
+ let hostEnv
2784
+ if (hostId === 'workflow') {
2785
+ const probed = probeClaudeVersion(process.env)
2786
+ hostEnv = {
2787
+ ...observeClaudeEnv(process.env, { version: probed.version, cpuCount: cpuCount() }),
2788
+ ...(probed.reason ? { versionReason: probed.reason } : {})
2789
+ }
2790
+ }
2453
2791
  step = runStart(ctx, {
2454
2792
  change: name,
2455
- host: typeof flags.host === 'string' ? flags.host : undefined,
2793
+ host: hostId,
2456
2794
  hostCapabilities,
2795
+ hostEnv,
2796
+ stateHome: startStateHome(root, flags),
2457
2797
  flags: {
2458
2798
  applyOnly: flags['apply-only'] === true,
2459
2799
  noCommit: flags['no-commit'] === true,
@@ -2470,6 +2810,18 @@ async function main() {
2470
2810
  strict: flags.strict !== undefined
2471
2811
  }
2472
2812
  })
2813
+ if (workflowEffortProbe && workflowEffortProbe.capability === 'unsupported') {
2814
+ // Spoken where the assumption used to be silent. The script still
2815
+ // forwards the option: `agent()` does not reject an unknown key the
2816
+ // way the CLI rejects an unknown flag.
2817
+ const banner = `EFFORT ROUTING UNAVAILABLE (workflow): ${workflowEffortProbe.reason}`
2818
+ const manifest = readManifest(root)
2819
+ if (manifest) {
2820
+ manifest.banners = [...(manifest.banners || []), banner]
2821
+ writeManifest(root, manifest)
2822
+ }
2823
+ if (step && typeof step === 'object') step.banners = [...(step.banners || []), banner]
2824
+ }
2473
2825
  } else if (sub === 'classified') {
2474
2826
  const classified =
2475
2827
  typeof flags.classified === 'string' && flags.classified.trim()
@@ -2477,25 +2829,25 @@ async function main() {
2477
2829
  : CLASSIFIED_PATH
2478
2830
  step = runClassified(ctx, { classifiedPath: classified })
2479
2831
  } else if (sub === 'record-batch') {
2480
- step = runRecordBatch(ctx, { results: resultList })
2832
+ step = runRecordBatch(ctx, { results: resultList, hostRecords })
2481
2833
  } else if (sub === 'judge') {
2482
2834
  const context = flags.context === undefined ? 'final' : String(flags.context)
2483
2835
  if (context !== 'inter-wave' && context !== 'final') {
2484
2836
  die(`--context must be inter-wave|final (got "${context}")`)
2485
2837
  }
2486
- step = runJudge(ctx, { context, results: resultList })
2838
+ step = runJudge(ctx, { context, results: resultList, hostRecords })
2487
2839
  } else if (sub === 'verify-final') {
2488
2840
  step = runVerifyFinal(ctx)
2489
2841
  } else if (sub === 'reviewed') {
2490
- step = runReviewed(ctx, { results: resultList })
2842
+ step = runReviewed(ctx, { results: resultList, hostRecords })
2491
2843
  } else if (sub === 'remediated') {
2492
2844
  const roundNum = num(flags, 'round')
2493
2845
  if (!Number.isInteger(roundNum)) die('run remediated requires --round <n>')
2494
- step = runRemediated(ctx, { round: roundNum, results: resultList })
2846
+ step = runRemediated(ctx, { round: roundNum, results: resultList, hostRecords })
2495
2847
  } else if (sub === 'commit') {
2496
- step = runCommit(ctx, { results: resultList })
2848
+ step = runCommit(ctx, { results: resultList, hostRecords })
2497
2849
  } else if (sub === 'replan') {
2498
- step = runReplan(ctx, { results: resultList })
2850
+ step = runReplan(ctx, { results: resultList, hostRecords })
2499
2851
  } else if (sub === 'next') {
2500
2852
  step = runNext(ctx)
2501
2853
  } else if (sub === 'close') {
@@ -2510,8 +2862,12 @@ async function main() {
2510
2862
  halt: typeof flags.halt === 'string' ? flags.halt : null,
2511
2863
  hostBanners: Array.isArray(hostBanners) ? hostBanners : [],
2512
2864
  hostObserved,
2865
+ hostRecords,
2513
2866
  notify: flags.notify === true
2514
2867
  })
2868
+ // Printed plainly, and NOT copied to LAST_STEP_PATH: a halted run closes
2869
+ // through here, and overwriting the file would replace the very step the
2870
+ // halt reason points a reader at with the close's own summary.
2515
2871
  emit(json, step, step.summary + '\n')
2516
2872
  if (step.exitCode !== 0) process.exit(step.exitCode)
2517
2873
  break
@@ -2522,7 +2878,7 @@ async function main() {
2522
2878
  )
2523
2879
  }
2524
2880
 
2525
- emit(json, step, formatRunStep(step))
2881
+ emitRunStep(json, root, step, formatRunStep(step), { relay: true })
2526
2882
  // A halt step still exits 0: its `then` is the close, and a driver that
2527
2883
  // stopped here would leave the run with no receipt and no terminal event.
2528
2884
  // `run close` is the call that carries the non-zero exit.
@@ -2532,19 +2888,22 @@ async function main() {
2532
2888
  case 'wave-state': {
2533
2889
  const sub = positional[1]
2534
2890
  const change = typeof flags.change === 'string' ? flags.change : undefined
2891
+ // The trajectory this command appends to lives in the state home; the
2892
+ // changed-path read below stays on the working tree.
2893
+ const logRoot = corpusHome(root, flags).home
2535
2894
  if (sub === 'create') {
2536
2895
  const plan = readInput(flags.plan, '--plan <file|->')
2537
2896
  const maxParallel = num(flags, 'max-parallel')
2538
2897
  // The one place the name enters the run: from here it travels on the
2539
2898
  // state, and no later wave-state invocation needs the flag.
2540
2899
  const state = createRunState(plan, { maxParallel, change })
2541
- const { ok: logOk } = logWaveMutation(root, { source: 'create', change, state })
2900
+ const { ok: logOk } = logWaveMutation(logRoot, { source: 'create', change, state })
2542
2901
  emit(json, state, formatRunState(state))
2543
2902
  if (!logOk) process.exit(1)
2544
2903
  } else if (sub === 'next') {
2545
2904
  const state = readInput(flags.state, '--state <file|->')
2546
- const { step, ok: logOk } = logWaveMutation(root, { source: 'next', change, state })
2547
- const spawnOk = logAgentSpawns(root, { state, change, step, source: 'next' })
2905
+ const { step, ok: logOk } = logWaveMutation(logRoot, { source: 'next', change, state })
2906
+ const spawnOk = logAgentSpawns(logRoot, { state, change, step, source: 'next' })
2548
2907
  emit(json, step, formatRunState(state))
2549
2908
  if (step.action === 'halt' || !logOk || !spawnOk) process.exit(1)
2550
2909
  } else if (sub === 'record-batch' || sub === 'record-verify') {
@@ -2557,11 +2916,11 @@ async function main() {
2557
2916
  sub === 'record-batch'
2558
2917
  ? recordBatchResult(state, result, { changedPaths: observedChangedPaths(root) })
2559
2918
  : recordVerifyResult(state, result)
2560
- const { ok: logOk } = logWaveMutation(root, { source: sub, change, state: next })
2919
+ const { ok: logOk } = logWaveMutation(logRoot, { source: sub, change, state: next })
2561
2920
  // Derived from the two states rather than returned by the recorder, so
2562
2921
  // `recordBatchResult`'s signature and every other caller are untouched.
2563
2922
  const recorded = sub === 'record-batch' ? batchOutcomes(state, next) : undefined
2564
- emitRecordedState(json, flags, next, { root, change, logOk, source: sub, recorded })
2923
+ emitRecordedState(json, flags, next, { root: logRoot, change, logOk, source: sub, recorded })
2565
2924
  } else if (sub === 'replan') {
2566
2925
  const state = readInput(flags.state, '--state <file|->')
2567
2926
  const groups = readInput(flags.groups, '--groups <file|->')
@@ -2569,7 +2928,7 @@ async function main() {
2569
2928
  die('--groups must be a JSON array of { group, tasks } revisions')
2570
2929
  }
2571
2930
  const next = applyReplan(state, groups)
2572
- const { ok: logOk } = logWaveMutation(root, { source: 'replan', change, state: next })
2931
+ const { ok: logOk } = logWaveMutation(logRoot, { source: 'replan', change, state: next })
2573
2932
  emitRecordedState(json, flags, next, { root, change, logOk, source: 'replan' })
2574
2933
  } else {
2575
2934
  die(
@@ -2582,7 +2941,8 @@ async function main() {
2582
2941
 
2583
2942
  case 'autonomy': {
2584
2943
  const sub = positional[1]
2585
- const opts = { root: flags.root ? String(flags.root) : undefined }
2944
+ // The autonomy ledger is a corpus: it lives in the state home (ship/state-home).
2945
+ const opts = { root: corpusHome(root, flags).home }
2586
2946
  if (sub === 'level') {
2587
2947
  const path = positional[2]
2588
2948
  if (!path) die('autonomy level requires a path')