@renzrollon/interlock 1.2.1 → 1.3.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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +55 -5
- package/bin/interlock +142 -14
- package/hooks/mod.mjs +835 -51
- package/hooks/preflight.mjs +168 -34
- package/lib/draw-plan.mjs +496 -0
- package/lib/draw-run.mjs +466 -0
- package/lib/lane.mjs +152 -0
- package/lib/limits.mjs +37 -2
- package/lib/meter-refusals.mjs +73 -0
- package/lib/plan-summary.mjs +119 -0
- package/lib/preflight-file.mjs +266 -0
- package/lib/resume-card.mjs +126 -8
- package/lib/run.mjs +43 -9
- package/lib/spec-meter.mjs +591 -0
- package/lib/waves.mjs +6 -107
- package/package.json +1 -1
- package/skills/review-artifacts/SKILL.md +3 -3
- package/skills/spec/SKILL.md +5 -5
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "interlock",
|
|
3
3
|
"displayName": "Interlock",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.3.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",
|
package/CHANGELOG.md
CHANGED
|
@@ -5,11 +5,61 @@ All notable changes to this project are documented here.
|
|
|
5
5
|
The format follows Keep a Changelog 1.1.0, and
|
|
6
6
|
this project adheres to Semantic Versioning 2.0.0.
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **The CLI draws a plan as a wave board and as Mermaid, and a run as a handoff graph.** A run already recorded every node and edge a diagram needs, and nothing drew them. `interlock waves --plan <file> --format board` draws a stored plan as one bordered block per wave and one row per lane, and `--format mermaid` draws it as a `flowchart LR`. `interlock run-log show <runId> --format board|mermaid` draws a run from its trajectory, in sequence order: each CLI ping with its exit code, each spawned agent, the packets handed from one wave to the next with their audit verdicts, the halt and the receipt. Two pure, Node-free modules do the drawing, `lib/draw-plan.mjs` and `lib/draw-run.mjs`. They read no file, no terminal and no clock, so the ship meter's pane can import the board next.
|
|
13
|
+
- **The flags.** `waves` gains `--plan <file>` (a stored plan instead of `--classified`), `--format board|mermaid`, `--state <file>` (an overlay) and `--columns <n>`. `run-log show` gains `--format` and `--columns`. `--format` refuses `--json`, as `report --html` does. `--state` or `--columns` without `--format` is a usage error, and so is `--plan` beside `--classified`.
|
|
14
|
+
- **Waves are keyed by position, never by group.** The real halted run's plan holds two waves with `group: 1`, and they draw as two blocks, `idx 0` and `idx 1`. The overlay prints only what the state recorded: `ok`, `failed`, `current`, `pending`, `not reached` or `not recorded`. It names what it cannot place. A skip recorded by group over two waves of that group is left unplaced and says which boundaries the state does not tell apart. A failure id no lane carries is listed under the board. A state with a different wave count is applied by task id only.
|
|
15
|
+
- **The handoff graph states its two caveats.** Implementer spawn times are when the step was emitted, not when the host started the agent. A spawn with no recorded result reads `result: not recorded`, never finished. The state and manifest are joined only when they name the run, and the header names a source that is `absent`, `unreadable` or `another run <id>`.
|
|
16
|
+
- **Two widths, published, not restated.** `interlock limits` prints `wave board min width (columns)` and `wave board default width (columns)`. Both renderers and `bin/interlock` read them from `lib/limits.mjs`, and `test/spine/limits.test.mjs` fails if `docs/07` restates either. Below the minimum the board is one spoken line.
|
|
17
|
+
- **The lane rule moved to `lib/lane.mjs`.** `laneLabel`, `laneTitle`, `laneTier`, `laneModel` and `laneEffort` live in a Node-free module, and `lib/waves.mjs` re-exports them, so every importer is unchanged. The board names a lane exactly as its spawn title, its briefing file and its trajectory line do. The import walker that pins the hooks module moved to `test/helpers/module-walk.mjs`, and the same walker now pins the renderers.
|
|
18
|
+
- **One Mermaid block in `docs/10`, generated and pinned.** `### The plan, drawn` holds the fixture plan's flowchart. `test/spine/draw-plan.test.mjs` re-renders the fixture and fails naming the first line that differs. The fixture is a byte copy of the real halted run `6e9d0b02…` under `test/fixtures/ship/halted-run-6e9d0b02/`.
|
|
19
|
+
- **Nothing else moves.** Without `--format` or `--plan`, both commands print exactly what they printed before. A missing plan, state or trajectory is spoken and exits 0. No exit code changed and no dependency was added.
|
|
20
|
+
|
|
21
|
+
- **The ship meter says when the host ends a run agent's turn without an answer.** One toast per agent per run: `<lane> · agent <id> · turn ended: <reason>`. The reason is the host's own word, repeated verbatim, and the meter adds no cause. `<lane>` is the lane's title where the agent is joined to its wave row, and `lane unknown` where it is not, which on the Workflow host is every lane today. Before this, a stopped lane showed only on the pane's agent row, and the driver's `AGENT RETURNED NO RESULT` banner reached the meter only with the close record. That banner is still the record. A probe on 2.1.291 confirmed the event arrives for a Workflow agent the session stops (`aborted`). `error` and `refusal` are pinned under the test kit only.
|
|
22
|
+
- **The meter's pane shows the session's context fill and cost, as the engine reports them.** Two lines come from the `$.session.usage()` read the pane already made: `context <tokens> of <window> tokens · <n>% used`, and `session cost $<usd> · the engine's total for this session, not the run's`. A figure the host did not answer is said to be absent. A read that fails names its reason on the context, cost and plan-window lines, where it used to guess "off a subscription". The module makes no dollar figure of its own: the reservation in the archived prompt-cache change stands, and `test/spine/mod-pins.test.mjs` now pins that the module never names `MODEL_PRICES` and never asks for a usage breakdown. No pin moved: no new engine call, hooked event, state key or `interlock limits` entry.
|
|
23
|
+
- **The ship meter repeats a guard's denial to the person, in the guard's words.** Before this, a settings guard's denial reached only the blocked agent, as tool-error text, so a guard refusing a subagent was a run degrading with the operator unaware. The engine ends a refused call with an errored result whose text is the guard's reason, and the module's tool hooks on `Bash`, `Edit`, `Write` and `Workflow` read that result. `Edit` and `Write` are new hooks that mirror the manifest's `Edit|Write` matcher. When the text names `guard-tests`, `guard-tasks`, `guard-commit` or `guard-relaunch`, one toast repeats it from the name on, once per distinct text per run, and the pane's new `refusals` section counts `guard denials: <n> (<guard> <n>, …)`. A denial that names no Interlock guard, a command that failed and a clean result change nothing, and every result is returned as it came. The names and the pane lines live in a new pure, Node-free `lib/meter-refusals.mjs`, and `test/spine/mod-pins.test.mjs` pins the names against the four guards' own `GUARD` constants.
|
|
24
|
+
- **The launch guard speaks too.** Its in-process refusal is toasted in the rule's words in any interactive session, the same words the model reads, and so is the settings form's refusal read off the Workflow result. The pane carries `launch guard: next launch allowed`, `next launch refused: <reason>` or `facts unreadable; the next launch is allowed`. That line is the rule's ruling over the session's record, read through the guard's own state read, and it is drawn when no run is live too. The one refusal the module returns stays on its one pinned line. `ALLOWED_CALLS`, `HOOKED_EVENTS`, the forbidden tokens and `interlock limits` did not move, and `claude plugin validate --strict` gains `tool.call{tool=Edit}` and `tool.call{tool=Write}` and no call.
|
|
25
|
+
- **What the probes found, on 2.1.291.** A settings hook's `PreToolUse` refusal reaches a module's `tool.call` result as `isError: true` with the text `PreToolUse:<tool> hook error: <reason>`, and no classic event fires for it. That capture is pinned as `test/fixtures/mod/tool-call-denied-by-guard.json`. Permission prompts and auto-mode denials were meant to be spoken too, from `classic.PermissionRequest` and `classic.PermissionDenied`. They are not, because no classic event of any kind reached a hooks module. That held for a prompt raised in the terminal for a workflow agent's write, for a `-p` permission request, and for a `classic.*` hook across a typed prompt and `/clear`. The recorder's files and the close's `PERMISSION PROMPTS DURING RUN` line remain the only count.
|
|
26
|
+
|
|
27
|
+
- **The session start's preflight reaches the person, on a band above the prompt.** The preflight's verdict, a spoken interrupted run and a waiting halt resume card used to go only into the model's context. On a host that loads the ship meter, the `AbovePrompt` band now shows them when there is something to say: the preflight's first line, each `fail` or `warn` check as `<status> <id>: <detail>`, each interrupted run's `PREVIOUS RUN INTERRUPTED` line, each card's path with its change, run and written time, and `preflight written <time> · /interlock-preflight`. A **Hide** control collapses it until the next session start's report. An all-ok preflight draws nothing.
|
|
28
|
+
- **Two commands.** `/interlock-preflight` opens the whole report: every check with its `fix:` lines, the notes with whether each was marked, the cards, and anything the hook could not read. `/interlock-handoff` renders each listed halt resume card as markdown, newest first, cut to `handoff pane chars` (a new `interlock limits` row, the `Markdown` element's own bound) with a line naming what was left out.
|
|
29
|
+
- **Read at your prompt, not at startup.** On 2.1.291 nothing reaches a hooks module between the preflight's write and the first prompt, and `/clear` raises nothing a module sees. So the module reads the report at each prompt a person submits and knows a new one by its written time. A compaction's report keeps a hidden band hidden.
|
|
30
|
+
- **One engine call more, and nothing else.** `$.fs.read` joins the module's allow-list, for the report and a card it names. `$.fs.list` and `$.fs.exists` join the forbidden tokens, and no event, state key or number is added.
|
|
31
|
+
- **The `SessionStart` preflight leaves its report in `.claude/ship/preflight.json`, and marks what it speaks.** In an OpenSpec project the hook writes one `interlock.preflight/1` object: the doctor's verdict, counts and checks (with fixes, without evidence), the session-start source, the interrupted runs it spoke with whether each mark landed, and the halt resume cards of open changes, listed from each card's first-line stamp (`listResumeCards` and `parseResumeCardStamp` in `lib/resume-card.mjs`). The previous file is removed first, so a failed write leaves no stale report and is one stderr line. Any other repository gets no file and the same output as before. The hook now marks each interrupted-run note it speaks, in every directory it found the note in. A note it cannot mark is recorded as not marked and spoken again at the next session start and run start.
|
|
32
|
+
|
|
33
|
+
- **The ship meter draws the wave board, from the steps alone.** The pane's `waves` section used to be a flat list of spawns. Now it is the board `interlock waves --format board` draws. It has one block per wave by position, and each lane gets a row named as its agent is, with its batch, ids, model, tier, effort, state word, `←` ordered-after ids and title. Between blocks sit the verify cells, and the board ends with the `then:` tail. The board is cut at the pane's own `bodyColumns`, with `truncate-end` behind the cut. It comes from one source, the relayed steps: the plan summary on the adoption step, the cursor, the recorded ids, the skipped verifications by wave position, and the halt. A dispatched lane's row also carries the model the host reported serving it. The module reads no file for it. It is one pane and one command, not a second pane. It uses `Box` and `Text` only, on both surfaces, with no colour.
|
|
34
|
+
- **Every absence is spoken.** Under an older `interlock` that relays no plan, the section reads `plan structure not relayed by this CLI` above the flat rows, which read as before. In a pane narrower than the board's minimum, the renderer's one line stands above the flat rows. A renderer that throws leaves `wave board could not be drawn` and a debug line. A `plan` or `recorded` of the wrong shape is named once and ignored. Agents that are not lanes stay flat rows beneath the board.
|
|
35
|
+
- **The renderer gained what a second drawer needs.** `lib/draw-plan.mjs` exports `drawPlanBoardRows`, the board as `{ key, text }` rows keyed `header`, `wave:<i>`, `lane:<label>`, `verify:<i>` and `tail`. `drawPlanBoard` returns those rows' texts. A `notes` option appends what a host observed of a lane after its state word, and a skip that carries a wave position is placed by it, with no group to guess.
|
|
36
|
+
- **What the probes found, on 2.1.291.** Unasked in a 144-column terminal, the pane seated docked at `bodyColumns` 102. The twenty-task board drew in full there, its long rows cut with `…`. Typing `/interlock-meter` left the pane where it was. Under `claude plugin test`, the `terminal` and `desktop` surfaces both hand the pane the props the mount passed. The Desktop app itself cannot be launched by the probe harness.
|
|
37
|
+
- **No pin moved.** `ALLOWED_CALLS`, `HOOKED_EVENTS`, the forbidden tokens and `GUARD_REFUSAL` are unchanged. `claude plugin validate --strict` prints the same `hooks:`, `calls:` and state lines. A new pin in `test/spine/mod-pins.test.mjs` holds the board to `lib/draw-plan.mjs`'s rows and holds its section to no file read.
|
|
38
|
+
- **The Workflow host's relay carries six more step fields, for the meter's board.** Until now the relay dropped the wave positions, so the meter could not place a spawn in a wave or a batch on the host that runs a ship. `RELAY_STEP_FIELDS` gains `waveIndex`, `batchIndex` and `batchCount` (58 bytes on a step that carries all three) and a verify step's `skipped`. It also gains two new fields. `recorded`, on the step `run record-batch` returns, holds the ids the batch recorded as ok, failed and not attempted. It is built from the same expression as the manifest's tally, so the two cannot disagree. `plan`, on the adoption step and on the step after a replan, holds a summary of the waves that will run. The summary is built by a new pure, Node-free `lib/plan-summary.mjs`, and per task it keeps only the id, tier, model and the words the lane title uses. For a twenty-task change it is 2.2 KB compact and about 4.9 KB as the step prints it. The status line now shows `batch <j>/<k>` on the Workflow host too.
|
|
39
|
+
- **The schema stays `interlock.run-step/1`, and neither driver reads the new fields.** Every field is additive. `test/workflows.test.mjs` now pins that neither `workflows/ship.js` nor `bin/interlock-run` spells `step.plan`, `step.recorded`, `step.skipped` or a position. The relay is still a whitelist, and its pin names all fifteen fields.
|
|
40
|
+
- **A probe relayed the real adoption step intact.** A haiku `interlock:ping` relayed the 11,859-byte adoption step of a twenty-task change, and its copy equalled the CLI's `last-step.json` byte for byte, less the trailing newline.
|
|
41
|
+
- **The summary draws exactly as its plan.** `test/spine/plan-summary.test.mjs` draws the real halted run's plan and its summary through the renderer, with and without the state as overlay, and compares the lines. It fails naming any task path, full description, briefing or carried wave state planted in a summary. `dependsOnIds` moved into `lib/lane.mjs` beside a new `titleGist`, so it is still the one reader of `task.dependsOn`.
|
|
42
|
+
|
|
43
|
+
- **The spec meter draws an `/interlock:spec` run live, in the CLI's words.** Spec runs in the parent conversation and publishes no stage, so a person saw only the transcript. On a mods host the hooks module now goes live when the spec skill is loaded in an interactive session: a typed `/interlock:spec`, or a Skill call naming it. A probe on 2.1.291 found the engine raises no `skill.prompt` for a plugin skill, so the load is read off the prompt's first word and the Skill call, and both are passed on unchanged. `openspec` commands alone never start it, so stock `/opsx:propose` draws nothing. The status line reads `interlock spec: <change> · <stage>`, for example `status 2/4 done · next: design`, `ledger blocking (needs_human 2 · invalid 0)`, `validate READY`, `gate BLOCKED (blocker 1 · warning 3)` and `checkpoint`. The stage is read from the `--json` lines `print-json-from-the-spec-gate-lines` pinned. A line that printed no JSON reads `(unparsed)` with its first line, never as a verdict. `/interlock-spec` shows the artifact table as the last status printed it, explore's investigator count and brief, the gate results by field, the writes as counts and times, and the line `the artifacts on disk, the findings file and the gate's exit are the record`.
|
|
44
|
+
- **Three ends.** The checkpoint keeps the line, because it waits for a person. An accepted ship launch hands the line to the ship meter before the ship run is live. `/clear`, `/resume` and `/branch` end it with the ship run. A compaction does not.
|
|
45
|
+
- **Chained lines are read.** In both spec runs a probe drove, the model chained keyed lines (`openspec new change … && openspec status …`, `interlock autonomy clean …; interlock notify checkpoint …`). The meter splits a command at unquoted `&&`, `||` and `;`. It reads output only when one line in the command prints the output it reads.
|
|
46
|
+
- **Observe-only, and pinned.** It writes nothing, reads no file, calls no new engine method and adds no event, state key or number. It shows no quiet word, no `complete`, no `ready to ship` and no autonomy level. The rules live in a new pure `lib/spec-meter.mjs`. `test/spine/spec-meter.test.mjs` pins every fenced `openspec`/`interlock` line in the three skills to its classifier. `test/spine/mod-pins.test.mjs` pins one status writer, the two load observers building no text, and no `complete`. `claude plugin validate --strict` gains `tool.call{tool=Skill}`, the `interlock-spec` pane and its command, and no call.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- **The spec flow's three gate lines ask the CLI for JSON, and each skill says which field is the verdict.** `/interlock:spec` runs `interlock ledger "<name>" --json` and `interlock validate "<name>" --json`, and `/interlock:review-artifacts` runs `interlock gate --findings … --metrics <change> --json`. Beside each line, one sentence names the verdict field (`blocking`, `ready`, `passed`) and the list to act on (`rows` and `invalidRows`, `problems`, `blockers` and `malformed`). An `error` with `candidates` means the change name did not resolve. The exit codes, the CLI and the metrics record are unchanged, and the exit code is still the verdict.
|
|
51
|
+
- **The autonomy record's blocker count comes from the gate.** The `<n>` in `interlock autonomy record review-artifacts --blockers <n>` is now the gate's `autonomyOutcome.blockers`, never a count read off the review's own `SUMMARY:` line. That line was written by the party being gated.
|
|
52
|
+
- **The lines an observer keys on are pinned by token, and in order.** `test/skills.test.mjs` pins `interlock drift --json`, `openspec new change`, `openspec status … --json`, the three gate lines, both `interlock autonomy` lines, `interlock notify checkpoint` and `GOAL MET` on their command lines, never on a prose mention. It also pins the order the spec skill runs them in and the `--json` on continuity's `interlock ready` line. This is the `--metrics` precedent: a flag nothing asserts is a flag the first reword drops. `test/spine/cli.test.mjs` pins the fields the skills read by name, including `autonomyOutcome.blockers`.
|
|
53
|
+
- **Two stale docs rows are gone.** The handoff table no longer lists a `.claude/ready/<name>-review.json` that nothing writes. It names the artifact review's findings file, read by `interlock gate` and by `interlock ready --findings`. `docs/05-continuity.md` no longer shows the deprecated `--review` flag.
|
|
54
|
+
- **The follow-up is `observe-the-spec-run-live`.** It is a hooks module that reads these lines off the Bash tool results.
|
|
55
|
+
- **The kit's close summary names the card where the close writes it.** `test/fixtures/mod/steps.mjs` and the meter's kit cases named the resume card `.claude/ship/resume/add-the-thing.md`, a path nothing writes. They now name `.claude/handoff/ship-add-the-thing-wf_6b71ef8e-ba7.md`, which is `resumeCardPath` for the fixture's change and run.
|
|
56
|
+
- **The hooks module's status line is composed in one place, the ship position first.** The engine keeps one status line per plugin and each write replaces it (probed on 2.1.291). The ship meter used to write it from five places. Now one composer, `setStatus`, writes it: the ship position while a ship run is live, otherwise the spec meter's line, otherwise nothing. With no spec run, every ship case reads the same lines as before.
|
|
57
|
+
|
|
8
58
|
## 1.2.1 — 2026-10-06
|
|
9
59
|
|
|
10
60
|
### Added
|
|
11
61
|
|
|
12
|
-
- **A listing
|
|
62
|
+
- **A listing image, and a push topic the operator chooses to share.** The plugin carries a square listing image. A Claude Code user can set an ntfy topic and an optional server in the plugin's options. `INTERLOCK_NTFY_TOPIC` and `INTERLOCK_NTFY_URL` still work, and a value already provided that way wins. `/interlock:fix-tests` no longer pre-approves every shell command. `/interlock:spec` filters `openspec instructions` through a bundled Node script.
|
|
13
63
|
|
|
14
64
|
- **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.
|
|
15
65
|
- **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.
|
|
@@ -120,13 +170,13 @@ this project adheres to Semantic Versioning 2.0.0.
|
|
|
120
170
|
|
|
121
171
|
- **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.
|
|
122
172
|
|
|
123
|
-
- **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
|
|
173
|
+
- **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 read 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.
|
|
124
174
|
|
|
125
175
|
- **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.
|
|
126
176
|
- **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.
|
|
127
177
|
- **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.
|
|
128
178
|
- **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.
|
|
129
|
-
- **The `CLAUDE_CODE_EFFORT_LEVEL` probe stays where it was.** The validate ping still
|
|
179
|
+
- **The `CLAUDE_CODE_EFFORT_LEVEL` probe stays where it was.** The validate ping still reads CLAUDE_CODE_EFFORT_LEVEL, and `EFFORT ROUTING OVERRIDDEN` is unchanged. The doctor's allowlist reason for that read now names that variable only.
|
|
130
180
|
|
|
131
181
|
- **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.
|
|
132
182
|
|
|
@@ -206,7 +256,7 @@ this project adheres to Semantic Versioning 2.0.0.
|
|
|
206
256
|
- **Push on halt or completion.** With `INTERLOCK_NTFY_TOPIC` set, `run close --notify` — which both `workflows/ship.js` and `bin/interlock-run` now pass on every close — posts one ntfy message per terminal outcome: `high` priority on `SHIP HALTED`, default otherwise. The message carries the summary's first line, the change and the run id, and nothing that identifies the machine — never the topic, the cwd or the project slug. `INTERLOCK_NTFY_URL` selects a self-hosted server (default the public server); leaving the topic unset makes the default path, and the test suite, open no connection at all. A failed push prints `push: failed — <reason>` and a `PUSH FAILED: <reason>` degradation banner and never moves the exit code. `interlock notify` and the `interlock notify checkpoint <change>` sugar (used by `/interlock:spec`'s checkpoint) are also callable standalone.
|
|
207
257
|
- **The summary names the run and where it closed.** Every terminal summary now carries `run: <runId>` (or `run: none — the run halted before a plan was adopted`), `project: <slug>` and `cwd: <absolute path>` rows, so a reader who was not watching can find the trajectory file and the checkout afterwards. `<slug>` is a pure function of the closing directory (`lib/project-slug.mjs`), matching the directory name Claude Code uses under `~/.claude/projects`.
|
|
208
258
|
- **A clean completion reminds you to archive.** When every task is ticked and the run did not halt, the summary prints `ARCHIVE PENDING — <change>: after merge, run openspec archive <change>` — plus `also unarchived: <n> completed change(s) — run interlock drift` when other changes are also sitting there — right before the closing "do not start another run" line. It is a reminder, never a gate: the exit code never moves, because the shipped change satisfies the unarchived check by construction at every clean close.
|
|
209
|
-
- **`interlock doctor` stops reporting green for a run whose first ping prompts.** `REQUIRED_COMMANDS` gains `test`,
|
|
259
|
+
- **`interlock doctor` stops reporting green for a run whose first ping prompts.** `REQUIRED_COMMANDS` gains `test`, the effort-variable read, and `mkdir` — the commands that probe actually shells out to — each with a `why`, and a new `notify` row (`ok` naming the server, `skip` naming `INTERLOCK_NTFY_TOPIC`, never the value). A repo-fact test now checks this repository's own `.claude/settings.json` against the derived list, and a drift test extracts every instructed command out of `workflows/ship.js` and `lib/prompts/*.mjs` and fails, naming the file and the command, the moment one falls outside `REQUIRED_COMMANDS`, the runner commands, or the documented host read-only set (`HOST_READ_ONLY`).
|
|
210
260
|
|
|
211
261
|
All four are additive: unset `INTERLOCK_NTFY_TOPIC` and a run behaves exactly as before, plus the three new identity rows and, on a clean ship, the archive line. See [when it stops](docs/04-when-it-stops.md) for `ARCHIVE PENDING` and `PUSH FAILED`, and [push notifications](docs/07-cli-and-configuration.md#the-one-network-call-push-notifications) for the two environment variables.
|
|
212
262
|
|
|
@@ -216,7 +266,7 @@ this project adheres to Semantic Versioning 2.0.0.
|
|
|
216
266
|
|
|
217
267
|
CI's `cli-smoke` job packs the tarball, installs it into a throwaway prefix, and runs every binary the `bin` map names plus `interlock limits` from that prefix — the map is read with `node -e`, so a renamed binary cannot fall out of the smoke silently. Releases publish from a `v[0-9]*` tag through `.github/workflows/release.yml`: `npm test` first, then `npm publish --provenance --access public` over the workflow's OIDC identity. **No registry token is stored in this repository.** Trusted publishing is a one-time console step on the registry for this repository and that file name; until it exists the publish step fails with the registry's own error and the workflow is reported failed, never skipped.
|
|
218
268
|
|
|
219
|
-
- **The ACP driver became a runner, and a host is now one file plus a declaration.** `bin/interlock-ship-acp` spoke one protocol to one kind of agent. Once the loop moved into the CLI, everything left in that file was a transport — and a transport welded to one vendor is the reason "Interlock runs on Claude Code and nothing else" was a portability claim nobody could act on. It is **`bin/interlock-run`** now, with `--host claude | acp | codex | qwen` (or
|
|
269
|
+
- **The ACP driver became a runner, and a host is now one file plus a declaration.** `bin/interlock-ship-acp` spoke one protocol to one kind of agent. Once the loop moved into the CLI, everything left in that file was a transport — and a transport welded to one vendor is the reason "Interlock runs on Claude Code and nothing else" was a portability claim nobody could act on. It is **`bin/interlock-run`** now, with `--host claude | acp | codex | qwen` (or INTERLOCK_RUN_HOST), and an adapter under `lib/host/` is a transport plus a declaration of what that host **cannot** do: `schemaEnforced`, `modelSelect`, `worktree`, `hooks`, `usage`, `billing`. The run program reads those declarations; nothing branches on a host's name.
|
|
220
270
|
|
|
221
271
|
`bin/interlock-ship-acp` still works: it prints `interlock-ship-acp is now interlock-run --host acp` on stderr — never on stdout, which carries the summary a caller may be parsing — and execs the runner with your arguments and its exit status. **It is removed in the next minor version (0.3.0).**
|
|
222
272
|
|
package/bin/interlock
CHANGED
|
@@ -47,6 +47,8 @@ import {
|
|
|
47
47
|
import {
|
|
48
48
|
CLASSIFIED_PATH,
|
|
49
49
|
LAST_STEP_PATH,
|
|
50
|
+
MANIFEST_PATH,
|
|
51
|
+
STATE_PATH,
|
|
50
52
|
VERIFY_TIMINGS_PATH,
|
|
51
53
|
checkSequence,
|
|
52
54
|
readManifest,
|
|
@@ -65,6 +67,8 @@ import {
|
|
|
65
67
|
writeManifest
|
|
66
68
|
} from '../lib/run.mjs'
|
|
67
69
|
import { haltStep } from '../lib/run.mjs'
|
|
70
|
+
import { drawPlanBoard, drawPlanMermaid } from '../lib/draw-plan.mjs'
|
|
71
|
+
import { drawRunBoard, drawRunMermaid } from '../lib/draw-run.mjs'
|
|
68
72
|
import { explicitStateHome, resolveStateHome } from '../lib/state-home.mjs'
|
|
69
73
|
import { probeClaudeEffort, probeClaudeVersion } from '../lib/host/claude-cli.mjs'
|
|
70
74
|
import { observeClaudeEnv, runtimeSlotsOf } from '../lib/host/claude-env.mjs'
|
|
@@ -150,6 +154,7 @@ const USAGE = `interlock — deterministic decisions for the spec-driven flow
|
|
|
150
154
|
|
|
151
155
|
Usage:
|
|
152
156
|
interlock waves --classified <file> Plan execution waves from classified tasks
|
|
157
|
+
interlock waves --plan <f> --format <f> Draw a stored plan: board | mermaid
|
|
153
158
|
[--mode solo|waves] (--mode forces the plan shape;
|
|
154
159
|
[--red-wave <n>] --red-wave/--tdd run a leading
|
|
155
160
|
[--tdd|--no-tdd] failing-test wave first)
|
|
@@ -286,6 +291,7 @@ Usage:
|
|
|
286
291
|
interlock run-log append --event <f> Append one ship-run trajectory event
|
|
287
292
|
interlock run-log list [--change <name>] List recorded ship runs
|
|
288
293
|
interlock run-log show <runId> Print one run's events in order
|
|
294
|
+
(--format board|mermaid: draw them)
|
|
289
295
|
interlock run-log query --run <id> Filter one run's events
|
|
290
296
|
interlock run-log check --state <file> Reconstructability gate: exit 1 on
|
|
291
297
|
a gap, a missing start, or a
|
|
@@ -421,6 +427,19 @@ waves:
|
|
|
421
427
|
section whose tasks are ALL test tasks. A run resolves it
|
|
422
428
|
from the tasks.md section heading instead.
|
|
423
429
|
--no-tdd Never treat any section as a red wave.
|
|
430
|
+
--plan <file|-> Render a stored plan (what "interlock waves --json"
|
|
431
|
+
printed) instead of planning from --classified; the
|
|
432
|
+
two are mutually exclusive. A path that names no file
|
|
433
|
+
prints "no plan at <path>" and exits 0.
|
|
434
|
+
--format board|mermaid Draw the plan instead of listing it: a wave board, one
|
|
435
|
+
block per wave position, or a Mermaid flowchart LR.
|
|
436
|
+
Mutually exclusive with --json. Mermaid is never cut.
|
|
437
|
+
--state <file|-> With --format: lay a wave state over the plan — task
|
|
438
|
+
words, the cursor, verify skips — as recorded. A path
|
|
439
|
+
that names no file draws a plan-only board saying so.
|
|
440
|
+
--columns <n> With --format board: the width. Else the terminal's,
|
|
441
|
+
else the published default; the minimum and the
|
|
442
|
+
default are in "interlock limits". No effect on mermaid.
|
|
424
443
|
|
|
425
444
|
surface:
|
|
426
445
|
--changed <files...> Repo-relative paths. Use "-" to read newline-separated stdin.
|
|
@@ -579,6 +598,12 @@ run-log:
|
|
|
579
598
|
--change. Tolerates a torn or garbage line the same
|
|
580
599
|
way "outcomes list" does — skips it, reports it.
|
|
581
600
|
show <runId> Print one run's events in sequence order.
|
|
601
|
+
show <runId> --format board|mermaid [--columns <n>]
|
|
602
|
+
Draw the run as a handoff graph from the same records,
|
|
603
|
+
joined to the state home's state.json and run.json only
|
|
604
|
+
when each names this run; the header names any source
|
|
605
|
+
that is absent, unreadable or another run's. Mutually
|
|
606
|
+
exclusive with --json; widths as for "waves --format".
|
|
582
607
|
query --run <id> [--type <t>] [--halted]
|
|
583
608
|
Filter one run's events. --type keeps only that event
|
|
584
609
|
type. --halted keeps only the events that explain a
|
|
@@ -1688,6 +1713,45 @@ function num(flags, key) {
|
|
|
1688
1713
|
return n
|
|
1689
1714
|
}
|
|
1690
1715
|
|
|
1716
|
+
// The drawn surfaces (draw-wave-plan-and-handoff-graph-from-the-cli design D9).
|
|
1717
|
+
// `--format` is a third surface beside text and --json, refused beside --json as
|
|
1718
|
+
// `report --html` is; `--state` and `--columns` mean nothing without it.
|
|
1719
|
+
|
|
1720
|
+
/** The format asked for, `null` for none; dies on an unknown one or beside --json. */
|
|
1721
|
+
function drawFormat(flags, json) {
|
|
1722
|
+
if (flags.format === undefined) return null
|
|
1723
|
+
if (flags.format !== 'board' && flags.format !== 'mermaid') die('--format must be board or mermaid')
|
|
1724
|
+
if (json) die('--format and --json are mutually exclusive: choose one surface')
|
|
1725
|
+
return flags.format
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
/**
|
|
1729
|
+
* The width a board is drawn at: `--columns`, else the terminal's when stdout is
|
|
1730
|
+
* one, else the published default. The renderers never read the terminal.
|
|
1731
|
+
*/
|
|
1732
|
+
function boardColumns(flags) {
|
|
1733
|
+
if (flags.columns !== undefined) {
|
|
1734
|
+
const n = num(flags, 'columns')
|
|
1735
|
+
if (!Number.isInteger(n) || n <= 0) die('--columns requires a positive integer')
|
|
1736
|
+
return n
|
|
1737
|
+
}
|
|
1738
|
+
if (process.stdout.isTTY && Number.isInteger(process.stdout.columns)) return process.stdout.columns
|
|
1739
|
+
return LIMITS.waveBoardDefaultColumns
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/** A JSON file a drawing joins, read as the renderer expects it: never a throw. */
|
|
1743
|
+
function readSource(path) {
|
|
1744
|
+
if (!existsSync(path)) return { value: null, read: 'absent' }
|
|
1745
|
+
try {
|
|
1746
|
+
return { value: JSON.parse(readFileSync(path, 'utf8')), read: 'ok' }
|
|
1747
|
+
} catch {
|
|
1748
|
+
return { value: null, read: 'unreadable' }
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
/** A path argument that names no file. `-` is stdin, never missing. */
|
|
1753
|
+
const missingFile = value => typeof value === 'string' && value !== '-' && !existsSync(value)
|
|
1754
|
+
|
|
1691
1755
|
// The plan mode override, resolved for the planner. Absent → undefined (the
|
|
1692
1756
|
// classifier's recommendation decides inside the envelope). A single value is
|
|
1693
1757
|
// passed through as a string so `decideMode` can accept or reject it; two
|
|
@@ -1795,19 +1859,58 @@ async function main() {
|
|
|
1795
1859
|
|
|
1796
1860
|
switch (cmd) {
|
|
1797
1861
|
case 'waves': {
|
|
1798
|
-
const
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1862
|
+
const format = drawFormat(flags, json)
|
|
1863
|
+
if (!format) {
|
|
1864
|
+
if (flags.state !== undefined) die('--state needs --format board|mermaid')
|
|
1865
|
+
if (flags.columns !== undefined) die('--columns needs --format board|mermaid')
|
|
1866
|
+
}
|
|
1867
|
+
if (flags.plan !== undefined && flags.classified !== undefined) {
|
|
1868
|
+
die('--plan and --classified are mutually exclusive')
|
|
1869
|
+
}
|
|
1870
|
+
let plan
|
|
1871
|
+
let source
|
|
1872
|
+
if (flags.plan !== undefined) {
|
|
1873
|
+
// A stored plan, rendered as it was planned. A path that names no file
|
|
1874
|
+
// is spoken and exits 0: reading a plan is never itself a gate.
|
|
1875
|
+
if (missingFile(flags.plan)) {
|
|
1876
|
+
const reason = `no plan at ${flags.plan}`
|
|
1877
|
+
emit(json, { plan: null, reason }, `${reason}\n`)
|
|
1878
|
+
break
|
|
1879
|
+
}
|
|
1880
|
+
plan = readInput(flags.plan, '--plan')
|
|
1881
|
+
source = flags.plan
|
|
1882
|
+
} else {
|
|
1883
|
+
const input = readInput(flags.classified, '--classified')
|
|
1884
|
+
const maxParallel = flags['max-parallel'] ? Number(flags['max-parallel']) : undefined
|
|
1885
|
+
// This subcommand sees only the classified input — it has no change name
|
|
1886
|
+
// and therefore no tasks.md to read the section marker from. So the red
|
|
1887
|
+
// wave is either named outright (`--red-wave`) or derived from the
|
|
1888
|
+
// classification's own structure (`--tdd`). A run resolves it from the
|
|
1889
|
+
// marker instead; see `resolveRedWave` in lib/run.mjs.
|
|
1890
|
+
plan = planWaves(input, {
|
|
1891
|
+
maxParallel,
|
|
1892
|
+
mode: modeFlag(flags),
|
|
1893
|
+
redWave: redWaveFlag(flags, input)
|
|
1894
|
+
})
|
|
1895
|
+
source = `interlock waves --classified ${flags.classified}`
|
|
1896
|
+
}
|
|
1897
|
+
if (!format) {
|
|
1898
|
+
emit(json, plan, formatPlan(plan))
|
|
1899
|
+
break
|
|
1900
|
+
}
|
|
1901
|
+
let state = null
|
|
1902
|
+
let stateNote
|
|
1903
|
+
if (flags.state !== undefined) {
|
|
1904
|
+
if (missingFile(flags.state)) stateNote = `no state at ${flags.state}`
|
|
1905
|
+
else state = readInput(flags.state, '--state')
|
|
1906
|
+
}
|
|
1907
|
+
// Validated for both forms; Mermaid is never cut, so it ignores the width.
|
|
1908
|
+
const columns = boardColumns(flags)
|
|
1909
|
+
const lines =
|
|
1910
|
+
format === 'board'
|
|
1911
|
+
? drawPlanBoard(plan, { columns, state, stateNote })
|
|
1912
|
+
: drawPlanMermaid(plan, { state, stateNote, source })
|
|
1913
|
+
process.stdout.write(lines.join('\n') + '\n')
|
|
1811
1914
|
break
|
|
1812
1915
|
}
|
|
1813
1916
|
|
|
@@ -2344,6 +2447,7 @@ async function main() {
|
|
|
2344
2447
|
case 'run-log': {
|
|
2345
2448
|
const sub = positional[1]
|
|
2346
2449
|
const logHome = corpusHome(root, flags).home
|
|
2450
|
+
if (flags.format !== undefined && sub !== 'show') die('--format is read by run-log show only')
|
|
2347
2451
|
if (sub === 'append') {
|
|
2348
2452
|
const record = readInput(flags.event, '--event <file|->')
|
|
2349
2453
|
const { written, path, reason } = appendRunLogEvent(logHome, record)
|
|
@@ -2360,8 +2464,32 @@ async function main() {
|
|
|
2360
2464
|
} else if (sub === 'show') {
|
|
2361
2465
|
const runId = positional[2]
|
|
2362
2466
|
if (!runId) die('run-log show requires a runId')
|
|
2467
|
+
const format = drawFormat(flags, json)
|
|
2468
|
+
if (!format && flags.columns !== undefined) die('--columns needs --format board|mermaid')
|
|
2363
2469
|
const result = readRunLog(logHome, runId)
|
|
2364
|
-
|
|
2470
|
+
if (!format || !result.exists) {
|
|
2471
|
+
// A run never recorded reads the same with and without --format.
|
|
2472
|
+
emit(json, result, formatRunLog(result))
|
|
2473
|
+
} else {
|
|
2474
|
+
// The run's own state and manifest, from the state home beside the
|
|
2475
|
+
// trajectory. The renderer joins each only when it names this run.
|
|
2476
|
+
const manifest = readSource(join(logHome, MANIFEST_PATH))
|
|
2477
|
+
if (manifest.read === 'ok' && !(manifest.value && manifest.value.schema === 'interlock.run/1')) {
|
|
2478
|
+
manifest.value = null
|
|
2479
|
+
manifest.read = 'unreadable'
|
|
2480
|
+
}
|
|
2481
|
+
const drawn = {
|
|
2482
|
+
runId,
|
|
2483
|
+
path: result.path,
|
|
2484
|
+
records: result.records,
|
|
2485
|
+
skipped: result.skipped,
|
|
2486
|
+
state: readSource(join(logHome, STATE_PATH)),
|
|
2487
|
+
manifest
|
|
2488
|
+
}
|
|
2489
|
+
const columns = boardColumns(flags)
|
|
2490
|
+
const lines = format === 'board' ? drawRunBoard(drawn, { columns }) : drawRunMermaid(drawn)
|
|
2491
|
+
process.stdout.write(lines.join('\n') + '\n')
|
|
2492
|
+
}
|
|
2365
2493
|
} else if (sub === 'query') {
|
|
2366
2494
|
const runId = typeof flags.run === 'string' ? flags.run : undefined
|
|
2367
2495
|
if (!runId) die('--run is required')
|