@renzrollon/interlock 1.1.1 → 1.2.1
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/icon.png +0 -0
- package/.claude-plugin/plugin.json +109 -1
- package/CHANGELOG.md +150 -11
- package/bin/interlock +421 -53
- package/bin/interlock-run +119 -7
- package/hooks/guard-relaunch.mjs +115 -0
- package/hooks/guard-tests.mjs +51 -3
- package/hooks/hooks.json +1 -0
- package/hooks/mod.mjs +655 -0
- package/hooks/preflight.mjs +124 -13
- package/hooks/recorder.mjs +189 -0
- package/lib/agent-usage.mjs +524 -0
- package/lib/doctor.mjs +511 -46
- package/lib/eval-history.mjs +233 -0
- package/lib/evals-capture.mjs +5 -2
- package/lib/evals-triage.mjs +1 -1
- package/lib/graph/cli.mjs +28 -5
- package/lib/graph/retrieve/context.mjs +8 -3
- package/lib/host/acp.mjs +77 -14
- package/lib/host/claude-cli.mjs +391 -21
- package/lib/host/claude-env.mjs +179 -0
- package/lib/host/codex.mjs +9 -1
- package/lib/host/effort.mjs +163 -0
- package/lib/host/model-map.mjs +43 -0
- package/lib/host/qwen.mjs +9 -1
- package/lib/host/registry.mjs +44 -10
- package/lib/host.mjs +46 -5
- package/lib/interrupted.mjs +167 -0
- package/lib/launch-ledger.mjs +198 -0
- package/lib/launch-rule.mjs +174 -0
- package/lib/limits.mjs +149 -41
- package/lib/meter-quiet.mjs +45 -0
- package/lib/notify-defaults.mjs +6 -0
- package/lib/notify.mjs +41 -25
- package/lib/plan-fingerprint.mjs +80 -11
- package/lib/prompts/implementer.mjs +1 -1
- package/lib/prompts/verify.mjs +24 -1
- package/lib/receipt.mjs +59 -8
- package/lib/report-html.mjs +89 -6
- package/lib/report.mjs +313 -12
- package/lib/resume-card.mjs +90 -2
- package/lib/run-log.mjs +192 -5
- package/lib/run.mjs +1177 -84
- package/lib/state-home.mjs +298 -0
- package/lib/verify.mjs +37 -0
- package/lib/waves.mjs +53 -16
- package/package.json +4 -2
- package/shared/TEST-PROFILE.md +10 -5
- package/skills/evals/SKILL.md +3 -3
- package/skills/fix-tests/SKILL.md +3 -3
- package/skills/spec/SKILL.md +1 -6
- package/skills/spec/scripts/compact-instructions.mjs +30 -0
- package/types/index.d.ts +28 -0
- package/workflows/ship.js +75 -51
- package/.claude-plugin/marketplace.json +0 -19
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 {
|
|
19
|
-
|
|
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,10 +61,20 @@ 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'
|
|
51
|
-
import {
|
|
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'
|
|
71
|
+
import {
|
|
72
|
+
composeCheckpointMessage,
|
|
73
|
+
postNtfy,
|
|
74
|
+
readNotifyConfig,
|
|
75
|
+
NOTIFY_TOPIC_ENV,
|
|
76
|
+
DEFAULT_NTFY_URL
|
|
77
|
+
} from '../lib/notify.mjs'
|
|
52
78
|
import { classifySurface } from '../lib/surface.mjs'
|
|
53
79
|
import { evaluateGate, formatGate, TOLERANCE_BAND } from '../lib/findings.mjs'
|
|
54
80
|
import { inspectChange, resolveChange, listChanges, formatInspection, tickTasks, planCoverage } from '../lib/artifacts.mjs'
|
|
@@ -151,9 +177,11 @@ Usage:
|
|
|
151
177
|
[--priority high|default] checkpoint reached while nobody was
|
|
152
178
|
interlock notify checkpoint <change> watching still reaches someone. The
|
|
153
179
|
only outbound request this CLI makes.
|
|
154
|
-
Topic from
|
|
155
|
-
server from
|
|
156
|
-
|
|
180
|
+
Topic from the plugin option or
|
|
181
|
+
INTERLOCK_NTFY_TOPIC, server from
|
|
182
|
+
the plugin option or
|
|
183
|
+
INTERLOCK_NTFY_URL
|
|
184
|
+
(default ${DEFAULT_NTFY_URL}). Unset
|
|
157
185
|
topic prints "not configured" and
|
|
158
186
|
exits 0; a failed push exits 1.
|
|
159
187
|
--json emits { sent, reason }
|
|
@@ -172,6 +200,9 @@ Usage:
|
|
|
172
200
|
interlock run close [--halt <reason>] names, writes the results, calls
|
|
173
201
|
[--host-banners <f>]
|
|
174
202
|
[--host-observed <f>]
|
|
203
|
+
(record-batch, judge, reviewed, remediated, commit, replan and close also
|
|
204
|
+
take --host-records <f>: the runner's per-spawn host observations, beside
|
|
205
|
+
--results and never inside them)
|
|
175
206
|
\`then.argv\`, and repeats until
|
|
176
207
|
\`then\` is null. It decides nothing:
|
|
177
208
|
every branch the loop has (apply-only,
|
|
@@ -267,6 +298,8 @@ Usage:
|
|
|
267
298
|
interlock verify unit --result <file> Judge a unit result on its own
|
|
268
299
|
interlock verify spill --run-id <id> --kind <k> --input <f>
|
|
269
300
|
Spill oversized stdout/stderr to disk
|
|
301
|
+
interlock verify exec --kind <k> --command <cmd>
|
|
302
|
+
Run one planned check and time it
|
|
270
303
|
interlock verify cluster --failures <f> Group failures by root-cause signature
|
|
271
304
|
interlock verify repair --state <file> Decide repair / halt / accept next
|
|
272
305
|
interlock plan fingerprint [change] Fingerprint a change's planning inputs
|
|
@@ -288,6 +321,10 @@ Usage:
|
|
|
288
321
|
Common options:
|
|
289
322
|
--json Emit JSON instead of human-readable text
|
|
290
323
|
--root <dir> Repo root (default: cwd)
|
|
324
|
+
--state-home <dir> Where corpora (trajectories, outcomes, metrics, resume
|
|
325
|
+
cards, interrupted notes, autonomy ledger) are read and
|
|
326
|
+
written; default INTERLOCK_STATE_HOME, else the main
|
|
327
|
+
checkout when --root is a linked worktree, else --root
|
|
291
328
|
Any <file> argument accepts "-" to read that input from stdin. Only one input
|
|
292
329
|
per invocation may be "-", since stdin can only be drained once.
|
|
293
330
|
|
|
@@ -645,6 +682,17 @@ verify spill:
|
|
|
645
682
|
head/tail slice bounded by \`interlock limits\`' verify preview budget.
|
|
646
683
|
Always exits 0.
|
|
647
684
|
|
|
685
|
+
verify exec:
|
|
686
|
+
--kind <k> The planned step this runs, e.g. typecheck/unit/lint (required)
|
|
687
|
+
--command <cmd> The planned command, verbatim; run through the shell (required)
|
|
688
|
+
--cwd <dir> Directory to run it in (default: the current one)
|
|
689
|
+
Runs the command with its stdout, stderr and exit code passed through
|
|
690
|
+
unchanged, and appends { kind, exitCode, durationMs, command, at } to
|
|
691
|
+
.claude/ship/verify-timings.jsonl. \`run judge\` folds those durations into
|
|
692
|
+
the inter-wave verify budget, so the budget counts the checks themselves
|
|
693
|
+
rather than the relay and agent time around them. A failed append is a
|
|
694
|
+
stderr warning; the exit code is always the command's.
|
|
695
|
+
|
|
648
696
|
plan:
|
|
649
697
|
fingerprint [change] Hash the inputs a plan for this change is derived from:
|
|
650
698
|
proposal.md, design.md, tasks.md (with checkbox markers
|
|
@@ -828,6 +876,51 @@ function readStdin() {
|
|
|
828
876
|
}
|
|
829
877
|
}
|
|
830
878
|
|
|
879
|
+
/**
|
|
880
|
+
* The explicit state home, if one was given: `--state-home <dir>`, else
|
|
881
|
+
* `INTERLOCK_STATE_HOME` (ship/state-home). `--root` keeps naming the working
|
|
882
|
+
* tree; this names where its corpora go.
|
|
883
|
+
*/
|
|
884
|
+
function explicitHome(flags) {
|
|
885
|
+
return explicitStateHome({
|
|
886
|
+
flag: typeof flags['state-home'] === 'string' ? flags['state-home'] : undefined,
|
|
887
|
+
env: process.env
|
|
888
|
+
})
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* The state home a run starts with: the explicit value, else the resolver. Never
|
|
893
|
+
* a manifest already at the root — that is the PREVIOUS run's.
|
|
894
|
+
*/
|
|
895
|
+
function startStateHome(root, flags) {
|
|
896
|
+
return resolveStateHome(root, { explicit: explicitHome(flags) || undefined, env: {} })
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* The state home a corpus command reads and writes (design D14): the home the
|
|
901
|
+
* live run at this root recorded, so a lane's `wave-state` append or a report
|
|
902
|
+
* run mid-run never disagrees with the run's own; else the explicit value; else
|
|
903
|
+
* the resolver. Resolved once per invocation and handed to every corpus reader
|
|
904
|
+
* and writer the command calls.
|
|
905
|
+
*
|
|
906
|
+
* @returns {{home: string, surface: string, reason: string|null, resolvedFrom: string}}
|
|
907
|
+
*/
|
|
908
|
+
function corpusHome(root, flags) {
|
|
909
|
+
const explicit = explicitHome(flags)
|
|
910
|
+
if (!explicit) {
|
|
911
|
+
const manifest = readManifest(root)
|
|
912
|
+
if (manifest && typeof manifest.stateHome === 'string' && manifest.stateHome) {
|
|
913
|
+
return {
|
|
914
|
+
home: manifest.stateHome,
|
|
915
|
+
surface: typeof manifest.surface === 'string' ? manifest.surface : 'unknown',
|
|
916
|
+
reason: typeof manifest.stateHomeReason === 'string' ? manifest.stateHomeReason : null,
|
|
917
|
+
resolvedFrom: typeof manifest.stateHomeFrom === 'string' ? manifest.stateHomeFrom : 'git'
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
return resolveStateHome(root, { explicit: explicit || undefined, env: {} })
|
|
922
|
+
}
|
|
923
|
+
|
|
831
924
|
function readInput(value, what) {
|
|
832
925
|
if (!value || value === true) die(`${what} is required`)
|
|
833
926
|
const raw = value === '-' ? readStdin() : readFileSync(value, 'utf8')
|
|
@@ -877,6 +970,51 @@ function emit(json, obj, text) {
|
|
|
877
970
|
process.stdout.write(json ? JSON.stringify(obj, null, 2) + '\n' : text)
|
|
878
971
|
}
|
|
879
972
|
|
|
973
|
+
/**
|
|
974
|
+
* Print a `run` step, shaped for the host that will read it, and keep a copy of
|
|
975
|
+
* the exact bytes printed.
|
|
976
|
+
*
|
|
977
|
+
* `relay: true` routes the step through `relayStep`, which slims it for the
|
|
978
|
+
* Workflow host — the one host whose stdout is retyped by a model rather than
|
|
979
|
+
* read by a process. The close does not come through here at all: its summary
|
|
980
|
+
* is what it is for, and it must not overwrite the copy a halt points at.
|
|
981
|
+
*
|
|
982
|
+
* The copy at LAST_STEP_PATH is forensic, not a corpus: when a relay's copy
|
|
983
|
+
* does not parse, the halt points here instead of at a transcript. A failed
|
|
984
|
+
* write is spoken on stderr and never touches the exit code — losing the copy
|
|
985
|
+
* must not lose the step.
|
|
986
|
+
*/
|
|
987
|
+
/**
|
|
988
|
+
* The machine's CPU count, or null when the platform will not say. The vendor
|
|
989
|
+
* reduces the Workflow runtime's concurrency on small machines by an amount it
|
|
990
|
+
* does not publish, so this is recorded beside the default and never turned
|
|
991
|
+
* into a number of its own.
|
|
992
|
+
*/
|
|
993
|
+
function cpuCount() {
|
|
994
|
+
try {
|
|
995
|
+
const n = cpus().length
|
|
996
|
+
return Number.isInteger(n) && n > 0 ? n : null
|
|
997
|
+
} catch {
|
|
998
|
+
return null
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function emitRunStep(json, root, step, text, { relay = false } = {}) {
|
|
1003
|
+
if (!json) {
|
|
1004
|
+
process.stdout.write(text)
|
|
1005
|
+
return
|
|
1006
|
+
}
|
|
1007
|
+
const shaped = relay ? relayStep(readManifest(root), step) : step
|
|
1008
|
+
const out = JSON.stringify(shaped, null, 2) + '\n'
|
|
1009
|
+
try {
|
|
1010
|
+
mkdirSync(join(root, dirname(LAST_STEP_PATH)), { recursive: true })
|
|
1011
|
+
writeFileSync(join(root, LAST_STEP_PATH), out)
|
|
1012
|
+
} catch (err) {
|
|
1013
|
+
process.stderr.write(`interlock: ${LAST_STEP_PATH} not written: ${(err && err.message) || err}\n`)
|
|
1014
|
+
}
|
|
1015
|
+
process.stdout.write(out)
|
|
1016
|
+
}
|
|
1017
|
+
|
|
880
1018
|
/**
|
|
881
1019
|
* record-* / replan: default stdout is the new state. --write-state writes that
|
|
882
1020
|
* state to a file and stdout becomes nextStep() — the shape `wave-state next`
|
|
@@ -934,13 +1072,18 @@ function changeFor(state, flagChange) {
|
|
|
934
1072
|
return changeOf(state) || flag
|
|
935
1073
|
}
|
|
936
1074
|
|
|
937
|
-
// `wave-state next` / `--write-state` both surface
|
|
938
|
-
// step. A `run-batch`/`test-wave` step carries
|
|
939
|
-
// LANES) and `remainingBatches` (rest of the
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
// record-batch --write-state
|
|
943
|
-
// events.
|
|
1075
|
+
// The `wave-state` protocol: `wave-state next` / `--write-state` both surface
|
|
1076
|
+
// the state machine's next step. A `run-batch`/`test-wave` step carries
|
|
1077
|
+
// `tasks[]` (the current batch, as LANES) and `remainingBatches` (rest of the
|
|
1078
|
+
// wave, as batches of lanes). A caller of that protocol runs remainingBatches
|
|
1079
|
+
// sequentially before its next record call, so the reconstructable spawn list
|
|
1080
|
+
// is remainingBatches — not only tasks[]. Mid-wave record-batch --write-state
|
|
1081
|
+
// is cursor catch-up and MUST NOT duplicate those events.
|
|
1082
|
+
//
|
|
1083
|
+
// The `run` program is NOT that protocol: it emits one step per batch, so it
|
|
1084
|
+
// passes the batch's own `lanes` and gets exactly those logged (see
|
|
1085
|
+
// `logLaneSpawns` in lib/run.mjs). Logging a whole wave there wrote spawn events
|
|
1086
|
+
// for lanes a halted run never dispatched.
|
|
944
1087
|
//
|
|
945
1088
|
// One spawn per LANE, because a lane is one agent. Counting per task would
|
|
946
1089
|
// report four spawns for a wave that ran two, which is the fold this change
|
|
@@ -960,13 +1103,21 @@ function spawnLanesOf(step) {
|
|
|
960
1103
|
// Takes the run's `state` rather than a runId, so the change name is resolved
|
|
961
1104
|
// from it the same way `logWaveMutation` does instead of being threaded in by a
|
|
962
1105
|
// caller who may not have been given one.
|
|
963
|
-
function logAgentSpawns(root, { state, change, step, source }) {
|
|
964
|
-
|
|
965
|
-
if (
|
|
1106
|
+
function logAgentSpawns(root, { state, change, step, source, lanes }) {
|
|
1107
|
+
const explicit = Array.isArray(lanes)
|
|
1108
|
+
if (!explicit) {
|
|
1109
|
+
if (!step || (step.action !== 'run-batch' && step.action !== 'test-wave')) return true
|
|
1110
|
+
if (source === 'record-batch' && Number(step.batchIndex) > 0) return true
|
|
1111
|
+
}
|
|
966
1112
|
const runId = runIdOf(state)
|
|
967
1113
|
const changeName = changeFor(state, change)
|
|
1114
|
+
const dispatched = explicit
|
|
1115
|
+
? lanes
|
|
1116
|
+
.map(lane => (Array.isArray(lane) ? lane.filter(t => t && typeof t === 'object') : []))
|
|
1117
|
+
.filter(lane => lane.length)
|
|
1118
|
+
: spawnLanesOf(step)
|
|
968
1119
|
let ok = true
|
|
969
|
-
for (const lane of
|
|
1120
|
+
for (const lane of dispatched) {
|
|
970
1121
|
const spawn = appendRunLogEvent(root, {
|
|
971
1122
|
runId,
|
|
972
1123
|
change: changeName,
|
|
@@ -1205,6 +1356,127 @@ function observedChangedPaths(root) {
|
|
|
1205
1356
|
return paths
|
|
1206
1357
|
}
|
|
1207
1358
|
|
|
1359
|
+
// The identity a shared-tree snapshot is committed under. It is nobody's: it is
|
|
1360
|
+
// set in the environment of the one `commit-tree` call and never in any config,
|
|
1361
|
+
// because `commit-tree` refuses to write without an identity, a CI checkout
|
|
1362
|
+
// often has none, and the user's name does not belong on an object they did not
|
|
1363
|
+
// author.
|
|
1364
|
+
const SNAPSHOT_IDENTITY = {
|
|
1365
|
+
GIT_AUTHOR_NAME: 'Interlock',
|
|
1366
|
+
GIT_AUTHOR_EMAIL: 'interlock@localhost',
|
|
1367
|
+
GIT_COMMITTER_NAME: 'Interlock',
|
|
1368
|
+
GIT_COMMITTER_EMAIL: 'interlock@localhost'
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* Every linked worktree of the repository that lies inside `root`, as a
|
|
1373
|
+
* root-relative path with forward slashes. Read from git at capture time rather
|
|
1374
|
+
* than kept as a list, so a host that moves where it puts its worktrees is
|
|
1375
|
+
* still covered. Both sides are resolved through `realpathSync` because git
|
|
1376
|
+
* reports real paths and `root` may arrive through a symlink (macOS's `/tmp`).
|
|
1377
|
+
*/
|
|
1378
|
+
function linkedWorktreesInside(root, porcelain) {
|
|
1379
|
+
const top = realpathSync(root)
|
|
1380
|
+
const inside = []
|
|
1381
|
+
for (const line of porcelain.split('\n')) {
|
|
1382
|
+
if (!line.startsWith('worktree ')) continue
|
|
1383
|
+
let path = line.slice('worktree '.length)
|
|
1384
|
+
try {
|
|
1385
|
+
path = realpathSync(path)
|
|
1386
|
+
} catch {
|
|
1387
|
+
// A worktree whose directory is gone is compared as git listed it.
|
|
1388
|
+
}
|
|
1389
|
+
const rel = relative(top, path)
|
|
1390
|
+
if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) continue
|
|
1391
|
+
inside.push(rel.split(sep).join('/'))
|
|
1392
|
+
}
|
|
1393
|
+
return inside
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* The commit an isolated batch's lanes fork from: the shared tree as it stands,
|
|
1398
|
+
* with every tracked edit, every deletion and every untracked file the ignore
|
|
1399
|
+
* rules let through — so it holds every earlier fold of the run, which HEAD
|
|
1400
|
+
* never does (the ship commit is the run's last step).
|
|
1401
|
+
*
|
|
1402
|
+
* Built through a temporary index and plumbing only. No ref moves, the
|
|
1403
|
+
* repository's own index is never opened for writing, and no working-tree file
|
|
1404
|
+
* changes. The commit is pinned by nothing: the lane worktrees forked from it
|
|
1405
|
+
* keep it reachable until the fold, and git's own expiry takes it after.
|
|
1406
|
+
*
|
|
1407
|
+
* - The index is seeded from HEAD, so a tracked-but-ignored file keeps its
|
|
1408
|
+
* committed content instead of dropping out of the tree.
|
|
1409
|
+
* - Interlock's run state and every linked worktree inside the root are left
|
|
1410
|
+
* out. Staged, a worktree becomes an embedded-repository entry; a lane's
|
|
1411
|
+
* checkout holds an empty directory there; and the fold reads that as a
|
|
1412
|
+
* deletion of a sibling lane's worktree.
|
|
1413
|
+
* - A tree identical to HEAD's returns HEAD itself and creates no object.
|
|
1414
|
+
*
|
|
1415
|
+
* @param {string} root
|
|
1416
|
+
* @param {string} [change] named in the snapshot's message, for whoever finds it
|
|
1417
|
+
* @returns {string|null} null on any failure — `decorate` halts on it rather
|
|
1418
|
+
* than forking lanes from a base nobody captured
|
|
1419
|
+
*/
|
|
1420
|
+
function snapshotTree(root, change) {
|
|
1421
|
+
let dir = null
|
|
1422
|
+
try {
|
|
1423
|
+
dir = mkdtempSync(join(tmpdir(), 'interlock-snapshot-'))
|
|
1424
|
+
const env = { ...process.env, GIT_INDEX_FILE: join(dir, 'index') }
|
|
1425
|
+
const git = (args, extraEnv = {}) =>
|
|
1426
|
+
execFileSync('git', args, {
|
|
1427
|
+
cwd: root,
|
|
1428
|
+
encoding: 'utf8',
|
|
1429
|
+
env: { ...env, ...extraEnv },
|
|
1430
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
1431
|
+
}).trim()
|
|
1432
|
+
|
|
1433
|
+
git(['read-tree', 'HEAD'])
|
|
1434
|
+
const worktrees = linkedWorktreesInside(root, git(['worktree', 'list', '--porcelain']))
|
|
1435
|
+
git([
|
|
1436
|
+
'add',
|
|
1437
|
+
'-A',
|
|
1438
|
+
'--',
|
|
1439
|
+
'.',
|
|
1440
|
+
':(exclude).claude/ship',
|
|
1441
|
+
...worktrees.map(rel => `:(exclude,literal)${rel}`)
|
|
1442
|
+
])
|
|
1443
|
+
const tree = git(['write-tree'])
|
|
1444
|
+
const head = git(['rev-parse', 'HEAD'])
|
|
1445
|
+
if (tree === git(['rev-parse', 'HEAD^{tree}'])) return head || null
|
|
1446
|
+
// --no-gpg-sign: `commit.gpgSign` would otherwise ask a signing agent to
|
|
1447
|
+
// sign an object nobody will ever push.
|
|
1448
|
+
const message = `interlock: shared-tree snapshot (${change || 'unknown change'})`
|
|
1449
|
+
return git(['commit-tree', '--no-gpg-sign', tree, '-p', head, '-m', message], SNAPSHOT_IDENTITY) || null
|
|
1450
|
+
} catch {
|
|
1451
|
+
return null
|
|
1452
|
+
} finally {
|
|
1453
|
+
if (dir) rmSync(dir, { recursive: true, force: true })
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* The commit a lane worktree is checked out at, as git reports it from inside
|
|
1459
|
+
* the worktree — never the base the lane's agent says it forked from, because
|
|
1460
|
+
* that report is the thing being audited. `null` when git cannot answer,
|
|
1461
|
+
* including when the directory is gone.
|
|
1462
|
+
*
|
|
1463
|
+
* @param {string} worktreePath
|
|
1464
|
+
* @returns {string|null}
|
|
1465
|
+
*/
|
|
1466
|
+
function worktreeHead(worktreePath) {
|
|
1467
|
+
if (typeof worktreePath !== 'string' || !worktreePath || !existsSync(worktreePath)) return null
|
|
1468
|
+
try {
|
|
1469
|
+
return (
|
|
1470
|
+
execFileSync('git', ['-C', worktreePath, 'rev-parse', 'HEAD'], {
|
|
1471
|
+
encoding: 'utf8',
|
|
1472
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
1473
|
+
}).trim() || null
|
|
1474
|
+
)
|
|
1475
|
+
} catch {
|
|
1476
|
+
return null
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1208
1480
|
/**
|
|
1209
1481
|
* A lane worktree's actual writes relative to the shared base commit, as git
|
|
1210
1482
|
* sees them — never a prediction. `null` means git could not answer (bad
|
|
@@ -1582,7 +1854,7 @@ async function main() {
|
|
|
1582
1854
|
if (flags.metrics !== undefined) {
|
|
1583
1855
|
if (flags.metrics === true) die('--metrics requires a change name')
|
|
1584
1856
|
const surviving = result.total + result.malformed.length
|
|
1585
|
-
metrics = writeReviewMetrics(root, {
|
|
1857
|
+
metrics = writeReviewMetrics(corpusHome(root, flags).home, {
|
|
1586
1858
|
change: String(flags.metrics),
|
|
1587
1859
|
counts: {
|
|
1588
1860
|
raised: result.dismissedCount + result.droppedByQuality + surviving,
|
|
@@ -1635,6 +1907,7 @@ async function main() {
|
|
|
1635
1907
|
// would encode. Every value here is one `if` away from a threshold; the
|
|
1636
1908
|
// absence of that `if` is the feature. See the header of lib/report.mjs.
|
|
1637
1909
|
const report = buildReport(root, {
|
|
1910
|
+
stateHome: corpusHome(root, flags),
|
|
1638
1911
|
since: typeof flags.since === 'string' ? flags.since : undefined,
|
|
1639
1912
|
change: typeof flags.change === 'string' ? flags.change : undefined,
|
|
1640
1913
|
maxRuns: num(flags, 'max-runs')
|
|
@@ -1698,23 +1971,27 @@ async function main() {
|
|
|
1698
1971
|
// `limits`: LIMITS holds positive-integer loop counts (its invariant test
|
|
1699
1972
|
// asserts it), whereas the lane-cap table is a tier→cap map and the solo
|
|
1700
1973
|
// envelope is a separate object — the same reason `effort` is emitted apart.
|
|
1974
|
+
// The slot count this machine's runtime will honour, as far as this
|
|
1975
|
+
// process can see it — beside the vendor fact, never instead of it.
|
|
1976
|
+
const runtimeObserved = runtimeSlotsOf(observeClaudeEnv(process.env, { cpuCount: cpuCount() }))
|
|
1701
1977
|
emit(
|
|
1702
1978
|
json,
|
|
1703
1979
|
{
|
|
1704
1980
|
limits: LIMITS,
|
|
1705
|
-
runtime: RUNTIME,
|
|
1981
|
+
runtime: { ...RUNTIME, observed: runtimeObserved },
|
|
1706
1982
|
evals: EVAL_CAPS,
|
|
1707
1983
|
// Its own group, beside `evals` rather than inside it: the eval caps
|
|
1708
1984
|
// are scalars a reader compares a running total against, and this is
|
|
1709
1985
|
// the table that produced the total. Its `id` is what a recorded
|
|
1710
|
-
// result row is keyed against.
|
|
1986
|
+
// result row is keyed against. Emitted whole, so each model's own
|
|
1987
|
+
// `cacheMultipliers.read` entry and both write tiers travel with it.
|
|
1711
1988
|
prices: MODEL_PRICES,
|
|
1712
1989
|
report: REPORT_CAPS,
|
|
1713
1990
|
effort: EFFORT,
|
|
1714
1991
|
laneCaps: LANE_CAPS,
|
|
1715
1992
|
solo: SOLO
|
|
1716
1993
|
},
|
|
1717
|
-
formatLimits()
|
|
1994
|
+
formatLimits({ runtimeObserved })
|
|
1718
1995
|
)
|
|
1719
1996
|
break
|
|
1720
1997
|
}
|
|
@@ -1779,7 +2056,7 @@ async function main() {
|
|
|
1779
2056
|
// Read-only by construction — see the header of lib/doctor.mjs. The exit
|
|
1780
2057
|
// code is the whole point: a preflight that only prints is a preflight a
|
|
1781
2058
|
// zero-touch run walks straight past.
|
|
1782
|
-
const report = diagnose(root)
|
|
2059
|
+
const report = diagnose(root, { cpuCount: cpuCount(), stateHome: explicitHome(flags) || undefined })
|
|
1783
2060
|
emit(json, report, formatDoctor(report))
|
|
1784
2061
|
if (!report.ok) process.exit(1)
|
|
1785
2062
|
break
|
|
@@ -1798,6 +2075,7 @@ async function main() {
|
|
|
1798
2075
|
const outDir = typeof flags.out === 'string' ? flags.out : ''
|
|
1799
2076
|
if (!outDir) die('evals capture requires --out <dir>')
|
|
1800
2077
|
const result = captureCase(root, {
|
|
2078
|
+
stateHome: corpusHome(root, flags).home,
|
|
1801
2079
|
runId,
|
|
1802
2080
|
taskId: typeof flags.task === 'string' ? flags.task : undefined,
|
|
1803
2081
|
outDir
|
|
@@ -1911,7 +2189,7 @@ async function main() {
|
|
|
1911
2189
|
let metrics = null
|
|
1912
2190
|
if (flags.metrics !== undefined) {
|
|
1913
2191
|
if (flags.metrics === true) die('--metrics requires a change name')
|
|
1914
|
-
metrics = writeReviewMetrics(root, { change: String(flags.metrics), counts: result.counts })
|
|
2192
|
+
metrics = writeReviewMetrics(corpusHome(root, flags).home, { change: String(flags.metrics), counts: result.counts })
|
|
1915
2193
|
if (!metrics.written) warn(`metrics not written: ${metrics.reason}`)
|
|
1916
2194
|
}
|
|
1917
2195
|
|
|
@@ -2029,10 +2307,11 @@ async function main() {
|
|
|
2029
2307
|
// trusting the untrusted source exactly when the trusted one is
|
|
2030
2308
|
// missing would defeat the partition on the runs most likely to need
|
|
2031
2309
|
// it.
|
|
2310
|
+
const home = corpusHome(root, flags).home
|
|
2032
2311
|
const receipt = runId
|
|
2033
|
-
? queryRunLog(
|
|
2312
|
+
? queryRunLog(home, runId, { type: 'run-receipt' }).records.pop() || null
|
|
2034
2313
|
: null
|
|
2035
|
-
const written = appendOutcome(
|
|
2314
|
+
const written = appendOutcome(home, { ...reportedHalf, observed: observedFromReceipt(receipt) })
|
|
2036
2315
|
if (carriedObserved) {
|
|
2037
2316
|
warn(
|
|
2038
2317
|
'outcome record carried an "observed" group: refused. Observed values are derived ' +
|
|
@@ -2054,7 +2333,7 @@ async function main() {
|
|
|
2054
2333
|
`${written.written ? `recorded → ${written.path}` : `not recorded: ${written.reason}`}\n`
|
|
2055
2334
|
)
|
|
2056
2335
|
} else if (sub === 'list' || sub === undefined) {
|
|
2057
|
-
const result = readOutcomes(root)
|
|
2336
|
+
const result = readOutcomes(corpusHome(root, flags).home)
|
|
2058
2337
|
emit(json, result, formatOutcomes(result))
|
|
2059
2338
|
} else {
|
|
2060
2339
|
die(`unknown outcomes subcommand: ${sub}`)
|
|
@@ -2064,9 +2343,10 @@ async function main() {
|
|
|
2064
2343
|
|
|
2065
2344
|
case 'run-log': {
|
|
2066
2345
|
const sub = positional[1]
|
|
2346
|
+
const logHome = corpusHome(root, flags).home
|
|
2067
2347
|
if (sub === 'append') {
|
|
2068
2348
|
const record = readInput(flags.event, '--event <file|->')
|
|
2069
|
-
const { written, path, reason } = appendRunLogEvent(
|
|
2349
|
+
const { written, path, reason } = appendRunLogEvent(logHome, record)
|
|
2070
2350
|
if (!written) {
|
|
2071
2351
|
// Never fatal: losing a trajectory line must not fail the run that
|
|
2072
2352
|
// produced it. Say so on stderr and exit 0.
|
|
@@ -2075,17 +2355,17 @@ async function main() {
|
|
|
2075
2355
|
emit(json, { written, path, reason }, `${written ? `recorded → ${path}` : `not recorded: ${reason}`}\n`)
|
|
2076
2356
|
} else if (sub === 'list') {
|
|
2077
2357
|
const change = typeof flags.change === 'string' ? flags.change : undefined
|
|
2078
|
-
const runs = listRunLogs(
|
|
2358
|
+
const runs = listRunLogs(logHome, { change })
|
|
2079
2359
|
emit(json, { runs }, formatRunLogList(runs))
|
|
2080
2360
|
} else if (sub === 'show') {
|
|
2081
2361
|
const runId = positional[2]
|
|
2082
2362
|
if (!runId) die('run-log show requires a runId')
|
|
2083
|
-
const result = readRunLog(
|
|
2363
|
+
const result = readRunLog(logHome, runId)
|
|
2084
2364
|
emit(json, result, formatRunLog(result))
|
|
2085
2365
|
} else if (sub === 'query') {
|
|
2086
2366
|
const runId = typeof flags.run === 'string' ? flags.run : undefined
|
|
2087
2367
|
if (!runId) die('--run is required')
|
|
2088
|
-
const result = queryRunLog(
|
|
2368
|
+
const result = queryRunLog(logHome, runId, {
|
|
2089
2369
|
type: typeof flags.type === 'string' ? flags.type : undefined,
|
|
2090
2370
|
halted: flags.halted === true
|
|
2091
2371
|
})
|
|
@@ -2093,7 +2373,7 @@ async function main() {
|
|
|
2093
2373
|
} else if (sub === 'check') {
|
|
2094
2374
|
const runId = runIdFromFlags(flags)
|
|
2095
2375
|
if (!runId) die('run-log check requires --state <file> (with a runId) or --run-id <id>')
|
|
2096
|
-
const result = checkRunLog(
|
|
2376
|
+
const result = checkRunLog(logHome, runId)
|
|
2097
2377
|
emit(json, result, formatRunLogCheck(result))
|
|
2098
2378
|
if (!result.ok) process.exit(1)
|
|
2099
2379
|
} else {
|
|
@@ -2205,7 +2485,7 @@ async function main() {
|
|
|
2205
2485
|
}
|
|
2206
2486
|
const opts = { context, ...verifyJudgeOpts(flags) }
|
|
2207
2487
|
const verdict = judgeVerification(plan, results, opts)
|
|
2208
|
-
const logOk = logVerifyJudgement(root, {
|
|
2488
|
+
const logOk = logVerifyJudgement(corpusHome(root, flags).home, {
|
|
2209
2489
|
runId: runIdFromFlags(flags),
|
|
2210
2490
|
// Same precedence as every wave-state append site: the state names the
|
|
2211
2491
|
// run, the flag is only the pre-state-name fallback.
|
|
@@ -2237,6 +2517,42 @@ async function main() {
|
|
|
2237
2517
|
result,
|
|
2238
2518
|
`spilled ${result.bytes} byte(s) → ${result.locator}${result.truncated ? ' (truncated preview)' : ''}\n`
|
|
2239
2519
|
)
|
|
2520
|
+
} else if (sub === 'exec') {
|
|
2521
|
+
const kind = typeof flags.kind === 'string' ? flags.kind.trim() : ''
|
|
2522
|
+
if (!kind) die('--kind is required')
|
|
2523
|
+
const command = typeof flags.command === 'string' ? flags.command : ''
|
|
2524
|
+
if (!command.trim()) die('--command is required')
|
|
2525
|
+
const cwd = typeof flags.cwd === 'string' && flags.cwd.trim() ? flags.cwd : undefined
|
|
2526
|
+
// Timed here, around the command and nothing else: the inter-wave budget
|
|
2527
|
+
// used to be clocked from the step's dispatch to its judgement, which
|
|
2528
|
+
// charged two relays and the verify agent's own turns to "verification"
|
|
2529
|
+
// and spent the whole budget on the first checkpoint of every Workflow run.
|
|
2530
|
+
const started = performance.now()
|
|
2531
|
+
const ran = spawnSync(command, { shell: true, stdio: 'inherit', cwd })
|
|
2532
|
+
const durationMs = Math.round(performance.now() - started)
|
|
2533
|
+
let exitCode
|
|
2534
|
+
if (ran.error) {
|
|
2535
|
+
process.stderr.write(`interlock: verify exec could not run the command: ${ran.error.message}\n`)
|
|
2536
|
+
exitCode = 127
|
|
2537
|
+
} else if (typeof ran.status === 'number') {
|
|
2538
|
+
exitCode = ran.status
|
|
2539
|
+
} else {
|
|
2540
|
+
const signal = ran.signal && osConstants.signals[ran.signal]
|
|
2541
|
+
exitCode = Number.isInteger(signal) ? 128 + signal : 1
|
|
2542
|
+
}
|
|
2543
|
+
try {
|
|
2544
|
+
mkdirSync(join(root, dirname(VERIFY_TIMINGS_PATH)), { recursive: true })
|
|
2545
|
+
appendFileSync(
|
|
2546
|
+
join(root, VERIFY_TIMINGS_PATH),
|
|
2547
|
+
JSON.stringify({ kind, exitCode, durationMs, command, at: new Date().toISOString() }) + '\n'
|
|
2548
|
+
)
|
|
2549
|
+
} catch (err) {
|
|
2550
|
+
// A timing is a budget input, not a gate: losing it costs the budget
|
|
2551
|
+
// its precision (the judge falls back to the round trip, and says
|
|
2552
|
+
// so), never the check its result.
|
|
2553
|
+
process.stderr.write(`interlock: verify timing not recorded: ${(err && err.message) || err}\n`)
|
|
2554
|
+
}
|
|
2555
|
+
process.exit(exitCode)
|
|
2240
2556
|
} else if (sub === 'cluster') {
|
|
2241
2557
|
const failures = readInput(flags.failures, '--failures <file|->')
|
|
2242
2558
|
if (!Array.isArray(failures)) {
|
|
@@ -2254,7 +2570,7 @@ async function main() {
|
|
|
2254
2570
|
if (action.action === 'halt') process.exit(1)
|
|
2255
2571
|
} else {
|
|
2256
2572
|
die(
|
|
2257
|
-
`unknown verify subcommand: ${sub || '(none)'} — expected plan|judge|unit|spill|cluster|repair`
|
|
2573
|
+
`unknown verify subcommand: ${sub || '(none)'} — expected plan|judge|unit|spill|cluster|repair|exec`
|
|
2258
2574
|
)
|
|
2259
2575
|
}
|
|
2260
2576
|
break
|
|
@@ -2381,11 +2697,13 @@ async function main() {
|
|
|
2381
2697
|
stdio: ['ignore', 'pipe', 'ignore']
|
|
2382
2698
|
}).trim() || null
|
|
2383
2699
|
} catch {
|
|
2384
|
-
// An unreadable base is null, never a guess: `
|
|
2385
|
-
// it rather than folding a batch against a commit nobody read.
|
|
2700
|
+
// An unreadable base is null, never a guess: `record-batch` halts
|
|
2701
|
+
// on it rather than folding a batch against a commit nobody read.
|
|
2386
2702
|
return null
|
|
2387
2703
|
}
|
|
2388
2704
|
},
|
|
2705
|
+
snapshotTree,
|
|
2706
|
+
worktreeHead,
|
|
2389
2707
|
logWaveMutation,
|
|
2390
2708
|
logAgentSpawns,
|
|
2391
2709
|
observedChangedPaths,
|
|
@@ -2417,6 +2735,11 @@ async function main() {
|
|
|
2417
2735
|
|
|
2418
2736
|
const results = flags.results ? readInput(flags.results, '--results <file|->') : []
|
|
2419
2737
|
const resultList = Array.isArray(results) ? results : [results]
|
|
2738
|
+
// The host's own observation of each spawn, beside the agents' results and
|
|
2739
|
+
// never inside them: a result is the agent's report, a host record is what
|
|
2740
|
+
// the host saw (design D7). Only the runner sends one; the CLI decides.
|
|
2741
|
+
const records = flags['host-records'] ? readInput(flags['host-records'], '--host-records <file|->') : []
|
|
2742
|
+
const hostRecords = Array.isArray(records) ? records : [records]
|
|
2420
2743
|
|
|
2421
2744
|
let step
|
|
2422
2745
|
if (sub === 'start') {
|
|
@@ -2434,7 +2757,7 @@ async function main() {
|
|
|
2434
2757
|
: ''
|
|
2435
2758
|
}`
|
|
2436
2759
|
)
|
|
2437
|
-
|
|
2760
|
+
emitRunStep(json, root, step, formatRunStep(step))
|
|
2438
2761
|
break
|
|
2439
2762
|
}
|
|
2440
2763
|
const name = resolved.name
|
|
@@ -2450,10 +2773,35 @@ async function main() {
|
|
|
2450
2773
|
die(`--host-capabilities must be a JSON object of the host's declared capabilities: ${err.message}`)
|
|
2451
2774
|
}
|
|
2452
2775
|
}
|
|
2776
|
+
const hostId = typeof flags.host === 'string' ? flags.host : undefined
|
|
2777
|
+
// The Workflow host used to record `effort: flag` because the runtime's
|
|
2778
|
+
// `agent()` option cannot be watched from this process. The installed
|
|
2779
|
+
// CLI's help can. A caller that already named the capability keeps it.
|
|
2780
|
+
let workflowEffortProbe = null
|
|
2781
|
+
if (
|
|
2782
|
+
hostId === 'workflow' &&
|
|
2783
|
+
!(hostCapabilities && Object.prototype.hasOwnProperty.call(hostCapabilities, 'effort'))
|
|
2784
|
+
) {
|
|
2785
|
+
workflowEffortProbe = probeClaudeEffort(process.env)
|
|
2786
|
+
hostCapabilities = { ...(hostCapabilities || {}), effort: workflowEffortProbe.capability }
|
|
2787
|
+
}
|
|
2788
|
+
// The model-routing variables, the slot override and the host version,
|
|
2789
|
+
// read from THIS process's environment — the Workflow driver forwards
|
|
2790
|
+
// what `runStart` decides from them and reads none of them itself.
|
|
2791
|
+
let hostEnv
|
|
2792
|
+
if (hostId === 'workflow') {
|
|
2793
|
+
const probed = probeClaudeVersion(process.env)
|
|
2794
|
+
hostEnv = {
|
|
2795
|
+
...observeClaudeEnv(process.env, { version: probed.version, cpuCount: cpuCount() }),
|
|
2796
|
+
...(probed.reason ? { versionReason: probed.reason } : {})
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2453
2799
|
step = runStart(ctx, {
|
|
2454
2800
|
change: name,
|
|
2455
|
-
host:
|
|
2801
|
+
host: hostId,
|
|
2456
2802
|
hostCapabilities,
|
|
2803
|
+
hostEnv,
|
|
2804
|
+
stateHome: startStateHome(root, flags),
|
|
2457
2805
|
flags: {
|
|
2458
2806
|
applyOnly: flags['apply-only'] === true,
|
|
2459
2807
|
noCommit: flags['no-commit'] === true,
|
|
@@ -2470,6 +2818,18 @@ async function main() {
|
|
|
2470
2818
|
strict: flags.strict !== undefined
|
|
2471
2819
|
}
|
|
2472
2820
|
})
|
|
2821
|
+
if (workflowEffortProbe && workflowEffortProbe.capability === 'unsupported') {
|
|
2822
|
+
// Spoken where the assumption used to be silent. The script still
|
|
2823
|
+
// forwards the option: `agent()` does not reject an unknown key the
|
|
2824
|
+
// way the CLI rejects an unknown flag.
|
|
2825
|
+
const banner = `EFFORT ROUTING UNAVAILABLE (workflow): ${workflowEffortProbe.reason}`
|
|
2826
|
+
const manifest = readManifest(root)
|
|
2827
|
+
if (manifest) {
|
|
2828
|
+
manifest.banners = [...(manifest.banners || []), banner]
|
|
2829
|
+
writeManifest(root, manifest)
|
|
2830
|
+
}
|
|
2831
|
+
if (step && typeof step === 'object') step.banners = [...(step.banners || []), banner]
|
|
2832
|
+
}
|
|
2473
2833
|
} else if (sub === 'classified') {
|
|
2474
2834
|
const classified =
|
|
2475
2835
|
typeof flags.classified === 'string' && flags.classified.trim()
|
|
@@ -2477,25 +2837,25 @@ async function main() {
|
|
|
2477
2837
|
: CLASSIFIED_PATH
|
|
2478
2838
|
step = runClassified(ctx, { classifiedPath: classified })
|
|
2479
2839
|
} else if (sub === 'record-batch') {
|
|
2480
|
-
step = runRecordBatch(ctx, { results: resultList })
|
|
2840
|
+
step = runRecordBatch(ctx, { results: resultList, hostRecords })
|
|
2481
2841
|
} else if (sub === 'judge') {
|
|
2482
2842
|
const context = flags.context === undefined ? 'final' : String(flags.context)
|
|
2483
2843
|
if (context !== 'inter-wave' && context !== 'final') {
|
|
2484
2844
|
die(`--context must be inter-wave|final (got "${context}")`)
|
|
2485
2845
|
}
|
|
2486
|
-
step = runJudge(ctx, { context, results: resultList })
|
|
2846
|
+
step = runJudge(ctx, { context, results: resultList, hostRecords })
|
|
2487
2847
|
} else if (sub === 'verify-final') {
|
|
2488
2848
|
step = runVerifyFinal(ctx)
|
|
2489
2849
|
} else if (sub === 'reviewed') {
|
|
2490
|
-
step = runReviewed(ctx, { results: resultList })
|
|
2850
|
+
step = runReviewed(ctx, { results: resultList, hostRecords })
|
|
2491
2851
|
} else if (sub === 'remediated') {
|
|
2492
2852
|
const roundNum = num(flags, 'round')
|
|
2493
2853
|
if (!Number.isInteger(roundNum)) die('run remediated requires --round <n>')
|
|
2494
|
-
step = runRemediated(ctx, { round: roundNum, results: resultList })
|
|
2854
|
+
step = runRemediated(ctx, { round: roundNum, results: resultList, hostRecords })
|
|
2495
2855
|
} else if (sub === 'commit') {
|
|
2496
|
-
step = runCommit(ctx, { results: resultList })
|
|
2856
|
+
step = runCommit(ctx, { results: resultList, hostRecords })
|
|
2497
2857
|
} else if (sub === 'replan') {
|
|
2498
|
-
step = runReplan(ctx, { results: resultList })
|
|
2858
|
+
step = runReplan(ctx, { results: resultList, hostRecords })
|
|
2499
2859
|
} else if (sub === 'next') {
|
|
2500
2860
|
step = runNext(ctx)
|
|
2501
2861
|
} else if (sub === 'close') {
|
|
@@ -2510,8 +2870,12 @@ async function main() {
|
|
|
2510
2870
|
halt: typeof flags.halt === 'string' ? flags.halt : null,
|
|
2511
2871
|
hostBanners: Array.isArray(hostBanners) ? hostBanners : [],
|
|
2512
2872
|
hostObserved,
|
|
2873
|
+
hostRecords,
|
|
2513
2874
|
notify: flags.notify === true
|
|
2514
2875
|
})
|
|
2876
|
+
// Printed plainly, and NOT copied to LAST_STEP_PATH: a halted run closes
|
|
2877
|
+
// through here, and overwriting the file would replace the very step the
|
|
2878
|
+
// halt reason points a reader at with the close's own summary.
|
|
2515
2879
|
emit(json, step, step.summary + '\n')
|
|
2516
2880
|
if (step.exitCode !== 0) process.exit(step.exitCode)
|
|
2517
2881
|
break
|
|
@@ -2522,7 +2886,7 @@ async function main() {
|
|
|
2522
2886
|
)
|
|
2523
2887
|
}
|
|
2524
2888
|
|
|
2525
|
-
|
|
2889
|
+
emitRunStep(json, root, step, formatRunStep(step), { relay: true })
|
|
2526
2890
|
// A halt step still exits 0: its `then` is the close, and a driver that
|
|
2527
2891
|
// stopped here would leave the run with no receipt and no terminal event.
|
|
2528
2892
|
// `run close` is the call that carries the non-zero exit.
|
|
@@ -2532,19 +2896,22 @@ async function main() {
|
|
|
2532
2896
|
case 'wave-state': {
|
|
2533
2897
|
const sub = positional[1]
|
|
2534
2898
|
const change = typeof flags.change === 'string' ? flags.change : undefined
|
|
2899
|
+
// The trajectory this command appends to lives in the state home; the
|
|
2900
|
+
// changed-path read below stays on the working tree.
|
|
2901
|
+
const logRoot = corpusHome(root, flags).home
|
|
2535
2902
|
if (sub === 'create') {
|
|
2536
2903
|
const plan = readInput(flags.plan, '--plan <file|->')
|
|
2537
2904
|
const maxParallel = num(flags, 'max-parallel')
|
|
2538
2905
|
// The one place the name enters the run: from here it travels on the
|
|
2539
2906
|
// state, and no later wave-state invocation needs the flag.
|
|
2540
2907
|
const state = createRunState(plan, { maxParallel, change })
|
|
2541
|
-
const { ok: logOk } = logWaveMutation(
|
|
2908
|
+
const { ok: logOk } = logWaveMutation(logRoot, { source: 'create', change, state })
|
|
2542
2909
|
emit(json, state, formatRunState(state))
|
|
2543
2910
|
if (!logOk) process.exit(1)
|
|
2544
2911
|
} else if (sub === 'next') {
|
|
2545
2912
|
const state = readInput(flags.state, '--state <file|->')
|
|
2546
|
-
const { step, ok: logOk } = logWaveMutation(
|
|
2547
|
-
const spawnOk = logAgentSpawns(
|
|
2913
|
+
const { step, ok: logOk } = logWaveMutation(logRoot, { source: 'next', change, state })
|
|
2914
|
+
const spawnOk = logAgentSpawns(logRoot, { state, change, step, source: 'next' })
|
|
2548
2915
|
emit(json, step, formatRunState(state))
|
|
2549
2916
|
if (step.action === 'halt' || !logOk || !spawnOk) process.exit(1)
|
|
2550
2917
|
} else if (sub === 'record-batch' || sub === 'record-verify') {
|
|
@@ -2557,11 +2924,11 @@ async function main() {
|
|
|
2557
2924
|
sub === 'record-batch'
|
|
2558
2925
|
? recordBatchResult(state, result, { changedPaths: observedChangedPaths(root) })
|
|
2559
2926
|
: recordVerifyResult(state, result)
|
|
2560
|
-
const { ok: logOk } = logWaveMutation(
|
|
2927
|
+
const { ok: logOk } = logWaveMutation(logRoot, { source: sub, change, state: next })
|
|
2561
2928
|
// Derived from the two states rather than returned by the recorder, so
|
|
2562
2929
|
// `recordBatchResult`'s signature and every other caller are untouched.
|
|
2563
2930
|
const recorded = sub === 'record-batch' ? batchOutcomes(state, next) : undefined
|
|
2564
|
-
emitRecordedState(json, flags, next, { root, change, logOk, source: sub, recorded })
|
|
2931
|
+
emitRecordedState(json, flags, next, { root: logRoot, change, logOk, source: sub, recorded })
|
|
2565
2932
|
} else if (sub === 'replan') {
|
|
2566
2933
|
const state = readInput(flags.state, '--state <file|->')
|
|
2567
2934
|
const groups = readInput(flags.groups, '--groups <file|->')
|
|
@@ -2569,7 +2936,7 @@ async function main() {
|
|
|
2569
2936
|
die('--groups must be a JSON array of { group, tasks } revisions')
|
|
2570
2937
|
}
|
|
2571
2938
|
const next = applyReplan(state, groups)
|
|
2572
|
-
const { ok: logOk } = logWaveMutation(
|
|
2939
|
+
const { ok: logOk } = logWaveMutation(logRoot, { source: 'replan', change, state: next })
|
|
2573
2940
|
emitRecordedState(json, flags, next, { root, change, logOk, source: 'replan' })
|
|
2574
2941
|
} else {
|
|
2575
2942
|
die(
|
|
@@ -2582,7 +2949,8 @@ async function main() {
|
|
|
2582
2949
|
|
|
2583
2950
|
case 'autonomy': {
|
|
2584
2951
|
const sub = positional[1]
|
|
2585
|
-
|
|
2952
|
+
// The autonomy ledger is a corpus: it lives in the state home (ship/state-home).
|
|
2953
|
+
const opts = { root: corpusHome(root, flags).home }
|
|
2586
2954
|
if (sub === 'level') {
|
|
2587
2955
|
const path = positional[2]
|
|
2588
2956
|
if (!path) die('autonomy level requires a path')
|