@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
@@ -10,7 +10,7 @@
10
10
  "name": "interlock",
11
11
  "source": "./",
12
12
  "description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec.",
13
- "version": "1.1.1",
13
+ "version": "1.2.0",
14
14
  "author": {
15
15
  "name": "Carl Rollon"
16
16
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "interlock",
3
3
  "displayName": "Interlock",
4
- "version": "1.1.1",
4
+ "version": "1.2.0",
5
5
  "description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec. Onboard a repo, produce a reviewed spec, then ship it start-to-commit with parallel agents and a deterministic gate spine.",
6
6
  "author": {
7
7
  "name": "Carl Rollon",
@@ -19,6 +19,7 @@
19
19
  "knowledge-graph"
20
20
  ],
21
21
  "workflows": "./workflows",
22
+ "types": "./types/index.d.ts",
22
23
  "hooks": {
23
24
  "SessionStart": [
24
25
  {
@@ -30,6 +31,58 @@
30
31
  ]
31
32
  }
32
33
  ],
34
+ "SessionEnd": [
35
+ {
36
+ "hooks": [
37
+ {
38
+ "type": "command",
39
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/recorder.mjs\""
40
+ }
41
+ ]
42
+ }
43
+ ],
44
+ "SubagentStart": [
45
+ {
46
+ "matcher": "^workflow-subagent$",
47
+ "hooks": [
48
+ {
49
+ "type": "command",
50
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/recorder.mjs\""
51
+ }
52
+ ]
53
+ }
54
+ ],
55
+ "SubagentStop": [
56
+ {
57
+ "matcher": "^workflow-subagent$",
58
+ "hooks": [
59
+ {
60
+ "type": "command",
61
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/recorder.mjs\""
62
+ }
63
+ ]
64
+ }
65
+ ],
66
+ "PermissionDenied": [
67
+ {
68
+ "hooks": [
69
+ {
70
+ "type": "command",
71
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/recorder.mjs\""
72
+ }
73
+ ]
74
+ }
75
+ ],
76
+ "PermissionRequest": [
77
+ {
78
+ "hooks": [
79
+ {
80
+ "type": "command",
81
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/recorder.mjs\""
82
+ }
83
+ ]
84
+ }
85
+ ],
33
86
  "PreToolUse": [
34
87
  {
35
88
  "matcher": "Edit|Write",
@@ -52,6 +105,46 @@
52
105
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/guard-commit.mjs\""
53
106
  }
54
107
  ]
108
+ },
109
+ {
110
+ "matcher": "Workflow",
111
+ "hooks": [
112
+ {
113
+ "type": "command",
114
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/guard-relaunch.mjs\""
115
+ }
116
+ ]
117
+ }
118
+ ],
119
+ "PostToolUse": [
120
+ {
121
+ "matcher": "Workflow",
122
+ "hooks": [
123
+ {
124
+ "type": "command",
125
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/guard-relaunch.mjs\""
126
+ }
127
+ ]
128
+ }
129
+ ],
130
+ "UserPromptSubmit": [
131
+ {
132
+ "hooks": [
133
+ {
134
+ "type": "command",
135
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/guard-relaunch.mjs\""
136
+ }
137
+ ]
138
+ }
139
+ ],
140
+ "UserPromptExpansion": [
141
+ {
142
+ "hooks": [
143
+ {
144
+ "type": "command",
145
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/guard-relaunch.mjs\""
146
+ }
147
+ ]
55
148
  }
56
149
  ]
57
150
  }
package/CHANGELOG.md CHANGED
@@ -9,6 +9,75 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
9
9
 
10
10
  ### Added
11
11
 
12
+ - **The ship meter says how long a run has been quiet.** A subscription run that reaches a plan window pauses instead of failing, and from the status line that pause looked exactly like a hang. While a run is live, `hooks/mod.mjs` now stamps the engine's time of each thing the run does: the launch, each step the CLI prints, each request a run agent starts and each answer it gets, each agent's turn end, each spawn. Once nothing has moved for `meter quiet after (ms)`, ` · quiet <n> min` follows the position on the status line and the spinner, and the pane gains a `last activity <time>` line with the word beside it. The next activity clears it. A lane thinking through one long request is not called quiet, because the request's start and its answer both count. The word names no cause: the plan windows stay beside it on the pane as the session reports them, with no threshold and no colour.
13
+ - **Two caps, published, not restated.** `interlock limits` prints `meter quiet after (ms)` and `meter tick (ms)`. Both are keys in `lib/limits.mjs`, read by a new pure, Node-free `lib/meter-quiet.mjs` that the hooks module imports, and `test/spine/limits.test.mjs` fails if the module restates either.
14
+ - **One timer, the host's own.** The figure is recomputed on `$.clock.every`, the engine's periodic dispatch, which the module starts at the launch and cancels at the close, a halt and the session boundary. A redraw happens only when the word changes. A sleep inside a hook would have been charged to that hook's own time budget, so `$.clock.sleep` joins the module's forbidden tokens. The pins' allow-list gains `$.clock.every` and nothing else.
15
+ - **A time it cannot read is no figure.** A clock read that fails leaves the stamp as it was and the word absent, and is named once per run on the debug log. A run whose clock never answered reads `last activity unknown`. When the launch guard cannot read the clock, it now reports no time instead of zero, so the meter never stamps a launch at the epoch.
16
+ - **The probes came first.** Under `claude plugin test`, `mock.clock().advance` drives a module's `$.clock.every` period by period, so the kit cases run the real interval; the clock-failure cases answer the clock themselves, because the kit takes one `clock.now` answer per test. In a `--plugin-dir` terminal session on 2.1.289, an interval a module started stopped at the module's hot reload and the reloaded module's own interval took over, so a reload leaves no orphan ticking.
17
+
18
+ - **Ship-run agents are shown with what they are doing, not just a task id.** A lane that was `1.1+5` in `/workflows`, the ship meter and `interlock-run`'s log is now `1.1+5 · Add the relaunch guard to…`: its label, then the first words of its first task, capped at 48 characters. A relay ping is `cli-12 · run next`. The title is display only: the briefing file, the lane worktree, the trajectory, `run record-batch` and every banner still name the lane by its label, so rewording a task changes nothing a run joins on. The title is a pure function of the lane, so a resumed run still cache-hits. Every spawn now carries `title`, and a spawn that is not a lane uses its label as its title.
19
+
20
+ - **The launch guard now also runs inside the engine, reading the prompt origin the engine stamps.** `guard-relaunch` tells a person from the host's completion wake by the prompt text's `<task-notification>` prefix. On a host that loads plugin mods (Claude Code 2.1.287 and later), `hooks/mod.mjs` now applies the same rule in process. A ship launch with no human prompt since the session's last is refused before the engine or the settings layer sees the call. The words are the same, and the model reads them as the Workflow tool's error text.
21
+
22
+ - **What counts as a person is the engine's word, not a text prefix.** A `prompt.submit` whose `origin.kind` is `composer` (your own Enter, typed or queued), `bridge` (Remote Control) or `sdk` (a `claude -p` or Agent SDK turn) records a human prompt. A task notification, a scheduled trigger, a peer session, a channel, a coordinator, an observer, a plugin's own prompt and a missing origin count nothing. Each of those leaves one debug-log line naming its kind. The prompt is never rewritten or dropped. A ship launch is `workflows/ship.js`, `interlock:ship`, or a resume naming the run id or persisted script path this session recorded. A launch the engine refused or errored is not recorded. A relayed task notification at `session.receive` is named on the debug log and passed on, never consumed.
23
+ - **The record is declared session state.** One key, `interlock.ledger`, holds the file ledger's shape and is declared in a new type contract, `types/index.d.ts`. It survives a reload of the module, `/clear`, `/resume` and `/branch` empty it (the next launch is allowed), and the machine-wide store is never read or written. Time comes from `$.clock.now()`, so the test kit can move it. A `-p` run has one `sdk` prompt and then a launch, so a second launch in the same run is refused there too.
24
+ - **The two forms never refuse twice.** On a mods host the module's `tool.call` hook runs before the plugins' `PreToolUse` settings hooks. Its refusal ends the call, so `guard-relaunch.mjs` never starts and the model gets one reason. Its allow falls through to the settings form, which decides from its own file ledger. They read one thing differently, and it is named: a peer, scheduled-trigger or channel prompt re-arms the settings form and not the module, so on a mods host the stricter reading decides first. Below 2.1.287, and wherever user mods are stopped, the settings form is the only enforcement and is unchanged. Retiring its four registrations waits on the plugin's version floor. A prompt typed and queued during the run still re-arms one launch, and that hole is named, not closed, in both forms. See [the guards](docs/13-the-guards.md#two-forms-and-which-is-active).
25
+ - **It fails open in process too.** A state or clock read that fails is read as an empty record and named on the debug log, so the launch reaches the settings form's own decision. A launch or prompt that cannot be recorded is named the same way, and never thrown out of a hook whose call already ran. A hook that throws is skipped by the engine.
26
+ - **The probes came first, and one moved the design.** On 2.1.289, an interactive session with a logging mod saw the typed prompt as `origin: { kind: 'composer' }` and the completion wake as `origin: { kind: 'task-notification' }` with a `turnId`. The wake came through `prompt.submit` only: `session.receive` did not fire for it, so the debug line for a wake is written from `prompt.submit`. Both payloads are pinned, scrubbed, under `test/fixtures/mod/`. On 2.1.161, the manifest's `types` key draws `Unknown field 'types'. Claude Code ignores it at load time.`, and a headless session showed its `SessionStart` and `PreToolUse` settings hooks still running beside the key, so the key ships. Only `claude plugin validate --strict` fails there. On 2.1.274, a module that passes `$` into `read` or `update` imported from `claude-code` is refused, which would have unloaded the meter as well. The module therefore calls `$.state.get` and `$.state.set` directly and imports nothing from `claude-code`, and 2.1.274 passes `--strict` on the plugin.
27
+ - **The table is repeated under the engine's kit.** `test/mod/relaunch.test.ts` runs under `claude plugin test .`. It covers the first launch and the second without a prompt (in a `-p` and an interactive session), every human and non-human origin between two launches, the resume by run id and by script, another workflow, a refused and an errored launch, the relayed notification, the aged launch, and an unreadable state.
28
+
29
+ - **A ship run is now drawn while it runs, in the terminal and the Desktop Code tab.** Every soft continue used to surface at close: for the twenty minutes before that, someone watching saw a spinner. The plugin gains a hooks module, `hooks/mod.mjs`, which Claude Code 2.1.287 and later load inside the engine. It reads each step the CLI prints off the ping's Bash result and shows it: a status line (`interlock: <change> · <action> · wave <w>`), one toast per degradation banner the moment the step raising it crosses, a spinner suffix, and `/interlock-meter`, a pane of waves (routed beside served model), per-agent requests and tokens, the session's plan windows with no threshold, every banner so far, and after the close the summary verbatim, resume card row included. The pane says that its figures are live and the close summary and receipt are the record.
30
+ - **It observes, and that is pinned twice.** No hook returns a deny or a substitute result (the in-process launch guard's one refusal, above, is the single exception the pin allows), there is no catch handler, and the module calls no model, prompt, message, process, network, file write or permission check. `test/spine/mod-pins.test.mjs` reads the module as text and fails on any engine call outside nine allowed ones or any forbidden token, and walks its imports so nothing it reaches loads `node:`. `claude plugin validate . --strict` lists the same hooks and calls. It is inert outside a live ship run and in any non-interactive session (`-p`, the SDK, a runner lane).
31
+ - **The settings hooks are untouched, on every host.** `hooks/hooks.json` is `{ "hooks": {}, "modules": ["./mod.mjs"] }`. The probe came first: on this machine 2.1.161, inside the plugin's v2.1.154+ floor, refused a `modules`-only file (`hooks: Invalid input: expected record, received undefined`), because it reads that path as its settings-hooks file. The dual shape passes `--strict` on 2.1.161, 2.1.274 and 2.1.289. A `--plugin-dir` session on 2.1.161 still ran the manifest's `SessionStart` hook and its `PreToolUse` deny. `test/hooks.test.mjs` pins every inline registration and the empty record.
32
+ - **The engine probe set the meter's shape.** On 2.1.289, `tool.call` fired for the ping's Bash inside a Workflow agent with `agentId` set, and its `text` was the CLI's whole stdout. `turn.step` and `turn.complete` carried the same agent id. `agent.spawn` did not fire for a Workflow `agent()`, so an agent row reads `lane unknown` unless a host raises it. On the Workflow host the relayed step drops the batch fields, so the status line shows the wave alone there. The captured records are pinned under `test/fixtures/mod/`.
33
+ - **`banners` is a field on every step that raised one.** A banner the run pushed onto the manifest (a lane the host stopped, an isolation warning) used to reach only the close summary. `finishStep` now carries every banner raised since the last step onto the next one, in the summary's order and once each. The close's `--json` record already carried `{ action, exitCode, summary, banners }`, and that is now a stated contract of `ship/run-program`.
34
+ - **`interlock doctor` gains a `mods` row**, advice and never a failure: `ok` from `MODS_MIN_HOST_VERSION` (2.1.287, beside the other host floors in `lib/host/claude-env.mjs`), `skip` below it or with the version unknown. It names the binary it probed, because the engine behind a Desktop session is not the `claude` on PATH; the meter logs the engine it loaded in.
35
+ - **The one `.test.ts` in the repository.** `test/mod/meter.test.ts` runs under `claude plugin test .` against the engine's own kit, each UI case on both surfaces. `npm test` collects `*.test.mjs` only, which a pin asserts. CI's `validate-plugin` job runs the kit after the strict validate, and runs it even when that validate is red: from 2.1.289 it fails this checkout over the root `CLAUDE.md` warning, a pre-existing condition with its own fix. `CLAUDE.md` says both conventions.
36
+ - **Brief 9's premise inverted.** It paired the meter with a second measurement route, on the assumption that the transcript probe would fail. That probe passed, and the CLI already records per-agent usage and substitutions, so the meter takes Brief 9's display half only. It writes nothing and joins nothing.
37
+ - **`scripts/host-probe.mjs`, development only and never shipped.** One allow-listed entry point for host probes (`validate`, a headless `--plugin-dir` session, a tmux TTY session), run in a clean login environment so a Desktop session's gateway variables never reach the child.
38
+
39
+ - **A ship run now says what its agents cost, which model ran them, and why a lane failed, on both hosts.** On the Workflow host every `/interlock:ship` printed `CACHE ACCOUNTING NOT REPORTED`, the trajectory recorded the routed model as if it were the one that ran, and a mid-run permission prompt went unnamed. On the runner a lane that hit its turn cap, a `success` without `structured_output` (anthropics/claude-code#82258) and a lane whose tools were denied all read like a lane that did nothing, and its resumable session id was dropped.
40
+
41
+ - **The probe came first, and it moved the design.** On CLI 2.1.274, a headless session with a logging hook captured the `SubagentStart`, `SubagentStop` and `PermissionRequest` payloads and a real subagent transcript, pinned scrubbed under `test/fixtures/hooks/` and `test/fixtures/transcripts/`. The host reports `agent_type: "workflow-subagent"` for every Workflow `agent()`, whatever `type` the script named (a real 2.1.289 ship ping spawned as `interlock:ping` showed the same), so the recorder matches `^workflow-subagent$` and not the plugin agent names the brief assumed. The transcript writes one line per content block and repeats the request's usage on each, so usage is summed once per assistant message. Every `claude -p` envelope listed a host-internal haiku call in `modelUsage` beside the session's own model.
42
+ - **Four recorder branches, which report and never decide.** `hooks/recorder.mjs` joins `SessionEnd` with `SubagentStart` and `SubagentStop` (matcher `^workflow-subagent$`) and `PermissionDenied` and `PermissionRequest`. Behind one gate (a live stage marker and a manifest carrying a run id) the stop sums the agent's transcript and pulls the briefing sha out of its bootstrap. The start records the agent once. Each permission event is one file naming its kind, agent, tool and reason, never the tool input. All of it goes to an outcome-class sidecar under the working root's `.claude/ship/agent-usage/<runId>/` (`lib/agent-usage.mjs`), one small file per key, so a later mod can write the same files. No branch starts a process, reads the trajectory or exits non-zero.
43
+ - **The close joins the sidecar to the spawns it dispatched.** `run close` matches agents to spawns by briefing sha. A wave where every briefed spawn joined gets its cache figures, and a complete join records the observed cache accounting `hook`, a value no adapter may declare. One spawn missing banners `CACHE ACCOUNTING PARTIAL: <n> of <m> agents unrecorded`. Relay pings and other unbriefed agents are counted in `AGENT USAGE UNJOINED` and attributed nowhere. The permission files banner `PERMISSION PROMPTS DURING RUN: <n>` and `AUTO MODE DENIED <n> TOOL CALLS (<tools>)`: counts the reader interprets, never a verdict.
44
+ - **One matcher, one verdict, two observations.** `modelMatches` in `lib/host/model-map.mjs` is the only comparison of a routed model against a served one. It ignores a provider prefix and a date stamp, and a tier alias matches any id carrying that tier. `modelSubstitution` in `lib/run.mjs` reads the strongest evidence present. Turn-scoped models come from an agent's own transcript, and there any unmatched id banners `MODEL SUBSTITUTED: <label> routed <slug>, ran <ids>`. Session-scoped models come from the envelope's breakdown, which includes host-internal calls, so there it banners only a lane the routed model never served. Both hosts print the same words.
45
+ - **The claude adapter reads the whole envelope, whatever the exit code, and the lane's own transcript.** `readClaudeEnvelope` returns a host record beside `result` and `usage`: subtype, error flag, bounded errors, permission denials, session id, turns, `sessionModels` and the client-side cost estimate, kept apart from `MODEL_PRICES`. The adapter reads the lane session's transcript for turn-scoped `servedModels`, which exists because the adapter never passes `--no-session-persistence`. `bin/interlock-run` forwards host records on their own channel, `--host-records`, never inside `results.json`. The CLI decides `LANE STOPPED BY HOST`, `SCHEMA RESULT MISSING (claude)` and `TOOLS DENIED IN LANE` at the record path.
46
+ - **A new trajectory event, `agent-result`,** records what the host observed about each spawn: from the envelope on the runner, from the join at close on the Workflow host. It inherits the trajectory's fatal class, and the schema id is unchanged. The receipt gains six nullable counts (`lanesStoppedByHost`, `schemaResultsMissing`, `toolsDeniedInLanes`, `modelSubstitutions`, `permissionPrompts`, `autoModeDenials`). Each is absent when nothing was observed. The resume card lists every lane with a recorded session and its `claude --resume <id> --fork-session` command, saying the transcript may be gone after the host's retention period or a deleted session.
47
+ - **Bare-proofing.** The adapter passes `--permission-prompts none` only outside `bypassPermissions` and only when the one-time `--help` probe lists the flag, and otherwise banners `PERMISSION PROMPTS NOT SUPPRESSED (claude)`. A pinned test fails on any argv or source line carrying `--no-session-persistence`. `interlock doctor` gains a `claude-bare` row, advice and never a failure, on whether a bare `-p` would have a credential. The agents-file workaround for #82258 was not adopted: its probe could not be run, and adoption requires a passing one.
48
+
49
+ - **Corpora of a linked-worktree run now land where `interlock report` reads them.** In a `claude --worktree`, `--bg` or Desktop worktree session a run used to start without its test profile and graph. It then wrote its fatal trajectory, outcome line, metrics and resume card into a directory that goes away with the worktree, and the report in the main checkout never knew. `lib/state-home.mjs` resolves the main checkout from `git rev-parse --git-common-dir`. Interlock's own lane worktrees resolve to themselves. Any git failure falls back to the root with `STATE HOME UNRESOLVED: <reason>`, and `--state-home <dir>` or `INTERLOCK_STATE_HOME` overrides the resolution.
50
+
51
+ - **Append-only corpora go to the state home; per-run working state stays put.** Trajectories, outcome lines, review metrics, resume cards, interrupted-run notes and the autonomy ledger are written under the home. The manifest, wave state, briefings, spill, stage marker, launch ledger and agent-usage sidecar stay in the working root. `run start` resolves once and records the home on the manifest, and every later subcommand uses that record. Loss semantics are unchanged: a failed trajectory append in the home still exits 1, and an outcome or metrics write failure still only reports. **A main-checkout run resolves to itself, so every path it writes is the path it wrote before.**
52
+ - **Inputs read through, and the drivers stop probing them.** `run start` finds the test profile and the graph in the root first and the state home second. It banners `TEST PROFILE FROM MAIN CHECKOUT` and `GRAPH FROM MAIN CHECKOUT`, and the latter says the graph may be stale for this worktree. The resolved profile path goes on the manifest for every later reader, the test guard included. `interlock-graph` query commands read the main checkout's graph in a worktree that has none and say so on stderr. Nothing is copied into the main checkout.
53
+ - **The run records where it ran.** The manifest, the `run-start` event and the receipt carry `surface` (`main`, `linked-worktree`, `lane-worktree` or `unknown`) and the state home, and `run-start` also carries `cwd`. The close summary prints `state home:` under `cwd:` when they differ. A worktree run notes `CORPORA IN MAIN CHECKOUT`. `interlock report` reads every corpus from the home and partitions its scanned runs by surface. It also counts the trajectories stranded in other linked worktrees, without opening any. `interlock doctor` gains a `state-home` row and probes each state directory where it now lives. A subcommand that finds no manifest names the home's when one exists there.
54
+
55
+ - **A session that ends mid-run now leaves a note, and three readers speak it.** A session that ended while its own ship run was live stopped the background workflow before `run close`. That covers closing the terminal, quitting the app, `/exit`, and possibly the Desktop's "Send now" interrupt. The run got no receipt, no resume card and no terminal trajectory event, in exactly the place a reader most needs one. A new recorder hook, `hooks/recorder.mjs`, registered on `SessionEnd`, writes one small note at `.claude/ship/interrupted/<runId>.json`. It carries the run id, the change, the session, the host's end reason (verbatim, bounded), the stage the marker held and the time. `interlock run start` prints `PREVIOUS RUN INTERRUPTED: <change> run <id> ended at stage <stage> — no resume card was written; interlock run-log show <id>` for every unspoken note, whatever change it names, and marks it spoken. The SessionStart preflight surfaces the same line and marks nothing. `interlock report` splits runs without a terminal event into *interrupted* and *unexplained*, each over the scanned runs.
56
+
57
+ - **It writes only for the session that owns a live run.** The recorder acts when a live stage marker exists and the run manifest's `sessionId`, now stored at plan adoption beside the `run-start` event that already recorded it, equals the ending session. In every other session and repository it creates no file and no directory. It returns no decision, prints nothing on stdout, and exits 0 on every path. It is the one recorder file later reporting branches join. `hooks/_shared.mjs` is unchanged.
58
+ - **The note is outcome-class, and that is stated beside the trajectory's rule.** A failed write is a stderr line from the hook. An unreadable note is `INTERRUPTED NOTE UNREADABLE` on the manifest, and one that cannot be marked spoken is `INTERRUPTED NOTE NOT MARKED` and is spoken again next time. None of these moves an exit code. The trajectory stays fatal-class, and no hook writes it. [The guards page](docs/13-the-guards.md) names the two classes side by side so nobody makes them consistent. `interlock doctor` lists `.claude/ship/interrupted` as a non-fatal state directory.
59
+ - **The runner closes on SIGINT and SIGTERM.** `bin/interlock-run` traps both and routes them through its existing `stop()` once, so a killed runner leaves a `run-halt` naming the signal, a receipt and a resume card like any other halt. A second signal, or a signal during a close already under way, starts no second close. A signal that lands before `run start` has answered is handled at the runner's first await, and the close reports that no manifest exists.
60
+
61
+ - **The Workflow runtime's concurrency is observed and printed, never used to resize a plan.** `interlock run start --host workflow` records `manifest.runtimeSlots`: the `CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS` override when it is an integer from 1 to 256, otherwise the vendor default beside the machine's CPU count, because the runtime may run fewer on a small machine. An override outside that range is recorded with its raw text, noted as `RUNTIME SLOTS OVERRIDE IGNORED`, and read as unset. When an adopted plan's widest batch holds more lanes than an observed override, the run banners `WAVE WIDER THAN RUNTIME SLOTS: the widest batch has <w> lanes and CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS=<n> — <w − n> lanes queue for a slot; the plan is not resized`. The vendor default raises nothing, because the reduction it may apply has no published number and inventing one would be a threshold in prose. `interlock limits` prints the observed value beside the vendor default, and its JSON carries `runtime.observed`.
62
+
63
+ - **`interlock doctor` gains a `claude-env` row.** It shows the installed Claude Code version, or `unknown`. It shows each of `CLAUDE_CODE_SUBAGENT_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL_FORCE`, `CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS` and `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` with its value or as unset. It gives the version-aware reading of the plain model variable and says that FORCE forces one model, that the teams variable does not affect Workflow agents, whether a Bedrock variable is set (by name only), and the CPU count beside the vendor default. It is `ok` only when none of the four is set and the version was read, otherwise `skip`, and never `fail`. It costs the preflight one `claude --version` spawn, bounded by the probe's own timeout.
64
+
65
+ - **A second ship launch with no human message since the last one is now denied by a hook, not only by a sentence.** The most expensive mistake on record was a parent chat calling Workflow again over leftover checkboxes, which cost another 20+ agents including a full review cycle. `skills/ship/SKILL.md` forbade it, `test/skills.test.mjs` pinned the sentence and `evals/trampoline-launch` measured whether the model obeyed. Nothing stopped a model that did not. `hooks/guard-relaunch.mjs` is one script registered on four events: `PreToolUse` and `PostToolUse` with a `Workflow` matcher, `UserPromptSubmit` and `UserPromptExpansion`. Its `PreToolUse` branch denies a ship launch when the session already launched one and no human prompt has arrived since. The deny goes through the existing `deny()`, quotes the skill's sentence verbatim and names the remedy: send a new message asking to ship the leftovers, or type `/interlock:ship` again.
66
+
67
+ - **The payloads were captured before the deny was written.** Two scratch sessions on CLI 2.1.274, with a logging hook on the four events, settled what the design could not assume. The captures are pinned under `test/fixtures/hooks/`. A typed `/interlock:ship` fires both prompt events and reaches the plugin's workflow command as `Workflow({ name: "interlock:ship", args })`. An accepted launch answers `status: 'async_launched'` with `taskId` and `runId`. The completion wake fires `UserPromptSubmit` with a `<task-notification>` prompt, so the guard does not count it as a person. Counted, it would have reset the clock in the very turn the relaunch happens in. The wake's own diagnostics suggest a resume through the persisted script and `resumeFromRunId`, so a call matching a run the session recorded counts as a launch too.
68
+ - **The ledger is per session and only an accepted launch creates it.** `.claude/ship/launch-ledger/<session_id>.json` holds the launches the runtime accepted and the time of the last human prompt, written by `lib/launch-ledger.mjs`, which holds the rule with no reference to the hook protocol. `PostToolUse` creates it only for a response whose status says the workflow launched. A launch the engine refused never reaches the hook at all, which the capture showed. A prompt makes one existence check and writes nothing when the session has no ledger, so a prompt in a repository that never shipped creates no file and no directory.
69
+ - **Its lifetime is a published cap.** `LIMITS.launchLedgerMaxAgeMs`, 24 hours, printed by `interlock limits` as `launch ledger max age (ms)` and read in `lib/`, where the cap-authority sweep finds it. A ledger whose newest launch is older reads as absent, and every launch write sweeps sibling ledgers past it. The bound can only widen the allow direction: ageing out turns a deny into an allow, never the reverse.
70
+ - **Every unknown allows.** No session id or an unsafe one, a ledger that is missing, unreadable, malformed or stale, a tool input it does not recognise as a ship launch, another workflow, a tool that is not Workflow, and the guard's own crash all allow. An unreadable ledger and a crash are named on stderr. The recorder branches print no decision on any path. A false deny costs one typed message, and that is still a denial, so the capture gated the deny branch.
71
+ - **The prose and the eval are unchanged.** The skill sentence and its token pin stay, and the deny quotes that sentence, so a reword of the skill now fails `test/hooks.test.mjs` too. The eval measures whether the model obeys; the hook bounds what happens when it does not. With hooks disabled, enforcement is the prose and the eval again. A prompt typed and queued during the run still re-arms one launch, and that hole is named, not closed. When the host can load plugin mods, an in-process guard replaces these four registrations. See [the guards](docs/13-the-guards.md#the-launch-ledger).
72
+
73
+ - **A cost-per-task sweep records what this repository's fixtures cost by model and effort, and changes no routing default.** `node evals/ship/matrix.mjs` crosses every committed outcome fixture with the API model ids `claude-opus-5-5` and `claude-sonnet-5-5` and the effort levels `low`, `medium`, `high` and `xhigh`. Each cell runs the control-arm procedure — the committed eval agent prompted once per task at that cell's model and effort, then the fixture's unit command and graders — in a scratch root outside the repository, and is priced by the outcome eval's own `priceUsage`. Each cell that ran appends one row to `evals/history/cost-per-task.jsonl` under schema `interlock.cost-per-task/1`: fixture, model, effort, judge pass or fail, token and cache-tier counts, dollars with the price-table id or the reason there are none, and wall clock. No transcript, prompt or diff is kept.
74
+
75
+ - **It is not the outcome eval.** It is a separate command writing a separate file. It never appends to `ship-outcomes.jsonl`, its rows are not promotion trials, no pull-request workflow starts it, and it runs no wave loop, planner or verify step. A red grader is a field on a row and exits zero. A failed append, or a scratch root inside the repository, fails the sweep.
76
+ - **A model the price table does not contain is not run.** The cell is recorded as not run with a reason naming the table id, and no request is made. The comparison is exact, so `claude-opus-5-5 ` with a trailing space is not a grid model.
77
+ - **It is bounded by a new published ceiling, `EVAL_CAPS.matrixCostUsd`.** `interlock limits` prints it, and the sweep reads it from `interlock limits --json`. It is checked between cells only, so a started cell is never killed. An unpriced row adds nothing to the total. A sweep stopped there is partial, not failed. It gates nothing.
78
+ - **The eval model client takes an optional effort.** When a cell passes one, every Messages request carries `output_config.effort`. The outcome eval passes none, so its requests have no `output_config`, and an `INTERLOCK_EVAL_EFFORT` exported in the operator's shell is stripped before it reaches an outcome-eval agent. The outcome eval's default model is unchanged.
79
+ - **No routing default moved by the sweep.** It edits neither `EFFORT` nor `LANE_CAPS.opusMinTier`. The opus floor is a separate change in this release, under Changed. The rows are evidence that a later limits edit may cite, and this change does not make that edit. See [evals](docs/14-evals.md#cost-per-task--model--effort).
80
+
12
81
  - **A halted ship run now leaves one file behind for the reader who was not watching.** Everything a halt records is machine-shaped and scattered: the trajectory under `.claude/ship/runs/`, the plan and its fingerprint under `.claude/ship/`, the unticked boxes in `openspec/changes/<change>/tasks.md`, and the reason itself in a summary printed to a terminal nobody was reading. Someone coming back in a new session, with none of that conversation in context, had to reassemble it from four places before they could do anything at all. `interlock run close` now does that reassembly once, at the halt, in prose: `lib/resume-card.mjs` writes `.claude/handoff/ship-<change>-<runId>.md`, and the terminal summary gains a `resume card: <path>` row — printed only when a card was actually written, so the row is never a pointer at a file that is not there. A run that halted before a plan was adopted has no run id and gets `ship-<change>-no-run-id.md`; the card states that absence in words rather than leaving the field blank, because "no trajectory was opened" is the fact a reader most needs out of that particular halt.
13
82
 
14
83
  The card carries the halt reason; where the run stopped (change, run id, project slug, cwd, trajectory path, and the `run-log show` / `run-log query --halted` commands that print the walk which led there); the leftover task ids beside an `interlock tasks tick <change> --ids <…>` whose id list is a placeholder you fill in, never the whole list — a halt leaves both done-but-unmarked work and work never attempted, and `tasks tick` flips a marker without verifying anything, so a paste-ready command over every leftover would tick unimplemented work and delete the only record of what is left; this run's own `PLAN REUSED` / `PLAN REBUILT` verdict and whether a plan and a fingerprint are stored for this change; the wave tallies; the degradation banners raised before the halt; and how to pick it up. It is written last in the close, so the banners it lists include the ones that close itself raised.
@@ -36,7 +105,46 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
36
105
 
37
106
  ### Changed
38
107
 
39
- - **A lane's model follows whether it can run in parallel.** A lane of two or more tasks — a path collision, a cohesion pack, or a chain — dispatches on opus. A lane of one task keeps that task's clamped model: haiku or sonnet below tier 5, opus only on a tier-5 task. The task's recorded model is not rewritten to match the dispatch, and effort stays derived from the hardest task's tier, so an opus lane of tier-1 or tier-2 work still runs at low effort. A host that maps slugs (`INTERLOCK_MODEL_MAP`) has to map `opus` as well as `sonnet`, because a multi-task lane now asks for it.
108
+ - **The launch rule moved into one Node-free module that both forms of the guard import.** `lib/launch-rule.mjs` holds what a ship launch is, what a human prompt is, the decision and its words, and three record transforms, `emptyRecord`, `withLaunch` and `withPrompt`. It imports only `./limits.mjs`. `lib/launch-ledger.mjs` keeps the file and re-exports every rule name unchanged, so `hooks/guard-relaunch.mjs` and `test/hooks.test.mjs` import what they imported before. `recordLaunch` and `recordPrompt` now apply `withLaunch` and `withPrompt`, so the file and the session record change by the same functions. One visible effect: a launch written to a ledger now drops earlier launches past the published age, which never changed a verdict. `test/spine/launch-ledger.test.mjs` pins that each re-exported name is the rule module's own object, that the rule names no `node:` import, and that `decideLaunch` over a plain record and over the same facts read from a file returns the same verdict and reason.
109
+
110
+ - **The hooks module's observe-only pin now allows exactly one refusal instead of none.** `test/spine/mod-pins.test.mjs` no longer forbids the token `deny` outright. It allows one line, `if (launch && verdict.decision === 'deny') return { deny: verdict.reason }`, with `verdict` assigned once from `decideLaunch` imported from `lib/launch-rule.mjs`, and fails naming any other. The allow-list grows by exactly `$.clock.now`, `$.state.get` and `$.state.set`. The module's session-state keys must equal the contract's, and its hooked events are pinned, `prompt.submit` and `session.receive` included.
111
+
112
+ - **The manifest names a type contract, and the package ships it.** `.claude-plugin/plugin.json` gains `"types": "./types/index.d.ts"`, and `package.json`'s `files` gains `types`. `test/spine/package.test.mjs` fails naming a contract the manifest names that `files` would leave out, and asserts nothing for a manifest without one. `claude plugin validate . --strict` on 2.1.289 now prints `state reads: interlock.ledger` and `state writes: interlock.ledger`. It still fails this checkout on the pre-existing root `CLAUDE.md` warning, and on nothing this change adds.
113
+
114
+ - **The Workflow driver no longer probes for the graph or the test profile; `run start` does.** The validate ping lost its two `test -f` lines and the `hasGraph`, `graphReason` and `hasTestProfile` fields, and `GRAPH UNAVAILABLE` and `NO TEST PROFILE` are raised by the CLI, root first and then the state home, on both hosts. Two probes of one file in two places is how a driver and the CLI would have disagreed about whether it exists. The implementer briefing's graph line no longer gates on a file in the working root: it reads `Run interlock-graph query / consumers before grep; if it reports no graph, grep.`, because the graph CLI now answers from the state home's graph when the lane's worktree has none.
115
+
116
+ - **The model-override policy moved out of the Workflow driver and into `run start`, and it now reads the host's version.** `workflows/ship.js` used to ask its validate ping to `printenv CLAUDE_CODE_SUBAGENT_MODEL` and the Bedrock variables. It raised `MODEL ROUTING OVERRIDDEN` whenever the model variable was set and withheld the haiku pin from every control-plane ping. Since Claude Code 2.1.251 that variable sets only the *default* subagent model, and a model a spawn names wins. On a current host the banner was false, and the pings ran on the override model, often opus, for the whole run. `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1`, which does replace every agent's model (2.1.257), was probed by nothing. `interlock run start --host workflow` now reads all of these from its own environment, the way it already reads the session id, and asks `claude --version` beside the existing `--help` effort probe. It then decides the banners and the ping model once, and puts `pingModel` on the step. The driver sets `pingExtra.model` from that field alone, and the relay whitelist carries it.
117
+
118
+ - **On 2.1.251 or later**, a plain `CLAUDE_CODE_SUBAGENT_MODEL` raises no banner, the pings keep haiku, and a `MODEL ROUTING NOTE` says the variable sets only the default on this host. **On an older host, or when the version cannot be read**, the old banner and the old withholding stand, with ` (host version unknown)` appended when the version was unreadable.
119
+ - **FORCE is bannered by name** (`MODEL ROUTING OVERRIDDEN: CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1 — every agent runs on one model`) on 2.1.257 or later, or when the version cannot be read. The Workflow host's observed `modelSelect` is refined to a new registry value, `forced`. This is the same downward refinement the effort probe already performs. No adapter may declare `forced`, and a run that declared its own model selection keeps it. On an older host FORCE is inert, and a note says so.
120
+ - **A Bedrock variable now says why the pings inherit the session model.** The driver used to withhold the model silently. The run notes `PING MODEL INHERITED` instead.
121
+ - **Both version floors are named constants** in `lib/host/claude-env.mjs`, the module that reads them. If the probe recorded in the change's design shows a spawn-time model does not win on a current host, the default-only floor becomes `null`, a one-constant revert.
122
+ - **The `CLAUDE_CODE_EFFORT_LEVEL` probe stays where it was.** The validate ping still runs `printenv CLAUDE_CODE_EFFORT_LEVEL`, and `EFFORT ROUTING OVERRIDDEN` is unchanged. The doctor's `printenv` allowlist reason now names that variable only.
123
+
124
+ - **A null agent result is no longer reported as an unacknowledged briefing.** On the Workflow host, `agent()` returns nothing when the runtime stops an agent, when the API fails unrecoverably, or when a usage limit ends it outside an interactive subscription session. `spawnOne` reported all three as `BRIEFING NOT ACKNOWLEDGED: <label> reported (nothing)`, which sent the reader to the briefing when the host was the cause. It now banners `AGENT RETURNED NO RESULT: <label> — the runtime stopped it, the API failed, or a usage limit ended it; the step is recorded as failed`. `BRIEFING NOT ACKNOWLEDGED` is kept for a present result whose hash is missing or wrong. Either way the lane is recorded as failed. The runner's `spawnOne` is unchanged, because its adapters already name a failed spawn.
125
+
126
+ - **The outcome eval prices the Claude 5.5 list, cache terms included, under a new price-table id.** `MODEL_PRICES` is now `anthropic-list-2026-10`: `claude-opus-5-5` at $4 / $20 per million input / output tokens, `claude-sonnet-5-5` at $2 / $10 and `claude-haiku-4-5` at $1 / $5, as published on 2026-10-02. `claude-opus-5` and `claude-sonnet-5` are not in it, so a usage that still names them is unpriced under the new id with a reason naming that id, never priced at a rate guessed from the name. Rows already recorded under `anthropic-list-2026-09b` (or `anthropic-list-2026-09`) were **not rewritten** and keep the id that priced them; no number under either old id was edited.
127
+
128
+ - **The cache-read multiplier is per model.** `cacheMultipliers.read` is keyed by the same model ids as the base rates — 0.05x for Opus 5.5, 0.1x for Sonnet 5.5 and Haiku 4.5 — and a listed model with no read multiplier is unpriced rather than priced at another model's rate. The write multipliers stay shared and per lifetime tier, `ephemeral_5m` and `ephemeral_1h`, never summed. `interlock limits` prints one read-multiplier row per model, and `--json` carries the per-model map.
129
+ - **`priceUsage` now reads them.** The outcome eval's dollar figure is uncached input, output, cache reads at the model's own read multiplier and each cache-write tier at its own write multiplier. An absent input, output, cache-read count or cache-write record withholds the whole figure with a reason naming the field; a measured zero still prices. The model lookup is the exact string, so `Claude-Sonnet-5-5` or ` claude-opus-5-5` is unpriced. This is a behaviour change: a usage that carries input and output but no cache fields used to price on those two alone and is now unpriced.
130
+ - **The eval agent records cache writes by lifetime tier.** `addUsage` turns a response's `usage` block into `{ ephemeral_5m, ephemeral_1h }` — copying the `cache_creation` split when the API sends it, and attributing a bare `cache_creation_input_tokens` to `ephemeral_5m` because the agent's breakpoint is the five-minute tier — and the usage record (`interlock.ship-eval-agent-usage/3`) stores that object. A usage line that still carries the old single write total makes the summed write record absent, not a zero write.
131
+ - **The outcome eval's default model is `claude-opus-5-5`**, a key in the new table. `INTERLOCK_EVAL_MODEL` still overrides it, is recorded exactly as given, and is priced only when it is an exact key.
132
+
133
+ The figure is still an outcome-eval estimate checked against `evals.shipEvalCostUsd`, never a bill and never a gate. The ship loop does not read the table, a missing price changes no exit code, and neither the ship receipt nor `interlock report` shows dollars.
134
+
135
+ - **Verify agents now run at the published verify effort, and that moves spend.** Both verify checks — the inter-wave check, with each of its retries, and the final check — used to be spawned with no effort, so they ran at whatever the host defaulted to while `interlock limits` printed a verify effort that nothing read. They now carry it. No value in the effort table changed; the `interlock limits` row is relabelled `effort: verify step (inter-wave and final)` because it now governs both checks, and its `--json` field is unchanged. The replan ping, the planner, the handoff and the commit spawn still carry no effort, on purpose, and a test fails on any new spawn kind that is placed in neither set.
136
+
137
+ - **The runner forwards the effort, too.** `interlock-run` used to drop a spawn's effort before it reached the adapter, so `--host claude` lanes ran at the CLI's default. It now forwards it exactly as the Workflow driver does: when the step names one, and as no key at all when it does not. Neither driver derives or names an effort level; the run program is the one place an effort is decided.
138
+ - **Effort is a declared host capability, recorded in the receipt.** Each adapter declares `effort` as `flag` (`claude`, with `--effort`), `negotiated` (`acp`, with `session/set_config_option` when the agent advertises an effort option whose flat values include the level exactly) or `unsupported` (`codex`, whose channel is not routed, and `qwen`, which has none). The Workflow host records the `claude --help` probe taken at `run start` (`flag` when the flag is listed, `unsupported` when it is not). The receipt's host block records the declaration; a receipt written before this reads as not recorded.
139
+ - **Three banners say what landed.** The runner prints `effort routing: applied on N/N spawns` when every named effort was applied, or `EFFORT ROUTING UNAVAILABLE (<host>)` with one line per spawn that missed, naming the level it requested and a reason true of that host. Both drivers print `EFFORT ROUTING OVERRIDDEN` when `CLAUDE_CODE_EFFORT_LEVEL` is set — the runner on `--host claude` and on `--host acp` when the command is the Claude binary or `claude-agent-acp` / `claude-code-acp` — and neither strips the variable. A missed effort never fails a spawn. A spawn is counted only once its process has started; one that never starts is reported as a failed spawn, and is in neither count. See [when it stops](docs/04-when-it-stops.md#effort-routing-unavailable-host).
140
+ - **`--host claude` reads the CLI's help once.** When the host is created, the adapter checks `--help` for the `--effort` flag. A CLI without it, or one whose help cannot be read, is declared `unsupported` for the run and is passed no flag, because a CLI that does not know the flag rejects the whole invocation. Only an option line that declares `--effort` counts. Help text that merely mentions it does not.
141
+ - **The planner/runtime mirror requirement is removed.** It guarded a second copy of the effort derivation in `workflows/ship.js` that no longer exists. A forward-only requirement replaces it: each driver passes the step's effort through and derives none.
142
+
143
+ - **Multi-task lanes below the hard band run on sonnet.** With Claude Sonnet 5.5 nearly matching Opus 5.5 on agentic coding benchmarks while staying half the list price, a lane of two or more tasks no longer defaults to opus. `laneModel` now dispatches on opus only when the lane's hardest tier is at or above `LANE_CAPS.opusMinTier` (4), or when every task already carries opus (solo promotion / pure tier-5). Cohesion packs and low-tier chains — the bulk of multi-task implementer work — run on sonnet; cross-file and architectural multi-task lanes, solo, and single-task tier-5 keep opus. Effort stays derived from tier alone. The floor is published by `interlock limits` beside the cohesion ceiling; the classifier prompt is unchanged (it still must not be told the dispatch rule).
144
+
145
+ - **Stored plans are rebuilt, not reused under the new rule.** The plan format is bumped to `interlock.ship-plan/5`, and the floor is part of the plan fingerprint. A stored `/4` plan has a lane report naming opus for every multi-task lane. It now reads `PLAN REBUILT (plan-format-changed)` instead of `PLAN REUSED`, so the classifier runs once more on the first ship of an in-flight change. A later change to the floor re-plans too, and `PLAN REBUILT (inputs-changed)` now names the cap that moved rather than reporting an artifact edit nobody made. A reused plan's lane report is re-derived from the narrowed lanes, so it names the model each lane will dispatch on.
146
+
147
+ - **A lane's model follows whether it can run in parallel.** A lane of two or more tasks — a path collision, a cohesion pack, or a chain — used to dispatch on opus unconditionally. That absolute rule is superseded by the opus floor above. A lane of one task still keeps that task's clamped model: haiku or sonnet below tier 5, opus only on a tier-5 task. The task's recorded model is not rewritten to match the dispatch, and effort stays derived from the hardest task's tier. A host that maps slugs (`INTERLOCK_MODEL_MAP`) has to map `opus` as well as `sonnet`, because hard multi-task lanes and solo still ask for it.
40
148
 
41
149
  **Chain lanes.** After cohesion and the singleton fold, consecutive batches of one wave that each hold a single lane are fused into one agent, in that batch order, under the same `LANE_CAPS.byTier` entry as the chain's hardest tier. A batch of two or more lanes is left parallel, and a chain never crosses a wave — including the red/next boundary and the implementation/test boundary. Solo plans are not fused. The "effectively serial" warning still counts lanes before fusion; the projected agent bill counts the lanes that dispatch after it. The plan format is bumped to `interlock.ship-plan/4`: a stored `/3` plan is rebuilt, because reusing one would dispatch the old shape.
42
150
 
@@ -44,6 +152,32 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
44
152
 
45
153
  ### Fixed
46
154
 
155
+ - **`claude plugin validate . --strict` passes on this repository again.** From Claude Code 2.1.289 the validator rejects a `CLAUDE.md` at the plugin root, which is also this repository's root, because a plugin cannot ship context that way. CI's `validate-plugin` job was red on every push. The instructions moved to `.claude/CLAUDE.md`, which Claude Code loads as project instructions the same way, and a test now fails if a root `CLAUDE.md` comes back. `/interlock:fix-tests` mirrors its `## Testing` block into the first instruction file that exists (`CLAUDE.md`, then `.claude/CLAUDE.md`, then `AGENTS.md`), so it no longer recreates a root file in a plugin repository. The file was never in the npm package.
156
+
157
+ - **`/clear`, `/resume` and `/branch` now end the run the ship meter holds.** `session.start` does not fire at those boundaries, and the meter reset its run only there and on an accepted launch. So after a `/clear` mid-run the status line kept naming a run the conversation no longer held, while the launch guard's record in session state, which the engine does empty at that boundary, already allowed the next launch. The module now hooks `classic.SessionStart`: a `clear`, `resume` or `fork` source cancels the meter's interval, starts a fresh run record, clears the status line and redraws the pane to `no ship run is live in this session`. A compaction (`compact`) keeps the session and its run, and is passed through untouched, as are `startup` and a source the module cannot read. The pane is not closed, since the module has no call to close it and its no-run text is true. The first step from the earlier run that crosses afterwards is named once on the debug log. The built-in `diff` mod resets on the same boundary; the meter resets on the event that also covers `/branch`, and not on the typed command, so leaving the `/resume` picker resets nothing. The repro, a kit case per source, failed against the previous module.
158
+
159
+ - **A runner lane reaches the CLI that wrote its briefing.** Briefings call `interlock` by name, and the runner was handing the lane the session's PATH. Claude Code appends plugin `bin/` directories, so an older copy already on that PATH could answer the name and reject a subcommand the briefing was written against. A lane's PATH now starts with the directory of the `interlock` the runner itself runs, and a later copy of that directory is dropped. Nothing else in the environment changes.
160
+
161
+ - **`--isolate-waves` no longer reverts an earlier batch's edit and reports success.** Every isolated batch forked its lane worktrees from `git rev-parse HEAD`, and HEAD never moves during a run, because the ship commit is the run's last step. So a batch deferred for sharing a file with an earlier one edited a stale copy of that file, and its whole-file fold overwrote the earlier batch's edit with exit 0 and no collision. A wave-2 lane could not see a file a wave-1 lane had created. The run reported success and the code was wrong. Reproduced against the real `merge-lanes` binary: batch 2, forked from the unchanged pre-run HEAD, dropped batch 1's appended line.
162
+
163
+ - **The merge base is a snapshot of the shared tree, not HEAD.** Before an isolated batch is dispatched, the CLI builds a commit of the shared tree as it stands, through a temporary index and git plumbing only. It holds every tracked edit and deletion and every untracked file `.gitignore` does not exclude, so it holds every earlier fold. It leaves out Interlock's own run state under `.claude/ship/` and any linked worktree inside the root, which would otherwise be staged as an embedded repository that a lane's fold reads as a deletion. No ref moves, the index and the working tree are untouched, the capture neither needs nor writes a git identity, and the commit is left to git's own expiry once the batch folds. Untracked, non-ignored files now reach lane trees, which is the fix for wave 2. An ignored generated file stays absent, as before.
164
+ - **A clean tree forks from HEAD itself.** When the snapshot's tree equals HEAD's, HEAD is the base and no object is written. That is the first isolated batch of a run started on a clean tree.
165
+ - **A lane forked from the wrong base halts, on every host.** `run record-batch` now reads each lane worktree's HEAD from git before anything folds, never from the lane's own report, and halts with `LANE BASE MISMATCH: <lane> forked from <sha>, expected <snapshot>` when it is not the stored base. No lane of that batch is folded, and every surviving worktree is named. A lane that reported no writes and whose worktree is gone is an empty-write lane, not a mismatch.
166
+ - **The Workflow host's limit is spoken.** Its runtime forks lane worktrees from `worktree.baseRef`, which a plugin can neither read nor set. `run start` with `--isolate-waves` there raises `ISOLATION BASE NOT CONTROLLED (workflow)`, and a mismatch on that host adds that `worktree.baseRef` chose the base and that `--isolate-waves` there needs the follow-up change that creates lane worktrees from the CLI. Until then an isolated run on that host halts after its first fold rather than folding wrongly.
167
+ - **A batch without `--isolate-waves` is byte-for-byte unchanged.** It still carries `mergeBase: null` and captures nothing.
168
+
169
+ See [when it stops](docs/04-when-it-stops.md#lane-base-mismatch).
170
+
171
+ - **A Claude ACP adapter is recognised as Claude, and the Workflow host records the effort it probed.** `claude-agent-acp` and `claude-code-acp` now raise `SUBSCRIPTION PATH` and `EFFORT ROUTING OVERRIDDEN` the way `claude` and `claude-code` already did. They still do not get `--agent`, and they still print `HOOKS NOT IN FORCE`, because those hooks load only in the Claude binary. A command named `npx` is still not recognised. `/interlock:ship` reads `claude --help` once at `run start` and records that verdict: `flag` when the flag is listed, `unsupported` with `EFFORT ROUTING UNAVAILABLE (workflow)` when it is not or the probe cannot be read. The script still passes each step's effort to `agent()`. A caller that already declared the capability keeps it.
172
+
173
+ - **The Workflow host's relay no longer retypes a whole program.** On `/interlock:ship`, every `interlock run` call is made by a relay, a small model that copies the CLI's stdout back into the script. A step carried each implementer's briefing inline plus the wave's carried state (`remainingBatches`, `previousHandoffs`, `changed`). The script reads none of it: briefings reach workers by path and hash, and the next `run` call reads the wave state from `state.json`. In a real run, a 23 KB step was retyped with a duplicated key run and two dropped brackets, did not parse, and halted a healthy run at wave 6. `interlock run` now prints a slim step for the Workflow host (`relayStep` in `lib/run.mjs`). It is a whitelist of the fields the interpreter reads, with no spawn `prompt`, and a typical batch step drops to a few KB. Runner hosts (`bin/interlock-run`) still get the full step, because they send the briefing inline. A test scans `workflows/ship.js` for the fields it reads, so the whitelist cannot starve it later.
174
+
175
+ - **A relay whose copy does not parse halts with the reason, not just the fact.** The halt used to read only `the CLI relay could not be read`. It now names the relay, the subcommand, how many characters were copied and the parser's own message (usually with the failing position), raises a `RELAY UNREADABLE` banner, and points at `.claude/ship/last-step.json`. Every `run` call except the close now writes the exact bytes it printed to that file, so a reader can compare the CLI's output with the relay's copy without digging through a transcript. The halt does not auto-retry: a stale copy could replay a batch past the sequence guard and record it twice.
176
+
177
+ - **The trajectory logs the implementer lanes each batch actually dispatches.** On the `run` path, lane spawns were logged a whole wave at a time at its first batch. A run that halted before a later batch therefore had spawn events for agents that never ran. A wave entered through `run judge` (inter-wave), `run replan` or `run next` logged no lane spawns at all. Each of those sites now logs exactly the lanes of the batch it emits, at every batch index, and a failed append halts before dispatch just as it already did at `run start` and `record-batch`. The `wave-state` subcommands keep their own protocol unchanged.
178
+
179
+ - **The inter-wave verify budget is charged the checks' own time.** The budget was clocked from a verify step's dispatch to its judgement, which also counts two relays and the verify agent's own turns. On a Workflow run, the first checkpoint spent the whole budget and every later checkpoint ran typecheck only. The new `interlock verify exec --kind <k> --command <cmd>` runs one planned check with its output and exit code passed through unchanged, and records its duration to `.claude/ship/verify-timings.jsonl`. The verify briefing now runs every planned step through it, and `run judge` charges the recorded durations. A checkpoint with no timing for a planned step falls back to the round trip and says so with `VERIFY BUDGET CLOCKED BY ROUND TRIP`. No threshold changed, and the per-run checkpoint cap and its placement are untouched.
180
+
47
181
  - **A failed write to the run trajectory now halts the run it was recording, instead of warning and shipping anyway.** `.claude/ship/runs` has been `fatal: true` in `interlock doctor` since it existed, and `wave-state` and `verify judge` have always exited non-zero when their append did not land. The live `interlock run` path was the one writer that did not: it called `ctx.warn` and walked on — into the task tick, and into the commit. The outcome was precisely the artifact the corpus exists to prevent: a run that ticked its boxes, committed, and cannot be replayed. It now halts at every site that owes the trajectory a line — `run-start`, `wave-state create` / `record-batch` / `record-verify` / `replan`, `agent-spawn` (implementer lanes included, which go through the same writer `wave-state next` uses) and `cli-exit` — **before** any tick and before any commit, with the reason `trajectory append failed: <site>: <cause>`. The cause is the filesystem's own, threaded out of the writer rather than left on a stderr the Workflow host does not relay; a full disk now says so by name. See [when it stops](docs/04-when-it-stops.md#the-loud-halts).
48
182
 
49
183
  What this costs you: a run that previously finished green on a machine that could not write its own record now stops. That green finish was the bug. Nothing is ticked and nothing is committed, so the re-run starts from a truthful `tasks.md`. The close still runs, so the receipt, the outcome record and the halt resume card all still land wherever the filesystem allows.