Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec. Onboard a repo, produce a reviewed spec, then ship it start-to-commit…

Spec-driven development for Claude Code. You read one spec; parallel agents ship it to a tested commit.
<img src="docs/assets/interlock-flow-wide.png" alt="Interlock flow: bootstrap once, then spec, you read the spec, ship, and optionally mr. Two commands, one human checkpoint." width="900">
/interlock:spec "<idea>" # explores your repo, writes a reviewed spec, then stops
# ← you read it. The only place a human is required.
/interlock:ship # parallel agents implement → your tests pass → commit. Nobody is asked.
ship is a Claude Code workflow, and the workflow runtime has no channel for mid-run input. It cannot ask you anything, so it never does.Interlock is a Claude Code plugin layered on OpenSpec, which owns the spec format. It needs Claude Code v2.1.154+ with dynamic workflows enabled, the openspec CLI, and Node.js ≥ 18. The full requirements table is in the first hour.
npm install -g @fission-ai/openspec@latest # OpenSpec first
cd your-project && openspec init
/plugin marketplace add renzrollon/interlock
/plugin install interlock@interlock
Then, once per repo. The plugin puts the interlock CLI on your PATH:
interlock doctor # preflight: prints the exact allowlist an unattended run needs
/interlock:bootstrap # reads the code, writes what it learned, builds the graph agents navigate
doctor exits 1 on anything that would stop a zero-touch run and prints the settings snippet that fixes it. The plugin runs it at every session start too. Your first shipped change, step by step: 01 — The first hour.
/interlock:bootstrap | Onboard a repo — once | skill |
/interlock:spec "<idea>" | Idea → explored, reviewed, implementation-ready change. Asks you what the repo cannot answer, then stops | skill |
| You read the spec | The checkpoint. Ten minutes, with a checklist | you |
/interlock:ship | Reviewed change → waves (parallel batches of file-disjoint tasks) → your test suite → commit. No review by default; --strict adds adversarial review and handoff artifacts. --solo / --waves force the shape | workflow |
/interlock:mr | Change → merge request | skill |
ship is the odd one out on purpose. A skill is instructions Claude follows; a workflow is a script a runtime executes, and this runtime has no channel for a question. That is why every decision that could need a human is settled before ship starts, and why spec is conversational where it has to be. The plan preview names the shape and the agent count before anything is spawned.
Decisions that have a correct answer are moved out of prose and into code, one at a time. The script holds the loop, the CLI holds the rules, the agents do the work.
interlock subcommand with an exit code, not markdown a model can talk itself past. Every subcommand →--isolate-waves, every parallel task runs in its own git worktree and a collision is a named halt, never a lost write.--strict, up to six review dimensions run in parallel, then two skeptics attack every finding. A dismissal must cite a file:line in the diff or it dismisses nothing. You see only what survived, and how many did not. How and why →PreToolUse hooks, inert outside a run. The guards →interlock drift reports unarchived changes, specs citing missing files, and code no spec describes. Each at its own confidence level, and it never blocks. OpenSpec vs Interlock →Most of the category competes on how much structure you write before coding — Spec Kit adds phases, BMAD adds roles, Kiro adds an IDE. Interlock competes on a different axis: how many decisions the model is not allowed to make.
It composes OpenSpec rather than replacing it. openspec init installs its own skills, and /interlock:spec drives the openspec CLI directly, so both stay available. Use /interlock:spec when you want the gates, and the stock skills when you want the plain artifact loop. What Interlock adds, and when plain OpenSpec is the right call: 03. Where it sits against OpenClaw, Hermes Agent and DeepSeek Harness: 08.
The trade is portability. Claude Code is the default and supported host; Cursor and Copilot are not supported. Everything the loop decides lives in a CLI any host can shell out to, so portability means a second host adapter, not thirty prompt templates.
interlock-run drives the same loop over the Claude Code CLI, any Agent Client Protocol (ACP) agent, OpenAI Codex or Qwen Code. You start it yourself. No slash command does, and /interlock:ship never falls back to it.
Two things to know before you do. claude -p, the Agent SDK and ACP are the usage Anthropic flagged for separate metered credit; the interactive Workflow runtime that /interlock:ship uses is the exempted path, and the runner prints which path it is on. And Codex and Qwen do not understand the planner's model tiers, so map them per host with INTERLOCK_MODEL_MAP; an unmapped tier is bannered, never silently run on your default.
Host table, banners and configuration: 07. The older interlock-ship-acp still works, prints a deprecation line, and is removed in the next minor. Code Mode is out of scope.
Three zero-dependency Node binaries land on your PATH, and they work without the plugin too (npm install -g @renzrollon/interlock):
interlock | The policy engine: limits, gate, waves, verify, doctor, report and the rest. Every gating command exits 1 when it blocks |
interlock-graph | A local, deterministic code knowledge graph. No vector store, no network |
interlock-run | The experimental runner above |
Everything runs without a model and without the network, with one exception. interlock notify and run close --notify push a message when a run halts or completes, and only when INTERLOCK_NTFY_TOPIC is set; INTERLOCK_NTFY_URL points it at a self-hosted server. Reference and every environment variable: 07 — CLI and configuration.
New here? Start with 01 — The first hour. Only ever prompted a coding agent? 09 defines every term once, ending at why ship is a script and not a prompt.
| 01 — The first hour | Install to first shipped change |
| 02 — The checkpoint | How to read a spec in ten minutes |
| 03 — OpenSpec vs Interlock | What composes with what |
| 04 — When it stops | Every halt and banner, and what to do |
| 05 — Continuity | When --continue may skip the human read |
| 06 — Why it works | The mechanisms, low-level, with the costs stated |
| 07 — CLI and configuration | Every subcommand, environment variable and host |
| 08 — The harness landscape | OpenClaw, Hermes Agent, DeepSeek Harness, and the spec-driven neighbours |
| 09 — From prompt to workflow | New to agentic workflows? Every term defined, then why ship is a script |
| 10 — Ship and spec for prompt-only engineers | Primer, review of spec+ship, token and quality tactics |
| 11 — The indicators | What interlock report measures and why it gates nothing; whether to commit the run corpora |
| 12 — Repository review policy | The optional REVIEW.md: what it can change and what it cannot |
| 13 — The guards | The hooks, the stage marker's lifecycle, and the fail-open rule |
| 14 — Evals | What a consumer's run is checked by, why no model evals run in your CI, how to file a failure |
git clone https://github.com/renzrollon/interlock && cd interlock
npm test # no dependencies to install first
claude --plugin-dir . # load it without installing
More in CONTRIBUTING.md. Built on OpenSpec by Fission AI, and on the wave-execution pattern for parallel task application. MIT.
hooks/mod.mjs 1919 lines1// The ship meter: a hooks module that draws a ship run while it runs, and the
2// in-process form of the launch guard.
3//
4// Every hook here observes but one branch. The CLI decides; this module shows
5// what the CLI already said, the moment it says it: the step each `interlock`
6// call printed (read off the ping's Bash result), the per-agent figures each
7// model request reported, and the plan windows the session reports. It joins
8// nothing into any record, and recognises no banner by its wording: it shows
9// the `banners` list the CLI emitted. The close summary and the receipt are
10// the record; the pane says so.
11//
12// THE ONE BRANCH THAT DECIDES (guard-ship-relaunch-in-process design D3). The
13// Workflow hook refuses a ship launch when the session recorded one newer than
14// its last human prompt: the settings hook `hooks/guard-relaunch.mjs` reads the
15// same rule over a file, and this reads it over the engine's own prompt origin
16// and session state, before the engine or the settings layer sees the call.
17// The verdict and its words are `lib/launch-rule.mjs`'s, never this file's;
18// `test/spine/mod-pins.test.mjs` pins that the refusal is that verdict, once.
19// A guard that cannot read its facts allows, says so on the debug log, and
20// leaves the settings form to decide.
21//
22// It runs in the engine's own environment, with no Node and `$` as its only
23// way out, so it imports nothing from the plugin that reaches `node:`:
24// `lib/launch-rule.mjs` is Node-free by design, and the briefing-hash
25// expression is restated below rather than imported, because
26// `lib/agent-usage.mjs` imports `node:fs` (design D4, D5). It imports nothing
27// from `claude-code` either: Claude Code 2.1.274 refuses a module that passes
28// `$` into a function imported from there, so the state is read and written
29// with `$.state` directly. What it may call and must never contain is pinned
30// by `test/spine/mod-pins.test.mjs` (design D8); its behaviour by
31// `test/mod/meter.test.ts` and `test/mod/relaunch.test.ts` under
32// `claude plugin test`.
33//
34// The meter's state is module-level (design D3): a reload starts it over, a run
35// launched before the reload is not drawn again, and the first of its steps to
36// cross the module is named once on the debug log. The guard's record is the session's (`$.state`, declared in
37// types/index.d.ts): it survives a reload, and /clear, /resume and /branch
38// empty it, which allows the next launch. The meter's run ends on that same
39// boundary, the classic session-start event with a `clear`, `resume` or `fork`
40// source, so the two things the module keeps are reset together
41// (show-quiet-time-and-reset-the-meter-on-clear design D4).
42//
43// THE QUIET FIGURE (same change, design D1-D3, D5). While a run is live the
44// module stamps the engine's time of each thing the run does, and one interval
45// the host runs for it recomputes how long the run has been quiet. Once that
46// reaches the published threshold, `quiet <n> min` follows the position on the
47// status line, the spinner and the pane. The word names no cause, and the
48// threshold and the period are `lib/meter-quiet.mjs`'s, read from
49// `interlock limits`; this file holds neither number.
50//
51// THE GUARDS' REFUSALS (speak-permission-prompts-and-guard-denials design D4,
52// D5). A settings guard that refuses a tool call ends it with an errored result
53// whose text carries the guard's own sentence behind an engine prefix. The
54// tool hooks on Bash, Edit, Write and Workflow read that result, and when it
55// names one of the four guards the sentence is toasted once and counted on the
56// pane. The in-process launch guard's own refusal is toasted in the rule's
57// words, and the pane shows the rule's ruling over the session's record. The
58// names and the words are `lib/meter-refusals.mjs`'s and the guards'; the
59// permission prompts are not spoken, because no classic event reaches a hooks
60// module on the engines probed (design D1, D9).
61//
62// THE WAVE BOARD (draw-the-wave-board-in-the-meter-pane design D5-D8). The
63// pane's waves section is the board `lib/draw-plan.mjs` draws for the CLI,
64// drawn from one source: the plan summary the adoption and replan steps carry,
65// with an overlay built from the steps alone (the cursor, the ids each recorded
66// batch names, the skipped verifications, the halt). No file is read for it.
67// The module lays those rows as one bordered card per wave, wraps the gist,
68// and colours the state word the renderer chose and the lane's label. It keys the lanes,
69// appends what the host observed of a lane, and speaks every fallback: a CLI
70// that relays no plan, a pane narrower than the board, a renderer that throws.
71//
72// THE AGENTS' NAMES AND THE PALETTE. The
73// Workflow host raises no `agent.spawn` for a Workflow agent, so the spawn
74// event's join never fires there. Every run agent's first act is to read its
75// own briefing, and that Read or Bash call names the spawn's briefing path:
76// the module joins the agent to the spawn's row by it, through the same
77// briefing hash. A driver line from an agent no row names marks a relay; any
78// other agent reads `unmatched agent`, said once beneath the rows. Every colour
79// is a theme key from `lib/meter-palette.mjs` and names a meaning, never a
80// threshold: the engine's own figures carry none.
81//
82// THE STEP TIMELINE. The agents list's place is a timeline: one line per step
83// as it crossed, the agent whose Bash call printed it folded into that line,
84// the agents a step spawned one indent beneath it in the order they started,
85// relays no step names at their own time, and the agents nothing names last.
86// Every timed line carries the local `HH:MM:SS` of a reading the module took
87// itself (the launch's, a step's stamp, an agent's first request), blank
88// where the clock gave none; durations are humanised. The order and the
89// step's words are `lib/meter-timeline.mjs`'s, the time words
90// `lib/meter-time.mjs`'s. Within a wave card the lanes sit beneath their
91// batch's sub-header, timed by when that batch was first dispatched.
92//
93// THE SESSION START (show-preflight-and-interrupted-runs-at-session-start
94// design D5-D8). Outside a run the module draws two things, both from the
95// report `hooks/preflight.mjs` leaves at `.claude/ship/preflight.json`: the
96// `AbovePrompt` band with `/interlock-preflight`, and `/interlock-handoff`,
97// which renders a halt resume card the report names. That file and a card it
98// names are the module's one file read, `$.fs.read`, and nothing else: never a
99// run's trajectory, its manifest or its wave state, and no listing or
100// existence check (the hook lists, in Node). It reads at each prompt a person
101// submits, the one event that reaches a module after the preflight wrote, and
102// after the classic session-start event where an engine raises one. A report
103// is known by its own written time, so a re-read changes nothing and the band
104// stays hidden once hidden; the lines, the hide rule and the card's cap are
105// `lib/preflight-file.mjs`'s.
106//
107// THE SPEC METER (observe-the-spec-run-live design D1-D7). The same module
108// draws an `/interlock:spec` run, which has no CLI spine of its own, from what
109// already crossed it: the spec skill's load, read off a submitted prompt's
110// first word or a Skill tool call (the engine raised no `skill.prompt` for a
111// plugin skill on 2.1.291, so that event is not hooked), the Bash results of
112// the flow's keyed `openspec` and `interlock` lines, the paths of Edit and
113// Write calls, and the Explore spawns. A prompt, a Skill call and every result
114// are passed on as they came. It writes nothing and reads no file: the
115// artifacts on disk, the findings file and the gate's exit are the record,
116// and the pane says so. Its record is module state, ended at the checkpoint,
117// at an accepted ship launch and at the session boundary. The module owns one
118// status line, written by `setStatus` alone: the ship position while a ship
119// run is live, else the spec line. Every rule and word is
120// `lib/spec-meter.mjs`'s.
121
122import { drawPlanBoardRows } from '../lib/draw-plan.mjs'
123import { decideLaunch, emptyRecord, isAcceptedLaunch, isShipLaunch, withLaunch, withPrompt } from '../lib/launch-rule.mjs'
124import { LIMITS } from '../lib/limits.mjs'
125import { PALETTE, stateProps, turnProps } from '../lib/meter-palette.mjs'
126import { TICK_MS, quietMs, quietWord } from '../lib/meter-quiet.mjs'
127import { clockText, durationText, stampText } from '../lib/meter-time.mjs'
128import { BATCH_ACTIONS, batchText, laneCountText, planWaveCount, stepText, timeline } from '../lib/meter-timeline.mjs'
129import { guardDenialLine, guardOf, guardReason, launchGuardLine } from '../lib/meter-refusals.mjs'
130import {
131 adoptPreflight,
132 bandLines,
133 cardLine,
134 clampCard,
135 freshStart,
136 noCardText,
137 noReportText,
138 orderCards,
139 preflightFilePath,
140 preflightPaneLines,
141 readPreflightReport
142} from '../lib/preflight-file.mjs'
143import {
144 SPEC_REPLACED_LINE,
145 SPEC_TITLE,
146 SPEC_UNPLACED_TOAST,
147 TEXT_KINDS,
148 changeOf,
149 freshSpec,
150 lateLineText,
151 promptSkill,
152 readResult,
153 skillRole,
154 specLines,
155 specPaneLines,
156 specStatusText,
157 unparsedEntry,
158 unreadLineText,
159 writeOf
160} from '../lib/spec-meter.mjs'
161
162const PANE = 'interlock-meter'
163const TITLE = 'Interlock'
164const RECORD_LINE = 'live figures; the close summary and the receipt are the record'
165const NO_RUN_LINE = 'no ship run is live in this session'
166const UNPLACED_TOAST = '/interlock-meter opens the ship meter'
167// The wave section's spoken lines (draw-the-wave-board-in-the-meter-pane design D8).
168const NO_LANES_LINE = 'no lanes dispatched yet'
169const UNRELAYED_LINE = 'plan structure not relayed by this CLI'
170const BOARD_FAILED_LINE = 'wave board could not be drawn'
171// The session start's two panes, each opened by the command of its name (design D6).
172const PREFLIGHT_PANE = 'interlock-preflight'
173const HANDOFF_PANE = 'interlock-handoff'
174// The spec meter's pane and command (observe-the-spec-run-live design D6), spelled
175// here so the validator records it; pinned equal to lib/spec-meter.mjs's.
176const SPEC_PANE = 'interlock-spec'
177
178// The launch guard's record in the session's state: the one key the plugin's
179// type contract declares (types/index.d.ts; guard-ship-relaunch-in-process D2).
180const LEDGER = { plugin: 'interlock', key: 'ledger' }
181// The prompt origins a person stands behind: their own Enter, the Remote
182// Control bridge, the SDK host's turn (design D3). The completion wake, a
183// schedule, a peer, a channel, a coordinator, an observer, a plugin's own and
184// a missing origin count nothing.
185const HUMAN_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
186const GUARD = 'interlock guard'
187// The line `cli()` in workflows/ship.js hands the ping (design D2).
188const DRIVER_LINE = /^interlock\s/
189// The bootstrap line a lane's spawn prompt carries (as `lib/agent-usage.mjs` reads it).
190const BRIEFING_SHA = /Expected sha256: ([0-9a-f]{64})/g
191// What an agent row and a turn-end toast read for an agent no row and no driver line names.
192const UNMATCHED = 'unmatched agent'
193const UNMATCHED_LINE = 'unmatched: no briefing read or interlock line seen from this agent yet'
194// The characters a briefing path may stand behind in a Read path or a command,
195// and the ones that would make it part of a longer file name after it.
196const PATH_BEFORE = /[\s\/'"=]/
197const PATH_AFTER = /[\w.+-]/
198/** The props a name is drawn with: a lane label, a spawn's title, a joined agent's name. */
199const NAME_PROPS = { bold: true, color: PALETTE.identity }
200const DIM = { dimColor: true }
201// The timeline's gutter: `HH:MM:SS` and two spaces, and how far a section body
202// and a child line sit in from what holds them (layout probe P3, Claude Code 2.1.295).
203const GUTTER_COLUMNS = 10
204const INDENT = 2
205const NO_STEP_LINE = 'no step has crossed yet'
206/** The props the run header and each section heading are drawn with. */
207const HEADING_PROPS = { bold: true, color: PALETTE.accent }
208const TOKEN_FIELDS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']
209// The classic session-start sources that start the session over, as the person
210// typed them (design D4). `startup` and `compact` are not among them.
211const BOUNDARIES = new Map([
212 ['clear', '/clear'],
213 ['resume', '/resume'],
214 ['fork', '/branch']
215])
216
217let session = { interactive: false, surface: null, cwd: null }
218let run = freshRun()
219// The session start's report, as last read (design D8): module state, reset
220// with the run on a reload and on the session boundary.
221let start = freshStart()
222// Counts every interval this environment started, so a period can tell its own (design D2).
223let generations = 0
224// The spec run, as module state (observe-the-spec-run-live design D7): reset
225// on a reload and at the session boundary, like the ship run.
226let spec = freshSpec()
227
228function freshRun() {
229 return {
230 // idle → live (accepted launch) → closing (a close or halt step) → closed (the close record)
231 phase: 'idle',
232 runId: null,
233 workflowName: null,
234 transcriptDir: null,
235 change: null,
236 action: null,
237 wave: undefined,
238 batchIndex: undefined,
239 batchCount: undefined,
240 rows: new Map(),
241 banners: [],
242 toasted: new Set(),
243 agents: new Map(),
244 summary: null,
245 exitCode: null,
246 // The quiet figure (design D1, D2): the engine's time of the run's last
247 // activity (null until the clock answers), the word it makes, and the one
248 // interval that recomputes it, known by its handle and its generation.
249 lastActivityAt: null,
250 // The reading the last stamp took (null when the clock failed), and the
251 // launch's own: the timeline's times, never guessed.
252 stampedAt: null,
253 launchedAt: null,
254 // Every step applied, in crossing order, and the time each batch was first dispatched, by `<waveIndex>:<batchIndex>`.
255 steps: [],
256 batchAt: new Map(),
257 quietWord: null,
258 tick: null,
259 generation: 0,
260 clockFailed: false,
261 // Whether a step that crossed with no live run has been named on the debug log.
262 idleStepNamed: false,
263 // Each settings guard's refusals this run, by guard, in first-seen order (design D4).
264 guardDenials: new Map(),
265 // The wave board (draw-the-wave-board-in-the-meter-pane design D6), every
266 // field taken off a step: the latest plan summary, the ids each recorded
267 // batch named (and, for a failed id, the wave it was recorded at), the
268 // skipped verifications by position, where the run is, the last batch
269 // dispatched, the halt, and whether a malformed field was named yet.
270 plan: null,
271 recorded: { ok: [], failed: [], notAttempted: [] },
272 failedAt: new Map(),
273 skips: [],
274 cursor: null,
275 dispatched: null,
276 halt: null,
277 planNamed: false,
278 recordedNamed: false
279 }
280}
281
282const watching = () => session.interactive && (run.phase === 'live' || run.phase === 'closing')
283const specWatching = () => session.interactive && spec.phase === 'live'
284const isInt = v => Number.isInteger(v)
285const str = v => (typeof v === 'string' && v ? v : null)
286
287const messageOf = err => (err && err.message) || String(err)
288const asRecord = value => (value && typeof value === 'object' && Array.isArray(value.launches) ? value : emptyRecord())
289
290/**
291 * What the launch guard decides over: the session's record and the time now. A
292 * missing or malformed record is empty; one that cannot be read at all is
293 * empty too, and named on the debug log. An empty record allows (design D5).
294 */
295async function guardFacts($) {
296 try {
297 const held = await $.state.get(LEDGER)
298 return { record: asRecord(held.value), now: await $.clock.now() }
299 } catch (err) {
300 $.ui.log(`${GUARD}: cannot establish this session's launches, allowing: ${messageOf(err)}`, { to: 'debug' })
301 // No time, not a made-up one: the empty record allows before the rule reads
302 // it, and the meter stamps no launch time it does not have (design D1).
303 return { record: emptyRecord(), now: null }
304 }
305}
306
307/**
308 * Change the session's record with `change(record, at)`, `at` the time now as
309 * ISO. Written only over the version it read and read again when another write
310 * landed first, as the engine's `update` does. A write that fails is named on
311 * the debug log and never thrown: it runs after the call it records did.
312 */
313async function changeRecord($, what, change) {
314 try {
315 const at = new Date(await $.clock.now()).toISOString()
316 let held = await $.state.get(LEDGER)
317 for (;;) {
318 const written = await $.state.set(LEDGER, change(asRecord(held.value), at), { ifVersion: held.version })
319 if (written.isSet) return
320 const again = await $.state.get(LEDGER)
321 if (again.version === held.version) throw new Error(`the write missed at version ${held.version} with no other write`)
322 held = again
323 }
324 } catch (err) {
325 $.ui.log(`${GUARD}: ${what} not recorded: ${messageOf(err)}`, { to: 'debug' })
326 }
327}
328
329/** `<change> · <action> · wave <w> · batch <j>/<k>`, each position omitted when the step carries none (design D6). */
330function position() {
331 const parts = []
332 if (run.change) parts.push(run.change)
333 if (run.action) parts.push(run.action)
334 if (run.wave !== undefined && run.wave !== null) parts.push(`wave ${run.wave}`)
335 const batch = batchText(run.batchIndex, run.batchCount)
336 if (batch) parts.push(batch)
337 return parts.join(' · ')
338}
339
340/** The position, then the quiet word while it is set: what the status line and the spinner say (design D5). */
341const shown = () => [position(), run.quietWord].filter(Boolean).join(' · ')
342
343function statusText() {
344 const text = shown()
345 return text ? `interlock: ${text}` : undefined
346}
347
348/**
349 * The module's one status line, composed in one place (observe-the-spec-run-live
350 * design D7): the ship position while a ship run is live, else the spec line
351 * while a spec run is live or stopped at its checkpoint, else nothing. The
352 * engine keeps one line per plugin and each call replaces it (probe 4), so
353 * this is the only writer.
354 */
355function setStatus($) {
356 const shownSpec = spec.phase === 'live' || spec.phase === 'checkpoint'
357 $.ui.status(run.phase === 'live' ? statusText() : shownSpec ? specStatusText(spec) : undefined)
358}
359
360/**
361 * The engine's time now, or `null` when the clock cannot be read. A read that
362 * fails is named once per run on the debug log and never thrown (design D1).
363 */
364async function clockNow($) {
365 const held = run
366 try {
367 const now = await $.clock.now()
368 if (Number.isFinite(now)) return now
369 throw new Error(`the clock answered ${String(now)}`)
370 } catch (err) {
371 if (!held.clockFailed) {
372 held.clockFailed = true
373 $.ui.log(
374 `interlock meter: the clock cannot be read; the last activity time stands and no quiet figure is shown: ${messageOf(err)}`,
375 { to: 'debug' }
376 )
377 }
378 return null
379 }
380}
381
382/** Sets the quiet word; true when it changed. */
383function setWord(word) {
384 if (run.quietWord === word) return false
385 run.quietWord = word
386 return true
387}
388
389/** Draws the word where it shows: the status line while the run is live, the pane and the spinner. */
390function redrawWord($) {
391 if (run.phase === 'live') setStatus($)
392 $.ui.invalidate('ui.render')
393}
394
395/**
396 * Activity (design D1): the run's last activity is now, and the quiet word is
397 * gone. A clock that cannot be read leaves the stamp as it was and the word
398 * absent, never a guessed time. True when the word changed, so the caller redraws.
399 */
400async function stamp($) {
401 const held = run
402 const now = await clockNow($)
403 if (held !== run) return false
404 run.stampedAt = now
405 if (now !== null) run.lastActivityAt = run.lastActivityAt === null ? now : Math.max(run.lastActivityAt, now)
406 return setWord(null)
407}
408
409/**
410 * Starts the run's one interval (design D2), the host's own timer: each period
411 * reads the clock and recomputes the word, and draws only when the word
412 * changed. A period that finds another run or another interval in its place
413 * returns at once. A host that will not start one leaves the pane, which
414 * recomputes the word whenever it is drawn.
415 */
416function startTick($) {
417 const generation = ++generations
418 run.generation = generation
419 const isMine = tick => run.generation === generation && run.tick === tick
420 try {
421 const tick = $.clock.every(TICK_MS, async () => {
422 if (!isMine(tick)) return
423 try {
424 const now = await clockNow($)
425 if (isMine(tick) && setWord(quietWord(quietMs(run.lastActivityAt, now)))) redrawWord($)
426 } catch (err) {
427 $.ui.log(`interlock meter: a quiet-figure period failed: ${messageOf(err)}`, { to: 'debug' })
428 }
429 })
430 run.tick = tick
431 } catch (err) {
432 $.ui.log(`interlock meter: no interval for the quiet figure; the pane computes it when drawn: ${messageOf(err)}`, {
433 to: 'debug'
434 })
435 }
436}
437
438/** Ends the run's interval, if it has one: a cancelled timer never fires again. */
439function stopTick() {
440 if (run.tick) run.tick.cancel()
441 run.tick = null
442 run.quietWord = null
443}
444
445/**
446 * A settings guard's refusal, read off the result the engine handed a tool hook
447 * (design D4): the text from the guard's name on is toasted once per distinct
448 * text per run and counted by guard. A result that is not errored, or names no
449 * guard, is not this module's to speak: nothing is drawn and nothing logged.
450 */
451function speakRefusal($, r) {
452 try {
453 const reason = r && r.isError === true ? guardReason(r.text) : null
454 if (!reason) return
455 const guard = guardOf(reason)
456 run.guardDenials.set(guard, (run.guardDenials.get(guard) || 0) + 1)
457 if (!run.toasted.has(reason)) {
458 run.toasted.add(reason)
459 $.ui.toast(reason)
460 }
461 $.ui.invalidate('ui.render')
462 } catch (err) {
463 $.ui.log(`interlock meter: a guard's refusal was not drawn: ${messageOf(err)}`, { to: 'debug' })
464 }
465}
466
467/**
468 * An edit during a live ship run, its result read for a guard's refusal
469 * (guard-tests, guard-tasks); during a live spec run, its path filed as a
470 * write when the engine did not mark it errored (observe-the-spec-run-live
471 * design D4). Returned as it came.
472 */
473async function editCall($, e, next) {
474 const ship = watching()
475 if (!ship && !specWatching()) return next(e)
476 const held = run
477 const heldSpec = spec
478 const r = await next(e)
479 if (ship && held === run) speakRefusal($, r)
480 if (heldSpec === spec && specWatching() && !(r && r.isError === true)) await fileWrite($, e.file_path)
481 return r
482}
483
484/**
485 * The engine's time now for the spec record, or `null` when the clock cannot
486 * be read: the ship meter's rule (design D6), named once per spec run.
487 */
488async function specNow($) {
489 const held = spec
490 try {
491 const now = await $.clock.now()
492 if (Number.isFinite(now)) return now
493 throw new Error(`the clock answered ${String(now)}`)
494 } catch (err) {
495 if (!held.clockFailed) {
496 held.clockFailed = true
497 $.ui.log(`interlock spec: the clock cannot be read; the last activity time stands: ${messageOf(err)}`, { to: 'debug' })
498 }
499 return null
500 }
501}
502
503/** Activity on the spec record: its last activity is now, when the clock answers. */
504async function specStamp($) {
505 const held = spec
506 const now = await specNow($)
507 if (held === spec && now !== null) spec.lastActivityAt = now
508 return now
509}
510
511/** Redraws what the spec run shows: the status line through the composer, and the panes. */
512function redrawSpec($) {
513 setStatus($)
514 $.ui.invalidate('ui.render')
515}
516
517/**
518 * The spec skill's load (design D1): `spec` starts a run over whatever was
519 * held, `explore` and `review-artifacts` set the stage of a live one, and
520 * anything else is not this meter's. Never throws.
521 */
522async function observeLoad($, name) {
523 try {
524 const role = skillRole(name)
525 if (role === 'spec') await startSpec($)
526 else if (role && specWatching()) {
527 spec.stage = { kind: role }
528 await specStamp($)
529 redrawSpec($)
530 }
531 } catch (err) {
532 $.ui.log(`interlock spec: a skill load was not drawn: ${messageOf(err)}`, { to: 'debug' })
533 }
534}
535
536/** A fresh spec run, live, its pane opened unasked; one toast when the engine will not seat it. */
537async function startSpec($) {
538 if (spec.phase !== 'idle') $.ui.log(SPEC_REPLACED_LINE, { to: 'debug' })
539 spec = freshSpec()
540 const started = spec
541 spec.phase = 'live'
542 spec.startedAt = await specStamp($)
543 if (started !== spec) return
544 redrawSpec($)
545 const placed = await openPane($, SPEC_PANE, SPEC_TITLE)
546 if (!placed || placed.isPlaced !== true) $.ui.toast(SPEC_UNPLACED_TOAST)
547}
548
549/**
550 * The spec record that will read a keyed command, or `null` (design D3). With
551 * no live spec run the first keyed line after the checkpoint or a session
552 * boundary is named once on the debug log, and none is read.
553 */
554function specReader($, lines) {
555 if (specWatching()) return spec
556 const keyed = lines.find(l => l.kind !== 'naming-only')
557 if (keyed && !spec.lateNamed && (spec.phase === 'checkpoint' || spec.afterBoundary)) {
558 spec.lateNamed = true
559 $.ui.log(lateLineText(keyed.kind, spec.phase), { to: 'debug' })
560 }
561 return null
562}
563
564/** The results kept for a change, `null` being the unnamed slot the pane never draws (design D2). */
565function changeSlot(name) {
566 if (!spec.changes.has(name)) spec.changes.set(name, {})
567 return spec.changes.get(name)
568}
569
570/**
571 * Files one keyed segment's entry (design D2, D3, D7): the change it names
572 * becomes the one drawn, its result is kept under that change (or the current
573 * one), and the stage is the entry. A checkpoint ends the run at its checkpoint.
574 */
575function fileEntry(entry, now) {
576 if (entry.name) {
577 spec.current = entry.name
578 if (!spec.order.includes(entry.name)) spec.order.push(entry.name)
579 }
580 if (entry.kind === 'naming-only') return
581 const slot = changeSlot(entry.name || spec.current)
582 if (entry.kind === 'drift') spec.drift = entry
583 else if (entry.kind === 'autonomy') spec.autonomy = entry.command
584 else if (TEXT_KINDS.includes(entry.kind)) slot[entry.kind] = entry
585 if (entry.kind === 'checkpoint') {
586 spec.phase = 'checkpoint'
587 spec.checkpointCrossed = true
588 spec.checkpointAt = now
589 }
590 spec.stage = entry
591}
592
593/**
594 * Reads a command's keyed segments into the spec record (design D3). Every
595 * entry is read before any is filed, so a reader that throws leaves the record
596 * as it was. Two segments whose results are read share one output, so each is
597 * kept unparsed. An unparsed result leaves one debug line. Never throws.
598 */
599async function applySpecLines($, held, lines, r) {
600 try {
601 const texts = lines.filter(l => TEXT_KINDS.includes(l.kind)).length
602 const text = r && typeof r.text === 'string' ? r.text : ''
603 const entries = lines.map(({ kind, segment }) => {
604 if (kind === 'naming-only') return { entry: { kind, name: changeOf(kind, segment, null) }, segment }
605 if (texts > 1 && TEXT_KINDS.includes(kind)) {
606 return { entry: { ...unparsedEntry(kind, text, 'compound command'), name: changeOf(kind, segment, null) }, segment }
607 }
608 return { entry: readResult(kind, segment, r), segment }
609 })
610 const now = await specNow($)
611 if (held !== spec || !specWatching()) return
612 for (const { entry, segment } of entries) {
613 if (!entry) continue
614 fileEntry(entry, now)
615 if (typeof entry.unparsed === 'string') $.ui.log(unreadLineText(entry, segment), { to: 'debug' })
616 }
617 if (now !== null) spec.lastActivityAt = now
618 redrawSpec($)
619 } catch (err) {
620 $.ui.log(`interlock spec: a keyed line was not read: ${messageOf(err)}`, { to: 'debug' })
621 }
622}
623
624/** A write during a live spec run, filed by its path (design D4). A path never names the change. Never throws. */
625async function fileWrite($, path) {
626 try {
627 const w = writeOf(path)
628 if (!w) return
629 const held = spec
630 const now = await specNow($)
631 if (held !== spec) return
632 if (w.kind === 'artifact') {
633 if (!spec.writes.has(w.change)) spec.writes.set(w.change, new Map())
634 const files = spec.writes.get(w.change)
635 const before = files.get(w.file)
636 files.set(w.file, { count: (before ? before.count : 0) + 1, at: now === null && before ? before.at : now })
637 } else if (w.kind === 'brief') spec.explore.brief = w.path
638 else if (w.kind === 'findings') spec.findings.set(w.change, w.path)
639 if (now !== null) spec.lastActivityAt = now
640 $.ui.invalidate('ui.render')
641 } catch (err) {
642 $.ui.log(`interlock spec: a write was not filed: ${messageOf(err)}`, { to: 'debug' })
643 }
644}
645
646/** What an `agent.spawn` chain returned, read for the meter and handed back unchanged. */
647async function noteSpawn($, input, spawned) {
648 if (session.interactive && run.phase === 'live') {
649 if (spawned && str(spawned.agentId) && input && typeof input.prompt === 'string') {
650 const found = [...input.prompt.matchAll(BRIEFING_SHA)]
651 if (found.length) agentOf(spawned.agentId).sha = found[found.length - 1][1]
652 }
653 if (await stamp($)) redrawWord($)
654 }
655 // An Explore investigator the chain let through (observe-the-spec-run-live design D5).
656 if (specWatching() && input && input.subagentType === 'Explore' && spawned && typeof spawned.model === 'string') {
657 await countInvestigator($)
658 }
659 return spawned
660}
661
662/** Whether `text` names `path` whole: behind its start or a separator, and not followed by more of a file name. */
663function namesPath(text, path) {
664 for (let at = text.indexOf(path); at !== -1; at = text.indexOf(path, at + 1)) {
665 const before = at === 0 ? '' : text[at - 1]
666 const after = text[at + path.length] ?? ''
667 if ((before === '' || PATH_BEFORE.test(before)) && (after === '' || !PATH_AFTER.test(after))) return true
668 }
669 return false
670}
671
672/**
673 * The Workflow host's join: it raises no
674 * `agent.spawn` for a Workflow agent, but every run agent's first act is to
675 * read its own briefing. A Read path or a Bash command that names a
676 * dispatched spawn's briefing path joins the agent to that spawn's row,
677 * through the same briefing hash the spawn event joins on. The first briefing
678 * an agent reads wins; an agent that names none is left as it was, and no
679 * agent record is made for it. Never throws.
680 */
681function noteBriefing($, agentId, text) {
682 try {
683 const id = str(agentId)
684 if (!id || typeof text !== 'string') return
685 const known = run.agents.get(id)
686 if (known && known.sha) return
687 const said = text.split('\\').join('/')
688 for (const r of run.rows.values()) {
689 if (!r.sha || !r.promptPath) continue
690 if (!namesPath(said, r.promptPath.split('\\').join('/'))) continue
691 agentOf(id).sha = r.sha
692 $.ui.invalidate('ui.render')
693 return
694 }
695 } catch (err) {
696 $.ui.log(`interlock meter: a briefing read was not joined to its lane: ${messageOf(err)}`, { to: 'debug' })
697 }
698}
699
700/**
701 * `run next` for `interlock run next --results … --json`: the subcommand a
702 * driver line relays, as `workflows/ship.js` names its relay (`argv.slice(0, 2)`),
703 * the second word dropped when it is a flag or a shell operator.
704 */
705function relayOf(command) {
706 const words = command.trim().split(/\s+/).slice(1, 3)
707 if (!words.length || !words[0]) return null
708 const second = words[1]
709 return second && !/^(--|[;&|<>])/.test(second) ? `${words[0]} ${second}` : words[0]
710}
711
712/** An agent that ran a driver line before any briefing named it is a relay, shown by what it relayed. Never throws. */
713function noteRelay($, agentId, command) {
714 try {
715 const id = str(agentId)
716 if (!id) return
717 const known = run.agents.get(id)
718 if (known && known.sha) return
719 const relay = relayOf(command)
720 if (!relay || (known && known.relay === relay)) return
721 agentOf(id).relay = relay
722 $.ui.invalidate('ui.render')
723 } catch (err) {
724 $.ui.log(`interlock meter: a driver line was not read for its agent: ${messageOf(err)}`, { to: 'debug' })
725 }
726}
727
728/**
729 * A clear, resume or fork ends the run the meter holds, before the chain
730 * beneath runs. Any other source, and a non-interactive session, changes
731 * nothing. Never throws.
732 */
733function endRunAtBoundary($, input) {
734 if (!session.interactive) return
735 try {
736 const boundary = BOUNDARIES.get(input.source)
737 if (!boundary) return
738 const ended = run
739 stopTick()
740 run = freshRun()
741 start = freshStart()
742 spec = freshSpec({ afterBoundary: true })
743 setStatus($)
744 $.ui.invalidate('ui.render')
745 const what = ended.phase === 'idle' ? 'no run was held' : `run ${ended.runId || '(no id)'} is no longer drawn`
746 $.ui.log(`interlock meter: ${boundary} started the session over; ${what}`, { to: 'debug' })
747 } catch (err) {
748 $.ui.log(`interlock meter: the session boundary was not applied: ${messageOf(err)}`, { to: 'debug' })
749 }
750}
751
752/** Once the chain beneath has run: the preflight's report, read for every source. */
753async function readPreflightAfterStart($, input) {
754 if (session.interactive) await readPreflight($, str(input.cwd) || session.cwd)
755}
756
757/** One Explore investigator the chain let through (design D5). Never throws. */
758async function countInvestigator($) {
759 try {
760 spec.explore.spawned++
761 await specStamp($)
762 redrawSpec($)
763 } catch (err) {
764 $.ui.log(`interlock spec: an investigator was not counted: ${messageOf(err)}`, { to: 'debug' })
765 }
766}
767
768/** `/interlock-spec` (design D6): the pure module's keyed lines on `Box` and `Text`, and nothing else. */
769function drawSpecPane($, e) {
770 const { Box, Text } = $.ui.resolve(e)
771 const props = l => (l.bold ? { bold: true } : l.dim ? { dimColor: true } : null)
772 return h(Box, { flexDirection: 'column' }, ...specPaneLines(spec).map(l => h(Box, { key: l.key }, h(Text, props(l), l.text))))
773}
774
775/**
776 * The launch guard's ruling now, over the record its branch would read, in the
777 * rule's words (design D5), and whether that ruling refuses the next launch.
778 */
779async function launchGuardText($) {
780 const { record, now } = await guardFacts($)
781 const ruling = decideLaunch(record, now)
782 return { text: launchGuardLine(ruling, now), refused: now !== null && ruling.decision !== 'allow' && Boolean(ruling.reason) }
783}
784
785/** Names, once per run record, a step that crossed with no live run (design D4). */
786function nameIdleStep($) {
787 run.idleStepNamed = true
788 $.ui.log(
789 'interlock meter: a step crossed with no live run and is not drawn ' +
790 "(a run launched before a /clear, /resume or /branch, or before this module reloaded, is no longer this session's)",
791 { to: 'debug' }
792 )
793}
794
795/** A driver-line call with no live run: passed on whole, its result read only to name the first step (design D4). */
796async function idleStep($, e, next) {
797 if (run.phase !== 'idle' || run.idleStepNamed) return next(e)
798 const r = await next(e)
799 noteIdleStep($, r)
800 return r
801}
802
803/** Names the first step record a result carries while no run is live; reads nothing else. */
804function noteIdleStep($, r) {
805 try {
806 if (run.phase === 'idle' && !run.idleStepNamed && readRecord(r.text).record) nameIdleStep($)
807 } catch {
808 // Reading the result is all this does; the call and its result are the engine's.
809 }
810}
811
812/** The step record in a Bash result's text, guarded as the driver guards it: an object with a string `action`. */
813function readRecord(text) {
814 if (typeof text !== 'string' || !text.trim()) return { problem: 'no result text' }
815 let value
816 try {
817 value = JSON.parse(text)
818 } catch {
819 return { problem: 'not JSON' }
820 }
821 if (!value || typeof value !== 'object' || Array.isArray(value)) return { problem: 'not an object' }
822 if (typeof value.action !== 'string') return { problem: 'no action' }
823 return { record: value }
824}
825
826function agentOf(id) {
827 let a = run.agents.get(id)
828 if (!a) {
829 a = {
830 id,
831 requests: 0,
832 known: 0,
833 partial: false,
834 sums: null,
835 models: [],
836 reason: null,
837 durationMs: null,
838 sha: null,
839 // The two words after `interlock` in the first driver line it ran while no row named it.
840 relay: null,
841 // Whether its turn end has been spoken: once per agent per run (speak-lane-turn-ends design D1).
842 endToasted: false,
843 // The reading at its first request, taken once (null when the clock failed then), and at its turn end.
844 stepped: false,
845 firstAt: null,
846 endAt: null
847 }
848 run.agents.set(id, a)
849 }
850 return a
851}
852
853/** The agent's first request, timed once by the stamp just taken: a later reading never moves it. */
854function firstRequest(a) {
855 if (a.stepped) return
856 a.stepped = true
857 a.firstAt = run.stampedAt
858}
859
860/** Field by field: a field one request omitted leaves that figure unknown (`null`), never a guessed zero (design D5). */
861function addUsage(a, usage) {
862 a.requests++
863 if (!usage || typeof usage !== 'object') {
864 a.partial = true
865 return
866 }
867 a.known++
868 if (!a.sums) a.sums = Object.fromEntries(TOKEN_FIELDS.map(f => [f, 0]))
869 for (const f of TOKEN_FIELDS) {
870 a.sums[f] = a.sums[f] === null || typeof usage[f] !== 'number' ? null : a.sums[f] + usage[f]
871 }
872 if (str(usage.model) && !a.models.includes(usage.model)) a.models.push(usage.model)
873}
874
875/** What a malformed field is, for its debug line: never its content. */
876const shapeOf = value => (value === null ? 'null' : Array.isArray(value) ? 'an array' : `a ${typeof value}`)
877
878/** A plan summary as the run program states it: an object with an array of waves. */
879const isPlanSummary = value => value !== null && typeof value === 'object' && Array.isArray(value.waves)
880
881/** A `recorded` block as the run program states it: three lists of ids. */
882const isRecordedIds = value =>
883 value !== null &&
884 typeof value === 'object' &&
885 ['ok', 'failed', 'notAttempted'].every(list => Array.isArray(value[list]))
886
887/** The summary's wave at a position, as a failure record names it: its group and its kind. */
888function waveAt(plan, index) {
889 if (!plan || !isInt(index)) return { wave: null, waveKind: null }
890 const wave = plan.waves[index]
891 if (wave && typeof wave === 'object') return { wave: wave.group ?? null, waveKind: str(wave.kind) }
892 return plan.testWave && index === plan.waves.length ? { wave: null, waveKind: 'test' } : { wave: null, waveKind: null }
893}
894
895/**
896 * What the wave board keeps from a step (draw-the-wave-board-in-the-meter-pane
897 * design D6). A `plan` or `recorded` without the shape the run program states
898 * is named once per run on the debug log and ignored; the rest of the step
899 * applies. The recorded ids are taken before the step's own position, because
900 * the step record-batch returns already names the next batch.
901 */
902function takeBoard($, record) {
903 if (record.plan !== undefined) {
904 if (isPlanSummary(record.plan)) run.plan = record.plan
905 else if (!run.planNamed) {
906 run.planNamed = true
907 $.ui.log(`interlock meter: a step's plan is ${shapeOf(record.plan)}, not a plan summary; ignored, the board keeps what it held`, {
908 to: 'debug'
909 })
910 }
911 }
912 if (record.recorded !== undefined) {
913 if (isRecordedIds(record.recorded)) {
914 for (const list of ['ok', 'failed', 'notAttempted']) {
915 for (const id of record.recorded[list]) {
916 if (!str(id) || run.recorded[list].includes(id)) continue
917 run.recorded[list].push(id)
918 if (list === 'failed') run.failedAt.set(id, waveAt(run.plan, run.dispatched && run.dispatched.waveIndex))
919 }
920 }
921 } else if (!run.recordedNamed) {
922 run.recordedNamed = true
923 $.ui.log(
924 `interlock meter: a step's recorded is ${shapeOf(record.recorded)}, not three id lists; ignored, the board keeps the ids it held`,
925 { to: 'debug' }
926 )
927 }
928 }
929 if (isInt(record.waveIndex)) {
930 const at = { waveIndex: record.waveIndex, batchIndex: isInt(record.batchIndex) ? record.batchIndex : null }
931 if (BATCH_ACTIONS.has(record.action)) {
932 run.cursor = { ...at, phase: 'batch' }
933 run.dispatched = at
934 } else if (record.action === 'verify') {
935 run.cursor = { ...at, phase: 'verify' }
936 const reason = str(record.reason)
937 const seen = run.skips.some(skip => skip.waveIndex === at.waveIndex && skip.reason === reason)
938 if (record.skipped === true && !seen) run.skips.push({ waveIndex: at.waveIndex, wave: record.wave ?? null, reason })
939 }
940 }
941 // The close record repeats the action without the reason; the halt step's stands.
942 if (record.action === 'halt') run.halt = { reason: str(record.reason) || (run.halt ? run.halt.reason : null) }
943}
944
945/**
946 * One step as the timeline draws it, its fields the record's own: the time the
947 * stamp read, its position, a skip or halt reason, the size of a plan summary
948 * it carried, the agent whose Bash call printed it, and the spawns it named.
949 */
950function noteStep(record, relayId, spawns) {
951 const step = {
952 n: run.steps.length,
953 at: run.stampedAt,
954 action: record.action,
955 wave: record.wave,
956 waveIndex: record.waveIndex,
957 batchIndex: record.batchIndex,
958 batchCount: record.batchCount,
959 skipped: record.skipped,
960 reason: record.reason,
961 planWaves: isPlanSummary(record.plan) ? planWaveCount(record.plan) : null,
962 relayId: str(relayId),
963 spawns
964 }
965 run.steps.push(step)
966 if (BATCH_ACTIONS.has(record.action) && isInt(record.waveIndex) && isInt(record.batchIndex)) {
967 const at = `${record.waveIndex}:${record.batchIndex}`
968 if (!run.batchAt.has(at)) run.batchAt.set(at, step.at)
969 }
970}
971
972async function applyRecord($, record, relayId) {
973 const held = run
974 await stamp($)
975 if (held !== run) return
976 const isClose = record.then === null && typeof record.summary === 'string' && typeof record.exitCode === 'number'
977 if (str(record.change)) run.change = record.change
978 run.action = record.action
979 if (record.wave !== undefined) run.wave = record.wave
980 run.batchIndex = record.batchIndex
981 run.batchCount = record.batchCount
982 const spawns = []
983 for (const s of Array.isArray(record.spawns) ? record.spawns : []) {
984 if (!s || typeof s !== 'object' || !str(s.label)) continue
985 spawns.push({ label: s.label, title: str(s.title) || s.label, sha: str(s.promptSha256) })
986 run.rows.set(s.label, {
987 label: s.label,
988 title: str(s.title) || s.label,
989 kind: str(s.kind),
990 model: str(s.model),
991 effort: str(s.effort),
992 sha: str(s.promptSha256),
993 // The briefing the spawn names, read off the agent's own first read of it.
994 promptPath: str(s.promptPath),
995 // Spawned by a step that dispatches a batch: a lane, planned or not.
996 batch: BATCH_ACTIONS.has(record.action)
997 })
998 }
999 noteStep(record, relayId, spawns)
1000 takeBoard($, record)
1001 for (const banner of Array.isArray(record.banners) ? record.banners : []) {
1002 if (typeof banner !== 'string' || !banner) continue
1003 if (!run.banners.includes(banner)) run.banners.push(banner)
1004 if (!run.toasted.has(banner)) {
1005 run.toasted.add(banner)
1006 $.ui.toast(banner)
1007 }
1008 }
1009 if (typeof record.summary === 'string' && (isClose || record.action === 'close')) run.summary = record.summary
1010 if (isClose) {
1011 run.exitCode = record.exitCode
1012 run.phase = 'closed'
1013 stopTick()
1014 } else if (record.action === 'close' || record.action === 'halt') {
1015 run.phase = 'closing'
1016 stopTick()
1017 }
1018 setStatus($)
1019 $.ui.invalidate('ui.render')
1020}
1021
1022async function openPane($, id = PANE, title = TITLE) {
1023 return $.ui.open({ id, title, closeOnEscape: true })
1024}
1025
1026/**
1027 * Reads the session start's report (design D5) and holds what `adoptPreflight`
1028 * keeps: the same report changes nothing, a new one is drawn, and a file that
1029 * cannot be read or is not a report is named once on the debug log. Never
1030 * throws: a read that rejects is the event's to carry on past.
1031 */
1032async function readPreflight($, cwd) {
1033 const path = preflightFilePath(cwd)
1034 let outcome
1035 try {
1036 outcome = readPreflightReport(await $.fs.read(path))
1037 } catch (err) {
1038 outcome = { problem: messageOf(err) }
1039 }
1040 try {
1041 const { start: next, changed, log } = adoptPreflight(start, outcome, path)
1042 start = next
1043 if (log) $.ui.log(log, { to: 'debug' })
1044 if (changed) $.ui.invalidate('ui.render')
1045 } catch (err) {
1046 $.ui.log(`interlock meter: the preflight report was not held: ${messageOf(err)}`, { to: 'debug' })
1047 }
1048}
1049
1050/** One keyed row per line, so the kit and the person both find each line on its own. */
1051function rowsOf($, e, prefix, lines) {
1052 const { Box, Text } = $.ui.resolve(e)
1053 return lines.map((line, n) => h(Box, { key: `${prefix}-${n}` }, h(Text, null, line)))
1054}
1055
1056/**
1057 * The band (design D6): the report's lines above whatever the other plugins
1058 * drew, and a Hide control that collapses it until a new session start's
1059 * report. It yields to a survey, and draws nothing of its own with no report,
1060 * a hidden band, or a report with nothing to say.
1061 */
1062async function drawBand($, e, next) {
1063 if (!session.interactive || (e.props && e.props.hasSurvey) || start.hidden || !start.report) return next(e)
1064 const lines = bandLines(start.report)
1065 if (!lines.length) return next(e)
1066 const beneath = await next(e)
1067 try {
1068 const { Box, Button } = $.ui.resolve(e)
1069 const hide = () => {
1070 start.hidden = true
1071 $.ui.invalidate('ui.render')
1072 }
1073 return h(
1074 Box,
1075 { flexDirection: 'column' },
1076 ...rowsOf($, e, 'preflight', lines),
1077 h(Button, { key: 'hide', label: 'Hide', onPress: hide }),
1078 ...(beneath ? [beneath] : [])
1079 )
1080 } catch (err) {
1081 $.ui.log(`interlock meter: the session-start band was not drawn: ${messageOf(err)}`, { to: 'debug' })
1082 return beneath
1083 }
1084}
1085
1086/** `/interlock-preflight`: the report's every line, or why there is none (design D6). */
1087function drawPreflightPane($, e) {
1088 const { Box } = $.ui.resolve(e)
1089 const lines = start.report
1090 ? preflightPaneLines(start.report)
1091 : [noReportText(start.path || preflightFilePath(session.cwd), start.problem)]
1092 return h(Box, { flexDirection: 'column' }, ...rowsOf($, e, 'preflight-pane', lines))
1093}
1094
1095/**
1096 * `/interlock-handoff` (design D6): each card the report lists, newest first,
1097 * read from its path now and rendered verbatim through `Markdown`, cut to the
1098 * published cap. A card that cannot be read is named with the reason, and the
1099 * next is drawn as before. Nothing here acts on a card.
1100 */
1101async function drawHandoffPane($, e) {
1102 const { Box, Text, Markdown } = $.ui.resolve(e)
1103 const column = rows => h(Box, { flexDirection: 'column' }, ...rows)
1104 if (!start.report) {
1105 return column(rowsOf($, e, 'handoff', [noReportText(start.path || preflightFilePath(session.cwd), start.problem)]))
1106 }
1107 const cards = orderCards(start.report)
1108 if (!cards.length) return column(rowsOf($, e, 'handoff', noCardText(start.report)))
1109 const out = []
1110 for (const [n, card] of cards.entries()) {
1111 out.push(h(Box, { key: `card-${n}` }, h(Text, null, cardLine(card))))
1112 try {
1113 const text = await $.fs.read(card.path)
1114 out.push(h(Box, { key: `card-${n}-body` }, h(Markdown, { text: clampCard(text, card.path).text })))
1115 } catch (err) {
1116 out.push(h(Box, { key: `card-${n}-unread` }, h(Text, null, `${card.path}: cannot be read: ${messageOf(err)}`)))
1117 }
1118 }
1119 return column(out)
1120}
1121
1122function tokensText(a) {
1123 if (a.known === 0) return a.requests ? 'tokens unknown (partial)' : 'no requests yet'
1124 const f = n => (n === null ? '?' : String(n))
1125 const s = a.sums
1126 return (
1127 `in ${f(s.input_tokens)} · out ${f(s.output_tokens)} · cache read ${f(s.cache_read_input_tokens)} · ` +
1128 `cache write ${f(s.cache_creation_input_tokens)}${a.partial ? ' (partial)' : ''}`
1129 )
1130}
1131
1132function agentForSha(sha) {
1133 if (!sha) return null
1134 for (const a of run.agents.values()) if (a.sha === sha) return a
1135 return null
1136}
1137
1138function rowForAgent(a) {
1139 if (!a.sha) return null
1140 for (const r of run.rows.values()) if (r.sha === a.sha) return r
1141 return null
1142}
1143
1144/**
1145 * What an agent is called: its joined spawn's title, else `cli · <what it
1146 * relayed>` for a driver-line agent, else `null`, which reads `unmatched agent`.
1147 */
1148function agentName(a) {
1149 const row = rowForAgent(a)
1150 if (row) return row.title
1151 return a.relay ? `cli · ${a.relay}` : null
1152}
1153
1154/**
1155 * The session lines (speak-lane-turn-ends-and-session-cost design D4, D6): the
1156 * engine's own figures as it answered them, each said to be unreported where
1157 * it is absent. Nothing here is computed: no percent from the tokens, no price.
1158 */
1159function sessionLines(usage, failure) {
1160 if (failure !== null) return [`context unavailable: ${failure}`, `session cost unavailable: ${failure}`]
1161 const num = v => (typeof v === 'number' && Number.isFinite(v) ? v : null)
1162 const context = usage.context && typeof usage.context === 'object' ? usage.context : {}
1163 const [tokens, size, percent] = [num(context.tokens), num(context.window), num(context.percent)]
1164 let fill = 'context not reported'
1165 if (size !== null && tokens === null) fill = `context: window ${size} tokens · fill not yet reported`
1166 else if (size !== null) fill = `context ${tokens} of ${size} tokens${percent !== null ? ` · ${percent}% used` : ''}`
1167 const usd = usage.cost && typeof usage.cost === 'object' ? num(usage.cost.usd) : null
1168 const cost =
1169 usd === null
1170 ? 'session cost not reported by this host'
1171 : `session cost $${usd.toFixed(4)} · the engine's total for this session, not the run's`
1172 return [fill, cost]
1173}
1174
1175/**
1176 * `started <local date and time> · last activity <local time>` (the start left
1177 * out when the launch read no time, `last activity unknown` when no stamp did),
1178 * then the quiet word while a live run is quiet, as the line and the word
1179 * apart. The word is recomputed from the clock at each draw, so a pane opened
1180 * after the host stopped the interval still shows it (design D2, D5).
1181 */
1182async function lastActivityText($) {
1183 const started = run.launchedAt === null ? '' : `started ${stampText(run.launchedAt)} · `
1184 if (run.lastActivityAt === null) return { line: `${started}last activity unknown`, word: null }
1185 const word = run.phase === 'live' ? quietWord(quietMs(run.lastActivityAt, await clockNow($))) : null
1186 return { line: `${started}last activity ${clockText(run.lastActivityAt)}`, word }
1187}
1188
1189/** An agent id as a line shows it: its first eight characters. */
1190const id8 = id => String(id).slice(0, 8)
1191
1192/** `1 request`, `N requests`. */
1193const requestsText = a => (a.requests === 1 ? '1 request' : `${a.requests} requests`)
1194
1195/** An agent's turn: `running`, else the host's word and, once reported, how long the turn took. */
1196const turnText = a =>
1197 a.reason ? `${a.reason}${a.durationMs !== null ? ` ${durationText(a.durationMs)}` : ''}` : 'running'
1198
1199/** Whether the host ended the agent's turn without an answer. */
1200const abnormal = a => Boolean(a.reason) && a.reason !== 'answer'lib/draw-plan.mjs 516 lines1// The wave board and the Mermaid plan (draw-wave-plan-and-handoff-graph-from-the-cli,
2// spec ship/diagrams): a plan drawn as lines, with an optional overlay from the
3// wave state.
4//
5// Every word on the board is one field of one record. Waves are keyed by
6// position, never by group: dependency layering emits a later layer under the
7// same section number, so a board keyed by group would fold two waves into one.
8// Lanes are named by `lib/lane.mjs`, the rule their spawn titles, briefing files
9// and trajectory lines are named by. Where the state does not force an
10// attribution — a skip recorded by group over two waves of that group, a
11// failure id no lane carries — the board says so rather than guess.
12//
13// Pure and Node-free: no file, no environment, no terminal, no clock. The CLI
14// reads the plan and the state and resolves the width; the ship meter's hooks
15// module can import this file because its closure is `lib/lane.mjs` and
16// `lib/limits.mjs` only (`test/spine/draw-plan.test.mjs` walks it). The widths
17// are `interlock limits`'s.
18
19import { TITLE_SEPARATOR, fitRow, laneEffort, laneGist, laneLabel, laneModel, laneTier, laneTitle } from './lane.mjs'
20import { LIMITS } from './limits.mjs'
21
22/** The words a lane's state cell may read, each the state's own record. */
23export const STATE_WORDS = Object.freeze(['ok', 'failed', 'current', 'pending', 'not reached', 'not recorded'])
24
25const [OK, FAILED, CURRENT, PENDING, NOT_REACHED, NOT_RECORDED] = STATE_WORDS
26const PER_TASK = 'per task'
27const TAIL = 'then: verify-final → commit → close'
28
29// Cell widths, so a board's columns align: the longest model name, the longest
30// effort word (`inherit`) and the longest state word (`not recorded`).
31const MODEL_CELL = 6
32const EFFORT_CELL = 7
33const STATE_CELL = 12
34
35const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
36const list = value => (Array.isArray(value) ? value : [])
37const isText = value => typeof value === 'string' && value !== ''
38const plural = (n, word) => `${n} ${word}${n === 1 ? '' : /(ch|sh|s|x)$/.test(word) ? 'es' : 's'}`
39
40/** A width the board draws at: a positive integer, else the published default. */
41function boardWidth(columns) {
42 return Number.isInteger(columns) && columns > 0 ? columns : LIMITS.waveBoardDefaultColumns
43}
44
45/** A lane as drawn: an array of task objects, never empty. */
46function lanesOfBatch(batch) {
47 return list(batch)
48 .map(lane => list(lane).filter(isObject))
49 .filter(lane => lane.length > 0)
50}
51
52/**
53 * The plan's positions: every wave holding a batch, in plan order, then the
54 * test wave — the waves `createRunState` keeps, so position `i` here is
55 * `state.waves[i]` there.
56 */
57function positionsOf(plan) {
58 if (!isObject(plan)) return []
59 const waves = list(plan.waves).filter(isObject).map(wave => ({ wave, test: false }))
60 if (isObject(plan.testWave)) waves.push({ wave: plan.testWave, test: true })
61 return waves
62 .map(({ wave, test }) => ({
63 test,
64 group: wave.group ?? null,
65 // A plan written before waves carried a kind is an implementation plan.
66 kind: test ? 'test' : wave.kind === 'test' ? 'test' : 'impl',
67 red: wave.red === true,
68 batches: list(wave.batches).map(lanesOfBatch).filter(batch => batch.length > 0)
69 }))
70 .filter(position => position.batches.length > 0)
71 .map((position, index) => ({ ...position, index }))
72}
73
74/** The text a wave's top rule carries. */
75function waveTitle(position) {
76 const batches = plural(position.batches.length, 'batch')
77 if (position.test) return `test wave · idx ${position.index} · test · ${batches}`
78 const group = position.group === null ? '?' : position.group
79 return (
80 `wave ${position.index + 1} · idx ${position.index} · group ${group} · ${position.kind} · ${batches}` +
81 (position.red ? ' · RED' : '')
82 )
83}
84
85/**
86 * The ids a lane was ordered after: the union of the `after` ids of the plan's
87 * own deferral records for the lane's tasks, minus the lane's own ids, in
88 * record order. Never recomputed from a task's `dependsOn`.
89 */
90function orderedAfter(lane, deferred) {
91 const own = new Set(lane.map(task => task.id))
92 const out = []
93 for (const record of deferred) {
94 if (!own.has(record.id)) continue
95 for (const id of list(record.after)) if (isText(id) && !own.has(id) && !out.includes(id)) out.push(id)
96 }
97 return out
98}
99
100/** A skip that carries the wave position its verify step ran at. */
101const isPositioned = skip => Number.isInteger(skip.waveIndex) && skip.waveIndex >= 0
102
103/** What the overlay can say, read once from the state. */
104function overlayOf(state, positions) {
105 if (!isObject(state)) return null
106 const stateWaves = list(state.waves)
107 const cursor = isObject(state.cursor) ? state.cursor : {}
108 const halt = isObject(state.halt) ? state.halt : null
109 return {
110 runId: isText(state.runId) ? state.runId : null,
111 waveCount: stateWaves.length,
112 // Applied by position only when the state walks the plan's own positions.
113 positional: stateWaves.length === positions.length,
114 completed: new Set(list(state.completed).filter(isText)),
115 failures: list(state.failures).filter(f => isObject(f) && isText(f.id)),
116 skips: list(state.skippedVerifications).filter(isObject),
117 positionedSkips: list(state.skippedVerifications)
118 .filter(skip => isObject(skip) && isPositioned(skip))
119 .map(skip => ({ index: skip.waveIndex, reason: isText(skip.reason) ? skip.reason : 'no reason recorded' })),
120 unresolved: list(state.unresolved).filter(isObject),
121 cursor: {
122 wave: Number.isInteger(cursor.waveIndex) ? cursor.waveIndex : null,
123 batch: Number.isInteger(cursor.batchIndex) ? cursor.batchIndex : null,
124 phase: typeof cursor.phase === 'string' ? cursor.phase : null
125 },
126 halted: halt !== null,
127 haltReason: halt && isText(halt.reason) ? halt.reason : null
128 }
129}
130
131/** The word the state records for one task at `position`, `batch`; '' for none. */
132function taskWord(overlay, id, position, batch) {
133 if (overlay.failures.some(f => f.id === id)) return FAILED
134 if (overlay.completed.has(id)) return OK
135 const { cursor } = overlay
136 if (!overlay.positional || cursor.wave === null) return ''
137 const inBatch = cursor.phase === 'batch'
138 if (position < cursor.wave || (position === cursor.wave && (!inBatch || (cursor.batch !== null && batch < cursor.batch)))) {
139 return NOT_RECORDED
140 }
141 if (position === cursor.wave && inBatch && batch === cursor.batch) return CURRENT
142 return overlay.halted ? NOT_REACHED : PENDING
143}
144
145/** A lane's state cell and ids cell: one word when its tasks agree, else per task. */
146function laneCells(overlay, lane, position, batch) {
147 const ids = lane.map(task => task.id)
148 if (!overlay) return { word: null, ids: ids.length > 1 ? ids.join(', ') : null, tasks: null }
149 const words = lane.map(task => taskWord(overlay, task.id, position, batch))
150 const tasks = ids.map((id, i) => ({ id, word: words[i] }))
151 if (words.every(word => word === words[0])) {
152 return { word: words[0], ids: ids.length > 1 ? ids.join(', ') : null, tasks }
153 }
154 return { word: PER_TASK, ids: ids.map((id, i) => (words[i] ? `${id} ${words[i]}` : id)).join(', '), tasks }
155}
156
157/**
158 * The overlay's text for each verify boundary (boundary `i` sits between
159 * position `i` and `i + 1`), plus what it could not place.
160 *
161 * Skips and unresolved checks are recorded by group, so they are placed by
162 * group: on the one boundary whose left wave carries that group, or, when two
163 * or more do, as an ambiguity on the first that says which boundaries the state
164 * does not tell apart. The cursor is not used to break the tie.
165 */
166function verifyTexts(overlay, positions) {
167 const texts = positions.slice(0, -1).map(() => [])
168 const unplaced = []
169 if (!overlay || !overlay.positional) return { texts, unplaced }
170 const { cursor } = overlay
171 const after = overlay.halted ? NOT_REACHED : PENDING
172 if (cursor.wave !== null) {
173 texts.forEach((text, i) => {
174 if (i === cursor.wave && cursor.phase === 'verify') text.push(CURRENT)
175 else if (i >= cursor.wave) text.push(after)
176 })
177 }
178
179 const groups = []
180 const byGroup = new Map()
181 const entry = group => {
182 if (!byGroup.has(group)) {
183 byGroup.set(group, { skips: [], unresolved: [] })
184 groups.push(group)
185 }
186 return byGroup.get(group)
187 }
188 for (const skip of overlay.skips.filter(skip => !isPositioned(skip))) {
189 entry(skip.wave ?? null).skips.push(isText(skip.reason) ? skip.reason : 'no reason recorded')
190 }
191 for (const u of overlay.unresolved) {
192 entry(u.wave ?? null).unresolved.push(`unresolved after ${Number.isInteger(u.attempts) ? u.attempts : '?'} attempts`)
193 }
194
195 // A skip the overlay recorded with its wave position — the ship meter's,
196 // read off the verify step (draw-the-wave-board-in-the-meter-pane D6) — is
197 // placed by it: the position says which boundary, so no group is guessed.
198 const touched = new Set()
199 for (const { index, reason } of overlay.positionedSkips) {
200 if (index < texts.length) {
201 texts[index].push(`skipped: ${reason}`)
202 touched.add(index)
203 } else unplaced.push(` unplaced: skip at idx ${index} · no verify boundary follows that position · ${reason}`)
204 }
205 for (const group of groups) {
206 const { skips, unresolved } = byGroup.get(group)
207 const recorded = [...skips.map(reason => `skipped: ${reason}`), ...unresolved]
208 const boundaries = texts.map((_, i) => i).filter(i => positions[i].group === group)
209 if (boundaries.length === 1) {
210 texts[boundaries[0]].push(...recorded)
211 touched.add(boundaries[0])
212 continue
213 }
214 const counted = [
215 ...(skips.length ? [`${skips.length} skip${skips.length === 1 ? '' : 's'}`] : []),
216 ...(unresolved.length ? [`${unresolved.length} unresolved`] : [])
217 ].join(' and ')
218 const reasons = [...skips, ...unresolved]
219 if (boundaries.length === 0) {
220 unplaced.push(` unplaced: ${counted} for group ${group} · no boundary carries that group · ${reasons.join(' · ')}`)
221 continue
222 }
223 const [first, ...later] = boundaries
224 const named = boundaries.map(i => `idx ${i}`)
225 const which = `${named.slice(0, -1).join(', ')} or ${named.at(-1)}`
226 texts[first].push(`${counted} for group ${group} · state does not say ${which}`, ...reasons)
227 touched.add(first)
228 for (const i of later) {
229 texts[i].push(`group ${group} skips: see idx ${first}`)
230 touched.add(i)
231 }
232 }
233
234 // A check that passed leaves no record, so a reached boundary with nothing
235 // recorded says exactly that, never `passed`.
236 if (cursor.wave !== null) {
237 texts.forEach((text, i) => {
238 if (i < cursor.wave && !touched.has(i)) text.push('no skip recorded')
239 })
240 }
241 return { texts, unplaced }
242}
243
244/** Each failure whose id no planned lane carries, by the wave the state recorded. */
245function unplacedFailures(overlay, planned) {
246 if (!overlay) return []
247 return overlay.failures
248 .filter(f => !planned.has(f.id))
249 .map(f => {
250 const where = `wave ${f.wave ?? '?'}${isText(f.waveKind) ? ` ${f.waveKind}` : ''}`
251 const error = isText(f.error) ? `error: ${f.error}` : 'no error recorded'
252 return ` unplaced: ${f.id} · ${where} · ${error}`
253 })
254}
255
256/** The overlay's part of the header (and of the Mermaid source comment). */
257function overlayHeader(overlay, positions, stateNote, { short = false } = {}) {
258 if (!overlay) return `plan only${isText(stateNote) ? ` (${stateNote})` : ''}`
259 if (!overlay.positional) {
260 return short
261 ? 'overlay by task id only'
262 : `overlay by task id only: state has ${overlay.waveCount} waves, plan has ${positions.length}`
263 }
264 const run = `run ${overlay.runId ? overlay.runId.slice(0, 8) : '?'}`
265 if (short) return run
266 const { cursor } = overlay
267 const at =
268 cursor.wave === null
269 ? ' · cursor not recorded'
270 : ` · cursor idx ${cursor.wave}${cursor.phase === 'batch' && cursor.batch !== null ? ` b${cursor.batch}` : ''}` +
271 (cursor.phase === 'verify' ? ' · verify' : '')
272 return `${run}${at}${overlay.halted ? ' · halted' : ''}`
273}
274
275/** Every lane of every position, with what a row or a node draws of it. */
276function lanesOf(positions, plan) {
277 const deferred = list(isObject(plan) ? plan.deferred : null).filter(r => isObject(r) && isText(r.id))
278 const out = []
279 for (const position of positions) {
280 position.batches.forEach((batch, b) => {
281 for (const lane of batch) {
282 const label = laneLabel(lane)
283 const title = laneTitle(lane)
284 const tier = laneTier(lane)
285 out.push({
286 lane,
287 position: position.index,
288 batch: b,
289 label,
290 title,
291 gist: laneGist(lane),
292 model: laneModel(lane),
293 tier: tier > 0 ? String(tier) : '?',
294 effort: laneEffort(lane) ?? 'inherit',
295 after: orderedAfter(lane, deferred)
296 })
297 }
298 })
299 }
300 return out
301}
302
303/** A top rule: its text, then `─` to exactly the width. */
304function topRule(text, width) {
305 const head = `┌─ ${text} `
306 const length = [...head].length
307 return length >= width ? fitRow(head, width) : `${head}${'─'.repeat(width - length)}`
308}
309
310/**
311 * The plan as rows of `{ key, text }`: `header`, `wave:<i>`, `lane:<label>`,
312 * `wave:<i>:end`, `verify:<i>`, `unplaced:<n>`, `tail`. Lane and wave-header
313 * rows also carry `parts`, so a drawer can colour a state word without
314 * searching the cut line; `text` is still the CLI line.
315 */
316function boardRows(plan, opts) {
317 const width = boardWidth(opts.columns)
318 if (width < LIMITS.waveBoardMinColumns) {
319 return [{ key: 'header', text: `wave board needs at least ${LIMITS.waveBoardMinColumns} columns; ${width} given` }]
320 }
321 const positions = positionsOf(plan)
322 const overlay = overlayOf(opts.state, positions)
323 const lanes = lanesOf(positions, plan)
324 const planned = new Set(lanes.flatMap(l => l.lane.map(task => task.id)))
325 const notes = new Map(Object.entries(isObject(opts.notes) ? opts.notes : {}).filter(([, text]) => isText(text)))
326 const mode = overlayHeader(overlay, positions, opts.stateNote)
327 const rows = []
328 const push = (key, text, parts) => {
329 const row = { key, text: fitRow(text, width) }
330 if (parts) row.parts = parts
331 rows.push(row)
332 }
333
334 push(
335 'header',
336 positions.length === 0
337 ? `plan · holds no waves · ${mode}`
338 : `plan · ${plural(positions.length, 'wave')} · ${plural(planned.size, 'task')} · ${mode}`
339 )
340
341 const labelCell = Math.max(0, ...lanes.map(l => l.label.length))
342 const batchCell = Math.max(0, ...lanes.map(l => `b${l.batch}`.length))
343 const { texts, unplaced } = verifyTexts(overlay, positions)
344 for (const position of positions) {
345 if (position.index > 0) {
346 const i = position.index - 1
347 push(`verify:${i}`, [` verify after idx ${i}`, ...texts[i]].join(' · '))
348 }
349 const title = waveTitle(position)
350 push(`wave:${position.index}`, topRule(title, width), { title })
351 for (const l of lanes.filter(l => l.position === position.index)) {
352 const { word, ids, tasks } = laneCells(overlay, l.lane, l.position, l.batch)
353 const cells = [
354 '│',
355 `b${l.batch}`.padEnd(batchCell),
356 l.label.padEnd(labelCell),
357 l.model.padEnd(MODEL_CELL),
358 `T${l.tier}`,
359 l.effort.padEnd(EFFORT_CELL)
360 ]
361 // A note is what a host observed of this lane, appended verbatim after the
362 // state word; the gist after it gives way first when the row is cut.
363 const note = notes.get(l.label)
364 if (overlay) cells.push(note ? `${word || ''} ${note}`.trimStart() : (word || '').padEnd(STATE_CELL))
365 else if (note) cells.push(note)
366 if (ids) cells.push(`[${ids}]`)
367 if (l.after.length) cells.push(`←${l.after.join(',')}`)
368 const head = cells.join(' ')
369 push(`lane:${l.label}`, l.gist ? `${head}${TITLE_SEPARATOR}${l.gist}` : head.trimEnd(), {
370 batch: `b${l.batch}`,
371 label: l.label,
372 model: l.model,
373 tier: `T${l.tier}`,
374 effort: l.effort,
375 state: word,
376 gist: l.gist,
377 note: note || '',
378 ids: ids || '',
379 after: l.after,
380 tasks
381 })
382 }
383 push(`wave:${position.index}:end`, `└${'─'.repeat(width - 1)}`)
384 }
385
386 const spoken = [...unplacedFailures(overlay, planned), ...unplaced]
387 spoken.forEach((text, n) => push(`unplaced:${n}`, text))
388 const halted = overlay && overlay.halted ? ` · halted${overlay.haltReason ? `: ${overlay.haltReason}` : ''}` : ''
389 push('tail', `${TAIL}${halted}`)
390 return rows
391}
392
393/**
394 * The wave board as keyed rows, for a drawer that keys what it draws — the ship
395 * meter's pane (draw-the-wave-board-in-the-meter-pane design D7, D10). Keys:
396 * `header`, `wave:<i>`, `lane:<label>` (the label its spawn, briefing and
397 * trajectory line carry), `wave:<i>:end`, `verify:<i>`, `unplaced:<n>` and
398 * `tail`. Below the published minimum width, the one spoken row, keyed
399 * `header`: a board always ends in `tail`, and the spoken line never does.
400 *
401 * `opts.notes` maps a lane label to text a host observed of that lane; it is
402 * appended verbatim after the lane's state word and cut with the row. A label
403 * no lane carries is drawn nowhere.
404 *
405 * @param {unknown} plan what `planWaves` emits, or a plan summary; any value draws
406 * @param {{columns?: number, state?: object|null, stateNote?: string, notes?: Record<string, string>}} [opts]
407 * @returns {{key: string, text: string, parts?: object}[]}
408 */
409export function drawPlanBoardRows(plan, opts = {}) {
410 return boardRows(plan, isObject(opts) ? opts : {})
411}
412
413/**
414 * The wave board: one bordered block per wave position, one row per lane, a
415 * verify cell at each boundary and a tail, every line at most `opts.columns`
416 * code points. Below the published minimum width, one spoken line. The texts
417 * of `drawPlanBoardRows`, in order.
418 *
419 * @param {unknown} plan what `planWaves` emits; any value draws, a non-plan as no waves
420 * @param {{columns?: number, state?: object|null, stateNote?: string, notes?: Record<string, string>}} [opts]
421 * @returns {string[]}
422 */
423export function drawPlanBoard(plan, opts = {}) {
424 return drawPlanBoardRows(plan, opts).map(row => row.text)
425}
426
427/** A Mermaid label, quoted: `"` is the one character a quoted label cannot hold. */
428const label = text => `"${String(text).replace(/"/g, '#quot;')}"`
429
430/** Node ids from lane labels, so the same plan renders to the same text. */
431function laneIds(lanes) {
432 const used = new Map()
433 return lanes.map(l => {
434 const base = `L${l.label.replace(/\./g, '_').replace(/\+/g, 'p').replace(/[^A-Za-z0-9_]/g, '_')}`
435 const n = (used.get(base) ?? 0) + 1
436 used.set(base, n)
437 return n === 1 ? base : `${base}_${n}`
438 })
439}
440
441/**
442 * The plan as a Mermaid `flowchart LR`: a subgraph per wave position (and per
443 * batch of a multi-batch wave), a node per lane, dotted `dependsOn` edges from
444 * the plan's deferral records, hexagon verify gates and subroutine closing
445 * gates. Never cut, and never styled.
446 *
447 * @param {unknown} plan
448 * @param {{state?: object|null, stateNote?: string, source?: string}} [opts]
449 * @returns {string[]}
450 */
451export function drawPlanMermaid(plan, opts = {}) {
452 const options = isObject(opts) ? opts : {}
453 const positions = positionsOf(plan)
454 const overlay = overlayOf(options.state, positions)
455 const lanes = lanesOf(positions, plan)
456 const ids = laneIds(lanes)
457 const source = isText(options.source) ? options.source : 'plan'
458 const out = ['flowchart LR', ` %% source: ${source} · ${overlayHeader(overlay, positions, options.stateNote, { short: true })}`]
459
460 const node = (l, i, indent) => {
461 const { word, ids: cell } = laneCells(overlay, l.lane, l.position, l.batch)
462 const state = word === PER_TASK ? ` · ${PER_TASK} [${cell}]` : word ? ` · ${word}` : ''
463 return `${indent}${ids[i]}[${label(`${l.title} · ${l.model} T${l.tier} ${l.effort}${state}`)}]`
464 }
465
466 for (const position of positions) {
467 out.push(` subgraph W${position.index}[${label(waveTitle(position))}]`)
468 const members = lanes.map((l, i) => ({ l, i })).filter(({ l }) => l.position === position.index)
469 if (position.batches.length === 1) {
470 for (const { l, i } of members) out.push(node(l, i, ' '))
471 } else {
472 position.batches.forEach((_, b) => {
473 out.push(` subgraph W${position.index}B${b}[${label(`b${b}`)}]`)
474 for (const { l, i } of members.filter(({ l }) => l.batch === b)) out.push(node(l, i, ' '))
475 out.push(' end')
476 })
477 out.push(` ${position.batches.map((_, b) => `W${position.index}B${b}`).join(' --> ')}`)
478 }
479 out.push(' end')
480 }
481
482 const { texts } = verifyTexts(overlay, positions)
483 const chain = []
484 for (const position of positions) {
485 if (position.index > 0) {
486 const i = position.index - 1
487 chain.push(`V${i}{{${label([`verify after idx ${i}`, ...texts[i]].join(' · '))}}}`)
488 }
489 chain.push(`W${position.index}`)
490 }
491 chain.push(`VF[[${label('verify-final')}]]`, `C[[${label('commit')}]]`, `X[[${label('close')}]]`)
492 out.push(` ${chain.join(' --> ')}`)
493
494 const laneOf = new Map()
495 lanes.forEach((l, i) => {
496 for (const task of l.lane) if (!laneOf.has(task.id)) laneOf.set(task.id, ids[i])
497 })
498 const seen = new Set()
499 const deferred = list(isObject(plan) ? plan.deferred : null).filter(r => isObject(r) && isText(r.id))
500 for (const record of deferred) {
501 for (const after of list(record.after).filter(isText)) {
502 const from = laneOf.get(after)
503 const to = laneOf.get(record.id)
504 let line
505 if (!to) line = ` %% dependsOn ${record.id} ← ${after}: ${record.id} is in no lane of this plan`
506 else if (!from) line = ` %% dependsOn ${record.id} ← ${after}: ${after} is in no lane of this plan`
507 else if (from === to) continue
508 else line = ` ${from} -. dependsOn .-> ${to}`
509 if (seen.has(line)) continue
510 seen.add(line)
511 out.push(line)
512 }
513 }
514 return out
515}
516lib/launch-rule.mjs 175 lines1// The launch rule: what a ship launch is, what a human prompt is, and whether
2// the next launch has a human behind it (guard-ship-relaunch design D1, D3;
3// guard-ship-relaunch-in-process design D1).
4//
5// The one recorded twenty-agent mistake was a parent chat calling Workflow again
6// over leftover checkboxes, with no human message in between. The skill forbids
7// it in prose; this is the rule that lets a hook refuse it: a launch newer than
8// the session's last human prompt means the next launch has no human behind it.
9//
10// ONE RULE, TWO TRANSPORTS. Two guards read this module: the settings hook
11// (`hooks/guard-relaunch.mjs`, through `lib/launch-ledger.mjs`, which keeps the
12// record in a file per session and re-exports everything here unchanged) and
13// the hooks module (`hooks/mod.mjs`, which keeps the same record in the
14// engine's session state). The decision and its words are defined once, so a
15// reword of the skill sentence moves both guards and both pins together.
16//
17// NO NODE. The hooks module runs in the engine with no Node, so this file
18// imports nothing but the published age, and `test/spine/launch-ledger.test.mjs`
19// pins that. A record here is plain data: `{ schema, launches: [{ at, runId,
20// workflowName, scriptPath }], lastHumanPromptAt }`, times as ISO strings.
21//
22// EVERY UNKNOWN ALLOWS. A launch time that does not parse is no launch, a
23// record that is not one is empty, an age past `LIMITS.launchLedgerMaxAgeMs` is
24// absent. A false refusal costs one typed message; a rule whose own parse
25// error blocked a launch would be the worse failure.
26
27import { LIMITS } from './limits.mjs'
28
29export const LEDGER_SCHEMA = 'interlock.launch-ledger/1'
30
31/**
32 * The skill sentence the deny quotes, verbatim, so one string is pinned in both
33 * places: `test/hooks.test.mjs` asserts `skills/ship/SKILL.md` still contains it.
34 */
35export const SKILL_QUOTE = 'Leftover `- [ ]` boxes after a run are a report, not authorization to call Workflow again'
36
37/** The leading marker of the host's completion wake (guard-ship-relaunch design D4, task-1 capture). */
38export const WAKE_MARKER = '<task-notification>'
39
40// The plugin's own ship workflow, by either handle the host passes: the script
41// path the skill trampoline names, or the command name the manifest's
42// `workflows` entry registers (`interlock:ship`, possibly marketplace-prefixed).
43const SHIP_SCRIPT = /(^|\/)workflows\/ship\.js$/
44const SHIP_NAME = /(^|[^A-Za-z0-9_])interlock:ship$/
45const LAUNCHED = new Set(['async_launched', 'remote_launched'])
46
47const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
48const posix = path => path.replace(/\\/g, '/')
49const str = value => (typeof value === 'string' && value ? value : null)
50const timeOf = launch => (isObject(launch) && typeof launch.at === 'string' ? Date.parse(launch.at) : NaN)
51const isRecord = value => isObject(value) && Array.isArray(value.launches)
52
53/**
54 * Is this Workflow input a launch of the plugin's ship workflow? A call
55 * carrying `resumeFromRunId` is still a launch: a resume replays every agent
56 * after the failed one. With the session's record, a call that resumes a run it
57 * recorded — by `runId`, or by the persisted `scriptPath` the completion wake
58 * suggests passing back — is one too, though it names neither handle above.
59 */
60export function isShipLaunch(toolInput, ledger = null) {
61 if (!isObject(toolInput)) return false
62 const scriptPath = typeof toolInput.scriptPath === 'string' ? posix(toolInput.scriptPath) : ''
63 if (scriptPath && SHIP_SCRIPT.test(scriptPath)) return true
64 if (typeof toolInput.name === 'string' && SHIP_NAME.test(toolInput.name)) return true
65 if (!isRecord(ledger)) return false
66 return ledger.launches.some(
67 launch =>
68 isObject(launch) &&
69 ((str(launch.runId) && toolInput.resumeFromRunId === launch.runId) ||
70 (str(launch.scriptPath) && scriptPath && scriptPath === posix(launch.scriptPath)))
71 )
72}
73
74/**
75 * Did the runtime accept this launch? Positive, not negative: only a response
76 * object whose `status` says it launched, carrying no error mark, counts. The
77 * capture found a refused launch never reaches PostToolUse at all — the engine
78 * rejects it at input validation — so this is the second line, and anything it
79 * cannot recognise records nothing, which is the allow direction.
80 */
81export function isAcceptedLaunch(toolResponse) {
82 if (!isObject(toolResponse)) return false
83 if (toolResponse.is_error === true || toolResponse.isError === true || toolResponse.error !== undefined) return false
84 return LAUNCHED.has(toolResponse.status)
85}
86
87/**
88 * A prompt a person sent, as opposed to the host's completion wake, read off
89 * the prompt's text: the settings form's test, which has no origin to read. No
90 * text is still a prompt. The hooks module reads the engine's stamped origin
91 * instead (guard-ship-relaunch-in-process design D3).
92 */
93export function isHumanPrompt(prompt) {
94 if (typeof prompt !== 'string') return true
95 return !prompt.trimStart().startsWith(WAKE_MARKER)
96}
97
98/** The newest launch with a readable time, or null. */
99export function newestLaunch(ledger) {
100 if (!isRecord(ledger)) return null
101 let newest = null
102 for (const launch of ledger.launches) {
103 const ms = timeOf(launch)
104 if (Number.isFinite(ms) && (!newest || ms > newest.ms)) newest = { at: launch.at, ms }
105 }
106 return newest
107}
108
109/** `lastHumanPromptAt` as milliseconds: null when none was recorded, NaN when it cannot be read. */
110export function promptMs(ledger) {
111 if (ledger.lastHumanPromptAt === null || ledger.lastHumanPromptAt === undefined) return null
112 return typeof ledger.lastHumanPromptAt === 'string' ? Date.parse(ledger.lastHumanPromptAt) : NaN
113}
114
115/** A record with no launch and no prompt: what a session starts from. A new object on every call. */
116export function emptyRecord() {
117 return { schema: LEDGER_SCHEMA, launches: [], lastHumanPromptAt: null }
118}
119
120/**
121 * The record with one more launch. Launches older than the published age
122 * (measured back from this one) and launches with no readable time are
123 * dropped, so the record stays bounded in either transport. Anything that is
124 * not a record starts from `emptyRecord()`. The input is never mutated.
125 */
126export function withLaunch(record, launch, maxAgeMs = LIMITS.launchLedgerMaxAgeMs) {
127 const base = isRecord(record) ? record : emptyRecord()
128 const ms = timeOf(launch)
129 const kept = base.launches.filter(prior => {
130 const at = timeOf(prior)
131 return Number.isFinite(at) && (!Number.isFinite(ms) || ms - at <= maxAgeMs)
132 })
133 return { ...base, launches: [...kept, launch] }
134}
135
136/**
137 * The record with a human prompt at `at` (an ISO time). Keeps the later of the
138 * recorded time and `at`: an earlier clock never moves the prompt back. Anything
139 * that is not a record starts from `emptyRecord()`. The input is never mutated.
140 */
141export function withPrompt(record, at) {
142 const base = isRecord(record) ? record : emptyRecord()
143 const next = typeof at === 'string' ? Date.parse(at) : NaN
144 if (!Number.isFinite(next)) return base
145 const previous = promptMs(base)
146 if (previous !== null && Number.isFinite(previous) && previous >= next) return base
147 return { ...base, lastHumanPromptAt: at }
148}
149
150/** The deny's words (guard-ship-relaunch design D3): what happened, the skill's rule, and the remedy. */
151export function denyReason(lastLaunchAt) {
152 return (
153 `guard-relaunch: a ship workflow was already launched in this session at ${lastLaunchAt} ` +
154 `and no human prompt has arrived since. skills/ship/SKILL.md: "${SKILL_QUOTE}." ` +
155 `Send a new message that asks to ship the leftovers, or type /interlock:ship again, ` +
156 `and the next launch is allowed.`
157 )
158}
159
160/**
161 * The rule. No record, or a stale one → allow. A launch newer than the last
162 * human prompt — including every launch when no prompt was recorded → deny.
163 * Anything this cannot read a time from → allow.
164 */
165export function decideLaunch(ledger, now = Date.now(), maxAgeMs = LIMITS.launchLedgerMaxAgeMs) {
166 const allow = { decision: 'allow', reason: null, lastLaunchAt: null, lastHumanPromptAt: null }
167 const newest = newestLaunch(ledger)
168 if (!newest) return allow
169 const prompt = promptMs(ledger)
170 const seen = { lastLaunchAt: newest.at, lastHumanPromptAt: prompt === null ? null : ledger.lastHumanPromptAt }
171 if (now - newest.ms > maxAgeMs) return { ...allow, ...seen }
172 if (prompt !== null && (!Number.isFinite(prompt) || prompt >= newest.ms)) return { ...allow, ...seen }
173 return { decision: 'deny', reason: denyReason(newest.at), ...seen }
174}
175lib/limits.mjs 680 lines1// Every cap the ship loop obeys, in one place.
2//
3// These numbers used to live in `skills/ship/SKILL.md` as prose — "cap two
4// attempts", "cap five root-cause iterations", "more than two task failures".
5// Prose caps drift: a number written in one heading gets restated differently
6// three headings later, and nothing catches it. Worse, a model reading a cap in
7// prose treats it as guidance, and the whole point of a cap is that it is not.
8//
9// So the loop engines read them from here, the CLI prints them, and the skills
10// cite the CLI rather than restating the number. When a cap changes it changes
11// once.
12//
13// Pure: no fs, no agent, no I/O.
14// Exposed to skills as `interlock limits [--json]`.
15
16/**
17 * Ceilings that come from the dynamic-workflow runtime rather than from
18 * Interlock policy. The runtime caps concurrency at 16 and total agents at 1000
19 * per run; exceeding either is the runtime's error, not ours, so the planner
20 * clamps below them rather than discovering them the hard way.
21 * @see https://code.claude.com/docs/en/workflows
22 */
23export const RUNTIME = {
24 maxConcurrentAgents: 16,
25 maxAgentsPerRun: 1000
26}
27
28export const LIMITS = {
29 /** Agents spawned per batch. Waves wider than this are split. */
30 maxParallel: 8,
31
32 // `maxTasksPerAgent` — the single scalar lane cap — was REMOVED here rather
33 // than aliased to the tier table that replaced it (`LANE_CAPS.byTier` below).
34 // It had six readers: the planner, `makeWave`, `createRunState`, the plan
35 // fingerprint's canonical text, `formatFingerprint` and `formatLimits`. A
36 // deprecated alias would have let any one of them keep reading a scalar while
37 // the rest read the table, and the two would disagree silently about how long
38 // a lane may get — which is the drift this module exists to prevent. Removing
39 // it makes the sweep enforceable instead: a stale reader fails at import.
40
41 /**
42 * Targeted fix attempts after a failed inter-wave check, per wave. After
43 * this the errors are logged and the run either halts (if they block the
44 * next wave) or continues with a warning.
45 */
46 interWaveFixAttempts: 2,
47
48 /** Revisions of not-yet-executed wave groups, per run. */
49 replansPerRun: 2,
50
51 /** Review → fix → re-review cycles. Surviving blockers after this halt. */
52 remediationRounds: 2,
53
54 /**
55 * Root-cause repair iterations against a red unit suite, across the whole
56 * run. Repairing by root cause is slow by design; this bounds it before the
57 * run turns into an open-ended debugging session.
58 */
59 rootCauseIterations: 5,
60
61 /**
62 * Task failures tolerated across all waves. Strictly more than this halts —
63 * a run losing three tasks is not producing a coherent change.
64 */
65 taskFailureHalt: 2,
66
67 /**
68 * Steps one run of the ship program may take before it halts. A step is one
69 * CLI call that yields a step record; a run that has taken this many without
70 * reaching a terminal state is not converging.
71 *
72 * This was `MAX_LOOP_STEPS`, a literal restated in both drivers — a loop bound
73 * the cap-authority spec forbids stating anywhere but here. It is enforced by
74 * `lib/run.mjs`, which counts on the run manifest and returns a `halt` step
75 * naming this cap once it is exceeded. A driver's own `RUNAWAY_BACKSTOP` is a
76 * different thing: the host runtime's agent ceiling, not a policy cap.
77 */
78 maxRunSteps: 200,
79
80 // `memoryEntriesPerRun` was removed here rather than wired. It had no reader
81 // anywhere outside this file and its own value-pinning test, and memory
82 // writing is not implemented as a counted operation — so wiring it would have
83 // meant inventing an enforcement point to justify a number. A cap this module
84 // prints but nothing obeys is the same failure as a cap written in prose,
85 // which is the failure this module exists to end.
86
87 /**
88 * Soft budget for inter-wave verification. Past this, drop to typecheck only
89 * rather than letting the checks outweigh the work they guard.
90 */
91 interWaveVerifyBudgetMs: 60_000,
92
93 /**
94 * Combined stdout/stderr byte threshold past which a verify step's output is
95 * spilled to disk (`lib/spill.mjs`) instead of returned inline. See
96 * add-ship-run-inspectability design.md §3.
97 */
98 verifySpillBytes: 8192,
99
100 /**
101 * Character budget for a spilled step's head-and-tail preview, and the
102 * ceiling every other verify-result text field must stay under. A result
103 * field larger than this is rejected as an oversized-result leak rather than
104 * judged.
105 */
106 verifyPreviewChars: 4096,
107
108 /**
109 * Inter-wave verification checkpoints allowed per run. A wave boundary is
110 * ordering; a checkpoint is an agent. Ordering is free, so the cap is on
111 * checkpoints, not on waves. Docs-only waves skip without consuming a slot.
112 */
113 interWaveVerifications: 3,
114
115 /**
116 * Character budget for one task's wave handoff — `summary`, `next`,
117 * `blocker` and the joined `evidence` locators added together. A packet over
118 * this fails its task rather than being truncated: silent truncation is the
119 * degradation the schema exists to forbid, and a next wave reading half a
120 * sentence is worse off than one told the report was rejected.
121 * See add-wave-handoff-and-prompt-snapshots design.md §2.
122 */
123 maxHandoffChars: 2000,
124
125 /**
126 * Byte ceiling for the repo-root `REVIEW.md` policy file. A file larger than
127 * this is not scanned: it is reported as a problem and the run proceeds under
128 * default policy (fail-open, add-interlock-review-policy design.md D3). The
129 * bound exists so a pathological or accidental (a committed build log named
130 * `REVIEW.md`) file cannot make the policy reader do unbounded work — the same
131 * reason every other scan in this loop is bounded rather than trusting.
132 * Read by `readReviewPolicy` in `lib/review-core.mjs`.
133 */
134 maxReviewPolicyBytes: 65_536,
135
136 /**
137 * Milliseconds a close's push to the ntfy relay may take before it is
138 * abandoned as a failed push. A hanging network call must not stall the
139 * close itself (design D5, add-harden-unattended-ship-runs). Read by
140 * `postNtfy` in `lib/notify.mjs` via `AbortController`.
141 */
142 notifyTimeoutMs: 5000,
143
144 /**
145 * Rows of any one list — leftover task ids, wave tallies, degradation banners
146 * — the halt resume card prints before it says how many it left out and names
147 * the command that prints them all. The card is a pointer a person reads in
148 * one sitting, not a second copy of the trajectory, so it is bounded; but a
149 * silent head would make it lie, so the truncation is always stated. Read by
150 * `lib/resume-card.mjs`.
151 */
152 resumeCardListRows: 40,
153
154 /**
155 * Milliseconds a session's launch ledger stays readable (guard-ship-relaunch
156 * design D5). A ledger whose newest launch is older than this reads as absent,
157 * and every ledger write sweeps sibling files past it, so the directory does
158 * not grow one file per session forever and no stale session can deny a live
159 * one. 24 hours is longer than any run `maxRunSteps` and the drivers' runaway
160 * backstop allow, so a live session's launch is never aged out while its run
161 * could still be in flight; it is shorter than the host's transcript
162 * retention, so the directory never outlives the sessions it names. Ageing out
163 * only ever turns a deny into an allow. Read by `lib/launch-ledger.mjs`.
164 */
165 launchLedgerMaxAgeMs: 24 * 60 * 60 * 1000,
166
167 /**
168 * Milliseconds a live ship run may go with no activity the engine reports
169 * (a step, a run agent's request or answer, a turn end, a spawn) before the
170 * ship meter appends `quiet <n> min` to its status line, spinner and pane
171 * (show-quiet-time-and-reset-the-meter-on-clear design D3). Longer than one
172 * model request at the deepest effort ordinarily takes to answer, so a lane
173 * that is thinking is not called quiet on the strength of one slow request;
174 * short enough that a person who looks during a run learns within one wave's
175 * time that nothing has moved. A display threshold with no verdict behind it:
176 * a wrong value costs an early or a late word, never a halt. Read by
177 * `lib/meter-quiet.mjs`, which the hooks module imports.
178 */
179 meterQuietAfterMs: 5 * 60 * 1000,
180
181 /**
182 * Milliseconds between the ship meter's recomputations of the quiet figure: the
183 * period of the one interval the hooks module asks the host for while a run is
184 * live (design D2, D3). The word is in whole minutes, so this keeps the shown
185 * minute at most this stale, at a few hundred dispatches an hour, which is
186 * nothing beside the run's own traffic. Read by `lib/meter-quiet.mjs`.
187 */
188 meterTickMs: 15 * 1000,
189
190 /**
191 * The most characters of a halt resume card the `/interlock-handoff` pane
192 * renders (show-preflight-and-interrupted-runs-at-session-start design D7).
193 * Not a choice: it is the `Markdown` element's own bound on Claude Code
194 * 2.1.286, which refuses a longer `text`. A card over it is cut with a line
195 * inside the rendered text naming the characters left out and the card's
196 * path, so the pane shows a head of the card instead of nothing. Read by
197 * `lib/preflight-file.mjs`, which the hooks module imports; restated nowhere
198 * in the module.
199 */
200 handoffPaneChars: 10_000,
201
202 /**
203 * The narrowest width, in columns, the wave board and the handoff board draw
204 * at (draw-wave-plan-and-handoff-graph-from-the-cli design D8). The fixed
205 * cells of a lane row — border, a two-digit batch, a five-character label,
206 * model, tier, effort and the longest state word — come to 41 columns, and
207 * seven more hold one ordered-after id. Below it a board would cut ids, so
208 * the renderer returns one spoken line instead. Read by `lib/draw-plan.mjs`
209 * and `lib/draw-run.mjs`.
210 */
211 waveBoardMinColumns: 48,
212
213 /**
214 * The width, in columns, a board is drawn at when neither `--columns` nor a
215 * terminal says (design D8). The widest uncut lane row of the real halted
216 * run's plan is 89 columns, so a piped board of a real plan keeps its gists.
217 * Read by both renderers, for a width they were not given, and by
218 * `bin/interlock`, which resolves the width before it draws.
219 */
220 waveBoardDefaultColumns: 100
221}
222
223/**
224 * Caps the eval suite obeys, published here so nothing restates them in
225 * `.github/workflows/evals.yml` — a cost written in workflow YAML and echoed in
226 * a skill drifts exactly like a prose cap does. The CI job reads them from
227 * `interlock limits` (add-interlock-evals design.md D8), following the same
228 * precedent the implementer prompt sets at `workflows/ship.js:194-195`.
229 *
230 * These are deliberately a separate object from LIMITS rather than more entries
231 * in it: LIMITS counts loop iterations, whereas a cost ceiling is a dollar
232 * amount. They are held to the same reader invariant all the same — the
233 * cap-authority test walks `.github/workflows/` alongside `lib/`, `bin/` and
234 * `workflows/`, and a CI reader counts when it names the published field
235 * (`evals.<cap>`) rather than the export. There is no exemption: a cap here with
236 * no reader fails the same test a cap in LIMITS would.
237 *
238 * `reportingThreshold` was removed rather than wired, following the
239 * `memoryEntriesPerRun` precedent above. It described a score at or above which
240 * a case reported as passing, but `lib/evals-triage.mjs` reads no score at all —
241 * `classifyCase` branches on each grader's `passed` boolean — so there was no
242 * path it could govern without inventing one. Publishing it told a contributor
243 * that a scoring rule existed which did not.
244 *
245 * The two cost ceilings are first guesses to be tuned from the `cost_usd` the
246 * first runs actually report (design.md D15), not numbers the specs depend on.
247 */
248export const EVAL_CAPS = {
249 /** US dollars. Ceiling for the per-pull-request smoke subset. */
250 smokeCostUsd: 2,
251
252 /** US dollars. Ceiling for the scheduled full-suite run. */
253 fullRunCostUsd: 15,
254
255 /**
256 * US dollars. Ceiling for the scheduled **outcome** eval — the one that ships
257 * a committed fixture through the real loop and grades what is on disk
258 * (`evals/ship/run.mjs`), not the transcript-grading case suite above.
259 *
260 * Its two readers exist on the day it lands, which is the whole reason it may
261 * be published at all: `evals/ship/run.mjs` reads it from `interlock limits
262 * --json` before it starts a fixture, and `.github/workflows/ship-outcome-
263 * eval.yml` reads the same field to bound the job. The number appears nowhere
264 * else — not in the workflow YAML, not in the eval's README.
265 *
266 * Enforced at fixture and arm boundaries only. It stops the runner from
267 * STARTING the next arm; it never kills a run in flight, because a
268 * half-killed run cannot be graded and would be recorded as a failure it did
269 * not earn (design D6). A sweep that reaches it is reported partial.
270 *
271 * A first guess, to be tuned from the spend the first rows actually report —
272 * the same posture as the two ceilings above.
273 */
274 shipEvalCostUsd: 20,
275
276 /**
277 * US dollars. Ceiling for one invocation of the **cost-per-task sweep**
278 * (`evals/ship/matrix.mjs`): the committed fixtures crossed with model and
279 * effort, each cell run by the control-arm procedure. A different run from the
280 * outcome eval, with its own ceiling, so neither can spend the other's budget.
281 *
282 * Its reader is the sweep, which reads it from `interlock limits --json` before
283 * starting each cell. It is run by hand and gates nothing: no workflow reads
284 * it, and no pull request starts the sweep. The number appears nowhere else.
285 *
286 * Enforced between cells only — a cell in flight is never killed, an unpriced
287 * cell adds nothing to the total, and a sweep that reaches it is partial.
288 *
289 * A first guess in the posture of `shipEvalCostUsd`: twice that ceiling, for a
290 * grid of control-arm cells rather than a handful of full loops, to be tuned
291 * from the dollars the first rows report.
292 */
293 matrixCostUsd: 40,
294
295 /**
296 * Agent runs per case. Passed as `--runs` by the scheduled full-suite job,
297 * which overrides each case's own value: those were chosen for the fast
298 * per-change subset, and the scheduled arm is the one that can afford
299 * repetition. The smoke job's `--runs 1` is that subset's single-run posture,
300 * not a restatement of this cap.
301 */
302 runsPerCase: 3,
303
304 // --- the promotion rule (spec: evals/promotion) --------------------------
305 //
306 // These three are the whole rule that decides whether an eval case may fail a
307 // build. They live here, with readers, for the reason the module header gives:
308 // a promotion threshold written in a specification is a sentence a reviewer
309 // re-argues once per proposal. `lib/evals-promote.mjs` reads all three by
310 // name, and nothing restates them in a skill, a spec, a doc, or the workflow
311 // YAML.
312 //
313 // Landing them WITH readers is deliberate: `runsPerCase` sat here printed and
314 // unread, and `reportingThreshold` was removed for the same reason. A fourth
315 // unread cap is the one thing this group must not gain.
316
317 /**
318 * Consecutive qualifying runs a case must pass across before it may be
319 * promoted from advisory to blocking. A run triage classified no-signal or
320 * configuration does not qualify, is named, and does not break the chain.
321 * Read by `promote()` in `lib/evals-promote.mjs`.
322 */
323 promotionRuns: 3,
324
325 /**
326 * Trials of a case a run must carry to count as evidence for promotion. Below
327 * this the run is real but too thin to prove repeatability, and the case is
328 * refused naming the run rather than promoted on it.
329 * Read by `promote()` in `lib/evals-promote.mjs`.
330 */
331 promotionTrialsPerRun: 3,
332
333 /**
334 * Minimum measured judge/human agreement, as a fraction, for each judged
335 * grader of a case being promoted. `interlock evals calibrate` measures;
336 * this is the floor it is measured against, applied only here — the
337 * calibration report itself issues no verdict.
338 *
339 * The one non-integer in this group, which is why the positive-integer
340 * invariant is over LIMITS rather than over every cap group.
341 * Read by `promote()` in `lib/evals-promote.mjs`.
342 */
343 judgeAgreementFloor: 0.8
344}
345
346/**
347 * The price table the outcome eval converts measured tokens into dollars with,
348 * so `EVAL_CAPS.shipEvalCostUsd` can be a ceiling on money rather than on a
349 * token count nobody can compare across models (design D6).
350 *
351 * A separate export from EVAL_CAPS for the reason EFFORT and LANE_CAPS are: the
352 * cap groups hold scalars a code path obeys, and this is a table. It lives here
353 * all the same, because a price written in the runner and echoed in a workflow
354 * would drift exactly like a prose cap.
355 *
356 * `id` IS THE LOAD-BEARING FIELD. It travels on every recorded result row, so a
357 * later price revision cannot silently reinterpret rows written under the old
358 * one. Revising a price therefore means a NEW id, never an edit to a number
359 * under the existing one — old rows keep meaning what they meant, and a reader
360 * comparing across a revision can see that it happened.
361 *
362 * The figures are the published list prices for each tier, in US dollars per
363 * million tokens, as of the date in the id. They are an input to a ceiling and
364 * to a recorded estimate — never to a bill, and never to a gate.
365 *
366 * A model that is not in the table yields NO cost figure: the runner records the
367 * spend as absent with that reason rather than pricing it at zero or guessing a
368 * tier from the name. An unpriced run still runs; it just cannot be counted
369 * against the ceiling, and the sweep says so.
370 *
371 * Read by `evals/ship/run.mjs` through `interlock limits --json`; printed by
372 * `formatLimits()` below.
373 */
374export const MODEL_PRICES = {
375 // A NEW id, not an edit under `anthropic-list-2026-09b`. These are the rates
376 // confirmed against the published list in October 2026 — a later list, not a
377 // revision of the September one — so rows recorded under either September id
378 // keep the id, and the meaning, they were written with. The 5.5 models replace
379 // `claude-opus-5` and `claude-sonnet-5` rather than sitting beside them: a row
380 // naming an old model is unpriced under this id, never priced at a guessed rate.
381 id: 'anthropic-list-2026-10',
382 perMillionTokens: {
383 'claude-opus-5-5': { input: 4, output: 20 },
384 'claude-sonnet-5-5': { input: 2, output: 10 },
385 'claude-haiku-4-5': { input: 1, output: 5 }
386 },
387 /**
388 * What a cached prefix costs, as multipliers on the model's own `input` rate.
389 *
390 * `read` is keyed by MODEL, by the same ids as `perMillionTokens`, because the
391 * published read multiplier is not one figure: Opus 5.5 reads at 0.05x while
392 * Sonnet 5.5 and Haiku 4.5 read at 0.1x. A model in `perMillionTokens` with no
393 * entry here is unpriced — the pricer never falls back to some other model's
394 * read multiplier.
395 *
396 * `write` is keyed by LIFETIME TIER, shared across every model the table
397 * prices, and is never flattened: a prefix written for five minutes and one
398 * written for an hour are different prices, and a total that added them could
399 * not be priced back apart. The keys are the tier names the host's usage
400 * envelope reports them under, so a recorded figure and its multiplier are
401 * looked up by the same string.
402 *
403 * Read by `priceUsage` in `evals/ship/arms.mjs`, by these names
404 * (`cacheMultipliers.read[model]`, `cacheMultipliers.write[tier]`). They live
405 * in the table rather than beside it so the two cannot drift — a run priced
406 * against one id and multiplied by a differently versioned constant is
407 * attributable to nothing.
408 */
409 cacheMultipliers: {
410 read: {
411 'claude-opus-5-5': 0.05,
412 'claude-sonnet-5-5': 0.1,
413 'claude-haiku-4-5': 0.1
414 },
415 write: { ephemeral_5m: 1.25, ephemeral_1h: 2 }
416 }
417}
418
419/**
420 * Reasoning-effort defaults for the wave planner and the two adversarial steps.
421 *
422 * Deliberately a separate object from LIMITS, exactly like EVAL_CAPS/REPORT_CAPS:
423 * LIMITS holds positive-integer iteration counts (its invariant test asserts
424 * every entry is one, and every entry must be read as `LIMITS.<cap>` by a
425 * code path). These are a tier→effort table and two fixed effort strings —
426 * neither an integer nor a loop count. Keeping them here still satisfies the one
427 * property that matters: the mapping is read from a single source and surfaced
428 * by `interlock limits`, never restated in code that can drift.
429 *
430 * `byTier` maps a lane's hardest tier to a workflow-runtime effort. `null` means
431 * "inherit the session default — do not force": tiers 3–4 inherit by policy so
432 * the run keeps whatever the session/model default is (xhigh for coding since
433 * w16); an untiered lane inherits by fallback. `verify` pins both verify
434 * checks — the inter-wave one and the final one, which one spawn site emits —
435 * and `skeptic` pins the review skeptics, regardless of any lane tier: the two
436 * steps whose whole job is catching what an implementer missed.
437 */
438export const EFFORT = {
439 byTier: { 1: 'low', 2: 'low', 3: null, 4: null, 5: 'xhigh' },
440 verify: 'xhigh',
441 skeptic: 'xhigh'
442}
443
444/**
445 * How long a lane may get, per tier, and which tiers may be packed together.
446 *
447 * A lane is an ordered task list one agent executes start to finish. Until this
448 * table existed the bound was one scalar for every lane in the run, which forced
449 * one number to answer two different questions: how much sequential trivia one
450 * agent may hold, and how much judgement-heavy work it may hold. Those have
451 * different answers, so this is a table.
452 *
453 * `byTier` is keyed by the lane's HARDEST task tier (`laneTier`). An untiered
454 * lane (tier 0) uses the tier-1 entry. Tier 4 keeps the old scalar's 4 —
455 * cross-file pattern-following is where a fresh context per task still pays.
456 * Tier 5 stays at 8. Chain lanes are bounded by this same table: the cap is the
457 * entry for the chain's hardest tier, packed next-fit.
458 *
459 * `opusMinTier` is the floor at which a multi-task lane (collision, cohesion or
460 * chain) dispatches on opus. Below it the lane runs on sonnet. Single-task lanes
461 * and solo promotions are not gated by this floor — see `laneModel`. The value
462 * sits next to the cohesion ceiling because both answer "how hard must the work
463 * be before this dial moves."
464 *
465 * `cohesionMaxTier` is the ceiling for COHESION: path-disjoint components in one
466 * dependency layer whose hardest tier is at or below it may be packed into one
467 * lane. Above it a component stays a lane of its own, joined only by a real path
468 * collision. Set it to 0 to disable cohesion while keeping the table.
469 *
470 * A separate export from LIMITS for the same reason `EFFORT` is: the LIMITS
471 * invariant asserts every entry is a positive integer, and `byTier` is a table.
472 * The cap-authority check covers this group all the same — every entry here must
473 * have a reader in `lib/`, `bin/` or `workflows/`.
474 *
475 * Read by `lib/waves.mjs` (lane construction, cohesion packing) and
476 * `lib/plan-fingerprint.mjs`; printed by `interlock limits` and emitted as
477 * `laneCaps` by `interlock limits --json`.
478 */
479export const LANE_CAPS = {
480 byTier: { 1: 8, 2: 8, 3: 6, 4: 4, 5: 8 },
481 cohesionMaxTier: 3,
482 /** Hardest-task tier at or above which a multi-task lane dispatches on opus. */
483 opusMinTier: 4
484}
485
486/**
487 * The envelope inside which a change may be planned as ONE lane — every task,
488 * in order, on one opus agent (`--solo`, or the classifier's recommendation).
489 *
490 * One bound, task count, on purpose. The classifier supplies the shape
491 * judgement ("this change is one coherent edit"); this supplies the ceiling.
492 * Neither restates the other, which is why the `plan-waves` prompt is forbidden
493 * from naming this number: a model that can read the bound can argue with it.
494 *
495 * A recommendation above the envelope is refused and named. An explicit `--solo`
496 * flag may exceed it — a human flag is the one input allowed to, the same way
497 * `--max-parallel` may ask for anything under the runtime ceiling — and the plan
498 * warns that it did.
499 *
500 * Read by `lib/waves.mjs` (mode decision) and `lib/plan-fingerprint.mjs`;
501 * printed by `interlock limits` and emitted as `solo` by `--json`.
502 */
503export const SOLO = {
504 maxTasks: 20
505}
506
507/**
508 * Caps for the corpus reader (`interlock report`).
509 *
510 * Not a policy ceiling like the ones above — nothing halts on it. It bounds how
511 * much of an unbounded, ever-growing corpus one invocation opens, so a
512 * repository with fifty thousand trajectories still gets a report. Reaching it
513 * is stated in the output rather than sampled silently: a truncated scan that
514 * looked complete would be worse than no report at all.
515 */
516export const REPORT_CAPS = {
517 /** Trajectory files opened in one `interlock report` invocation. */
518 maxRunsScanned: 2000
519}
520
521/**
522 * Clamp a requested parallelism to something both Interlock and the runtime
523 * will honour. A caller asking for 40 gets the runtime ceiling, not an error:
524 * over-asking is a preference, not a mistake worth failing a run over.
525 * @param {number} [requested]
526 * @returns {{value: number, clamped: boolean, reason: string|null}}
527 */
528export function clampParallel(requested) {
529 if (!Number.isInteger(requested) || requested <= 0) {
530 return { value: LIMITS.maxParallel, clamped: false, reason: null }
531 }
532 if (requested > RUNTIME.maxConcurrentAgents) {
533 return {
534 value: RUNTIME.maxConcurrentAgents,
535 clamped: true,
536 reason: `workflow runtime allows at most ${RUNTIME.maxConcurrentAgents} concurrent agents`
537 }
538 }
539 return { value: requested, clamped: false, reason: null }
540}
541
542/**
543 * Human-readable dump, so a skill can show the caps without hardcoding them.
544 *
545 * `runtimeObserved` is the concurrency this machine's runtime will honour as far
546 * as the caller could observe it (`runtimeSlotsOf` in `lib/host/claude-env.mjs`):
547 * an override from the environment, or the vendor default beside the CPU count
548 * that may reduce it. Printed beside the vendor fact, never in place of it, and
549 * read by the caller — this module still reads no environment.
550 *
551 * @param {{runtimeObserved?: {observed: number|null, source: string, cpuCount: number|null, invalid?: string}}} [opts]
552 */
553export function formatLimits({ runtimeObserved } = {}) {
554 const rows = [
555 ['max parallel agents per batch', LIMITS.maxParallel],
556 ['inter-wave fix attempts (per wave)', LIMITS.interWaveFixAttempts],
557 ['replans (per run)', LIMITS.replansPerRun],
558 ['remediation rounds', LIMITS.remediationRounds],
559 ['root-cause iterations (per run)', LIMITS.rootCauseIterations],
560 ['task failures tolerated', LIMITS.taskFailureHalt],
561 ['run steps (per run)', LIMITS.maxRunSteps],
562 ['inter-wave verify budget', `${LIMITS.interWaveVerifyBudgetMs / 1000}s`],
563 ['inter-wave verifications (per run)', LIMITS.interWaveVerifications],
564 ['verify spill threshold (bytes)', LIMITS.verifySpillBytes],
565 ['verify preview budget (chars)', LIMITS.verifyPreviewChars],
566 ['wave handoff budget (chars, per task)', LIMITS.maxHandoffChars],
567 ['review policy scan cap (bytes)', LIMITS.maxReviewPolicyBytes],
568 ['push timeout (ms)', LIMITS.notifyTimeoutMs],
569 ['resume card list rows (per list)', LIMITS.resumeCardListRows],
570 ['launch ledger max age (ms)', LIMITS.launchLedgerMaxAgeMs],
571 ['meter quiet after (ms)', LIMITS.meterQuietAfterMs],
572 ['meter tick (ms)', LIMITS.meterTickMs],
573 ['handoff pane chars', LIMITS.handoffPaneChars],
574 ['wave board min width (columns)', LIMITS.waveBoardMinColumns],
575 ['wave board default width (columns)', LIMITS.waveBoardDefaultColumns]
576 ]
577 const evalRows = [
578 ['eval smoke cost ceiling (per PR)', `$${EVAL_CAPS.smokeCostUsd}`],
579 ['eval full-run cost ceiling (scheduled)', `$${EVAL_CAPS.fullRunCostUsd}`],
580 ['eval outcome-run cost ceiling (scheduled)', `$${EVAL_CAPS.shipEvalCostUsd}`],
581 ['eval cost-per-task sweep ceiling (by hand)', `$${EVAL_CAPS.matrixCostUsd}`],
582 ['eval runs per case', EVAL_CAPS.runsPerCase],
583 ['eval promotion: consecutive qualifying runs', EVAL_CAPS.promotionRuns],
584 ['eval promotion: trials per qualifying run', EVAL_CAPS.promotionTrialsPerRun],
585 ['eval promotion: judge agreement floor', `${Math.round(EVAL_CAPS.judgeAgreementFloor * 100)}%`]
586 ]
587 // The price table prints beside the ceiling it serves: an operator reading a
588 // dollar cap needs to see which prices turned tokens into those dollars, and
589 // the id is what a recorded row is keyed against.
590 const priceRows = [
591 ['price table id', MODEL_PRICES.id],
592 ...Object.keys(MODEL_PRICES.perMillionTokens)
593 .sort()
594 .map(model => [
595 `price: ${model} ($/Mtok)`,
596 `in ${MODEL_PRICES.perMillionTokens[model].input}, out ${MODEL_PRICES.perMillionTokens[model].output}`
597 ]),
598 // The cache multipliers print beside the base rates for the same reason the
599 // base rates print beside the ceiling: they are part of what turns a token
600 // count into a dollar figure, and a multiplier nobody can see is a price
601 // written in prose. One read row per model, because the read multiplier is
602 // not one figure; one write row per lifetime tier, because they are never
603 // summed. A priced model with no read multiplier prints as `unpriced`, which
604 // is what the pricer makes of it.
605 ...Object.keys(MODEL_PRICES.perMillionTokens)
606 .sort()
607 .map(model => [
608 `cache read multiplier: ${model} (x input)`,
609 typeof MODEL_PRICES.cacheMultipliers.read[model] === 'number'
610 ? MODEL_PRICES.cacheMultipliers.read[model]
611 : 'unpriced'
612 ]),
613 ...Object.keys(MODEL_PRICES.cacheMultipliers.write)
614 .sort()
615 .map(tier => [`cache write multiplier: ${tier} (x input)`, MODEL_PRICES.cacheMultipliers.write[tier]])
616 ]
617 const reportRows = [['report trajectory scan cap', REPORT_CAPS.maxRunsScanned]]
618 // One row per tier, rather than a rendered object: an operator reading this
619 // wants to know what bounds the lane in front of them, and a tier is how they
620 // find it. The cohesion ceiling and the solo envelope print beside them because
621 // all three answer one question — how many tasks may one agent be handed.
622 const laneRows = [
623 ['lane cap: tier 1 lane (also untiered)', LANE_CAPS.byTier[1]],
624 ['lane cap: tier 2 lane', LANE_CAPS.byTier[2]],
625 ['lane cap: tier 3 lane', LANE_CAPS.byTier[3]],
626 ['lane cap: tier 4 lane', LANE_CAPS.byTier[4]],
627 ['lane cap: tier 5 lane', LANE_CAPS.byTier[5]],
628 ['cohesion tier ceiling (packs at or below)', LANE_CAPS.cohesionMaxTier],
629 ['multi-task opus floor (hardest tier at or above)', LANE_CAPS.opusMinTier],
630 ['solo envelope (max tasks in one lane)', SOLO.maxTasks]
631 ]
632 // `null` in the tier table means inherit the session default; print it as
633 // "inherit" so an operator reads the policy, not an empty cell.
634 const inherit = v => (v === null || v === undefined ? 'inherit (session default)' : v)
635 const effortRows = [
636 ['effort: tier 1 lane', inherit(EFFORT.byTier[1])],
637 ['effort: tier 2 lane', inherit(EFFORT.byTier[2])],
638 ['effort: tier 3 lane', inherit(EFFORT.byTier[3])],
639 ['effort: tier 4 lane', inherit(EFFORT.byTier[4])],
640 ['effort: tier 5 lane', inherit(EFFORT.byTier[5])],
641 ['effort: verify step (inter-wave and final)', EFFORT.verify],
642 ['effort: review skeptic step', EFFORT.skeptic]
643 ]
644 const width = Math.max(
645 ...[...rows, ...laneRows, ...evalRows, ...priceRows, ...reportRows, ...effortRows].map(
646 ([label]) => label.length
647 )
648 )
649 const fmt = ([label, value]) => ` ${String(label).padEnd(width)} ${value}`
650 return (
651 rows.map(fmt).join('\n') +
652 '\n\n' +
653 laneRows.map(fmt).join('\n') +
654 '\n\n' +
655 evalRows.map(fmt).join('\n') +
656 '\n\n' +
657 priceRows.map(fmt).join('\n') +
658 '\n\n' +
659 reportRows.map(fmt).join('\n') +
660 '\n\n' +
661 effortRows.map(fmt).join('\n') +
662 `\n\nruntime ceilings: ${RUNTIME.maxConcurrentAgents} concurrent, ` +
663 `${RUNTIME.maxAgentsPerRun} agents per run${observedSlots(runtimeObserved)}\n`
664 )
665}
666
667/** The observed clause of the runtime-ceilings line, or '' when nothing was observed. */
668function observedSlots(observed) {
669 if (!observed || typeof observed !== 'object') return ''
670 if (observed.source === 'env' && Number.isInteger(observed.observed)) {
671 return ` — observed on this machine: CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS=${observed.observed}`
672 }
673 const machine = Number.isInteger(observed.cpuCount) ? `this ${observed.cpuCount}-CPU machine` : 'this machine'
674 const ignored =
675 typeof observed.invalid === 'string'
676 ? `CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS=${observed.invalid} is not a valid count and is ignored; `
677 : ''
678 return ` — observed on this machine: ${ignored}vendor default, possibly reduced on ${machine}`
679}
680lib/meter-palette.mjs 52 lines1// The ship meter's colours, as the theme keys the pane draws with.
2//
3// Each value is a key of the person's own theme, never a fixed hue: `Text` and
4// `Box` colour props resolve a theme key, so the pane follows the light, dark
5// or colour-blind theme the person chose (probed on Claude Code 2.1.295: the
6// keys below resolve, nested spans inside one `Text` keep their own, a
7// bordered `Box` takes one for its border, and an unknown key draws uncoloured
8// with no error). A colour names what a word means: a state the renderer
9// printed, a name, a heading, a word the host or the CLI sent. It is never a
10// threshold or a verdict of the meter's own, so a figure the engine reports
11// (the context, the cost, a plan window) carries none.
12//
13// Pure and Node-free: the hooks module runs in the engine with no Node, and
14// imports this file.
15
16export const PALETTE = Object.freeze({
17 // A state word the board printed: the recorded ok, the recorded failure, the cursor.
18 ok: 'success',
19 failed: 'error',
20 current: 'warning',
21 // A lane label and an agent's name.
22 identity: 'suggestion',
23 // The run header and the section headings.
24 accent: 'claude',
25 // A word the CLI or the host raised to be read: a banner, the quiet word, a running turn.
26 warn: 'warning',
27 // A refusal, and a turn the host ended without an answer.
28 alarm: 'error'
29})
30
31const DIM_WORDS = new Set(['pending', 'not reached', 'not recorded', 'per task'])
32
33/** The props a state word the board printed is drawn with: a hue for ok, failed and current, dim for the rest, else none. */
34export function stateProps(word) {
35 if (word === 'ok') return { color: PALETTE.ok }
36 if (word === 'failed') return { color: PALETTE.failed }
37 if (word === 'current') return { color: PALETTE.current }
38 if (DIM_WORDS.has(word)) return { dimColor: true }
39 return null
40}
41
42/**
43 * The props an agent's turn word is drawn with. No reason is a turn still
44 * running; `answer` is the host's ordinary end; any other word the host sends
45 * is a turn it ended without one. The caller shows the word itself verbatim.
46 */
47export function turnProps(reason) {
48 if (reason === null || reason === undefined) return { color: PALETTE.warn }
49 if (reason === 'answer') return { color: PALETTE.ok }
50 return { color: PALETTE.alarm }
51}
52lib/meter-quiet.mjs 46 lines1// The ship meter's quiet figure (show-quiet-time-and-reset-the-meter-on-clear
2// design D3): how long a live run has gone without activity, and the word the
3// meter shows once that reaches the published threshold.
4//
5// Pure and Node-free: `hooks/mod.mjs` imports it inside the engine, where there
6// is no Node, and `test/spine/mod-pins.test.mjs` walks the import. The two
7// numbers are `interlock limits`'s; neither this file nor the hooks module
8// restates them. The word names no cause: a run waiting on a plan window and a
9// run that hung read the same here, and the person tells them apart from the
10// windows the pane lists beside it.
11
12import { LIMITS } from './limits.mjs'
13
14const MINUTE_MS = 60 * 1000
15
16/** The period of the meter's interval, in milliseconds. */
17export const TICK_MS = LIMITS.meterTickMs
18
19/**
20 * Milliseconds from the last activity to now, never negative (a clock that went
21 * backwards reads as no wait). `null` unless both are finite numbers: a time
22 * the module could not read is no figure, never a guessed one.
23 *
24 * @param {unknown} lastActivityAt milliseconds since the epoch, or null
25 * @param {unknown} now milliseconds since the epoch, or null
26 * @returns {number|null}
27 */
28export function quietMs(lastActivityAt, now) {
29 if (!Number.isFinite(lastActivityAt) || !Number.isFinite(now)) return null
30 return Math.max(0, now - lastActivityAt)
31}
32
33/**
34 * `quiet <n> min` once `ms` reaches the threshold, `<n>` the whole minutes and
35 * `<1` under one; `null` below it, or for no figure.
36 *
37 * @param {number|null} ms what `quietMs` returned
38 * @param {number} [afterMs] the threshold; the published one unless a test names another
39 * @returns {string|null}
40 */
41export function quietWord(ms, afterMs = LIMITS.meterQuietAfterMs) {
42 if (!Number.isFinite(ms) || ms < afterMs) return null
43 const minutes = Math.floor(ms / MINUTE_MS)
44 return `quiet ${minutes < 1 ? '<1' : minutes} min`
45}
46lib/meter-time.mjs 44 lines1// The ship meter's time words: the clock reading a line was stamped with, and
2// how long a turn took, as a person reads them.
3//
4// Each reading is the engine's own (`$.clock.now()` in the hooks module), so a
5// time is either a real reading or absent: absent draws as nothing, never as a
6// guessed time. The wall-clock words are the local time of the machine the
7// engine runs on, from `Date`'s own local fields. The duration bands are unit
8// conversions (seconds, minutes, hours), not thresholds: no band is a verdict
9// on the figure it formats.
10//
11// Pure and Node-free: the hooks module runs in the engine with no Node, and
12// imports this file.
13
14const isTime = ms => typeof ms === 'number' && Number.isFinite(ms)
15const pad = n => String(n).padStart(2, '0')
16
17/** The local wall-clock time of a reading, `HH:MM:SS`; `''` for no reading. */
18export function clockText(ms) {
19 if (!isTime(ms)) return ''
20 const d = new Date(ms)
21 return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
22}
23
24/** The local date and time of a reading, `YYYY-MM-DD HH:MM:SS`; `''` for no reading. */
25export function stampText(ms) {
26 if (!isTime(ms)) return ''
27 const d = new Date(ms)
28 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${clockText(ms)}`
29}
30
31/**
32 * A duration in milliseconds as a person reads it: under a minute, seconds to
33 * one decimal, cut down (`41.2s`, `0.8s`, never `60.0s`); under an hour, whole
34 * minutes and seconds (`2m 14s`); else hours and minutes (`1h 03m`). `''` for
35 * no figure, a negative one or one that is not a finite number.
36 */
37export function durationText(ms) {
38 if (!isTime(ms) || ms < 0) return ''
39 if (ms < 60_000) return `${(Math.floor(ms / 100) / 10).toFixed(1)}s`
40 const seconds = Math.floor(ms / 1000)
41 if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${pad(seconds % 60)}s`
42 return `${Math.floor(seconds / 3600)}h ${pad(Math.floor((seconds % 3600) / 60))}m`
43}
44lib/meter-timeline.mjs 162 lines1// The ship meter's step timeline: what each step the CLI printed says, and the
2// order the pane lays the steps, the agents they spawned and the agents no
3// step names in.
4//
5// A step's words are its own fields, verbatim: the action, its position, how
6// many lanes a batch dispatched, a skipped verification's reason, a halt's
7// reason and the plan size a step relayed. No word here is a verdict, a cause
8// or a state the step did not carry.
9//
10// The order is pure data over plain inputs, so it is pinned under Node
11// (`test/spine/meter-timeline.test.mjs`) and the hooks module only draws it:
12//
13// - The top level is the launch, every step and every relay agent no step
14// names, laid by time. An entry with a time sorts by it, ties keeping their
15// input order (the launch, then the steps as they crossed, then the relays as
16// first seen); an entry with no time follows every timed one, in input order.
17// - Beneath a step, at depth 1, the agents it spawned: a spawn is joined to an
18// agent by its briefing hash, one agent per spawn in step order (an agent
19// beyond the spawns that carry its hash stays with the last of them). The
20// joined spawns are laid by their agent's first request time, by the same
21// rule; the spawns no agent has joined yet follow, `waiting`, in the step's
22// spawn order. A step's relay is drawn on the step's own line and nowhere
23// else.
24// - Last, the agents nothing names, under one head, in first-seen order.
25//
26// Pure and Node-free: the hooks module runs in the engine with no Node, and
27// imports this file.
28
29/** The actions that dispatch a batch of lanes. */
30export const BATCH_ACTIONS = new Set(['run-batch', 'test-wave'])
31
32const isInt = v => Number.isInteger(v)
33const isTime = v => typeof v === 'number' && Number.isFinite(v)
34const str = v => (typeof v === 'string' && v ? v : null)
35const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
36
37/** `batch <j>/<k>`, one-based, as the status line says it; `null` when the step carries no position. */
38export function batchText(batchIndex, batchCount) {
39 return isInt(batchIndex) && isInt(batchCount) ? `batch ${batchIndex + 1}/${batchCount}` : null
40}
41
42/** How many lanes run side by side: `N in parallel`, `1 lane`, or `null` for none. */
43export function laneCountText(n) {
44 if (!isInt(n) || n < 1) return null
45 return n === 1 ? '1 lane' : `${n} in parallel`
46}
47
48/** The waves a plan summary holds, its test wave counted; `null` for anything that is not a summary. */
49export function planWaveCount(plan) {
50 if (plan === null || typeof plan !== 'object' || !Array.isArray(plan.waves)) return null
51 return plan.waves.length + (plan.testWave ? 1 : 0)
52}
53
54/** A step's words, from its own fields only. */
55export function stepText(step) {
56 const s = step && typeof step === 'object' ? step : {}
57 const parts = [String(s.action)]
58 if (s.wave !== undefined && s.wave !== null) parts.push(`wave ${s.wave}`)
59 const batch = batchText(s.batchIndex, s.batchCount)
60 if (batch) parts.push(batch)
61 if (BATCH_ACTIONS.has(s.action)) {
62 const lanes = laneCountText(Array.isArray(s.spawns) ? s.spawns.length : 0)
63 if (lanes) parts.push(lanes)
64 }
65 if (s.action === 'verify' && s.skipped === true) parts.push(str(s.reason) ? `skipped: ${s.reason}` : 'skipped')
66 if (s.action === 'halt' && str(s.reason)) parts.push(s.reason)
67 if (isInt(s.planWaves)) parts.push(`plan: ${plural(s.planWaves, 'wave')}`)
68 return parts.join(' · ')
69}
70
71/** The entries with a time laid by it, ties in input order; then the entries with none, in input order. */
72function byTime(entries) {
73 const timed = entries.map((entry, i) => ({ entry, i })).filter(({ entry }) => isTime(entry.at))
74 timed.sort((a, b) => a.entry.at - b.entry.at || a.i - b.i)
75 return [...timed.map(({ entry }) => entry), ...entries.filter(entry => !isTime(entry.at))]
76}
77
78/**
79 * The timeline as ordered display entries, each `{ key, kind, at, depth, ... }`:
80 * `launch` (`name`), `step` (`step`), `spawn` (`step`, `spawn`, `agent` or
81 * `null` while it waits), `relay` (`agent`, a relay agent no step names),
82 * `unmatched-head`, and `unmatched` (`agent`). An agent's entry is keyed
83 * `agent-<id>`, a waiting spawn's `spawn-<n>-<label>`, a step's `step-<n>`.
84 *
85 * @param {{
86 * steps?: {n: number, at: number|null, action: string, wave?: unknown, waveIndex?: unknown, batchIndex?: unknown,
87 * batchCount?: unknown, skipped?: unknown, reason?: unknown, planWaves?: number|null, relayId?: string|null,
88 * spawns: {label: string, title: string, sha: string|null}[]}[],
89 * agents?: {id: string, sha?: string|null, relay?: string|null, firstAt?: number|null}[],
90 * launch?: {at: number|null, name?: string|null}|null
91 * }} input
92 * @returns {object[]}
93 */
94export function timeline({ steps = [], agents = [], launch = null } = {}) {
95 const relays = new Set(steps.map(step => str(step.relayId)).filter(Boolean))
96
97 // Each spawn carrying a hash, in step order, and the agents carrying it, first seen first.
98 const slots = new Map()
99 const spawnsOf = step => (Array.isArray(step.spawns) ? step.spawns : [])
100 steps.forEach((step, i) =>
101 spawnsOf(step).forEach((spawn, j) => {
102 const sha = str(spawn.sha)
103 if (!sha) return
104 if (!slots.has(sha)) slots.set(sha, [])
105 slots.get(sha).push({ pos: `${i}:${j}`, agents: [] })
106 })
107 )
108 const placed = new Set()
109 const counts = new Map()
110 for (const agent of agents) {
111 const sha = str(agent.sha)
112 if (!sha || relays.has(agent.id) || !slots.has(sha)) continue
113 const held = slots.get(sha)
114 const at = counts.get(sha) || 0
115 held[Math.min(at, held.length - 1)].agents.push(agent)
116 counts.set(sha, at + 1)
117 placed.add(agent.id)
118 }
119 const joinedBy = new Map()
120 for (const held of slots.values()) for (const slot of held) joinedBy.set(slot.pos, slot.agents)
121
122 const top = []
123 if (launch && typeof launch === 'object') {
124 top.push({ key: 'launch', kind: 'launch', at: isTime(launch.at) ? launch.at : null, depth: 0, name: str(launch.name) })
125 }
126 for (const step of steps) top.push({ key: `step-${step.n}`, kind: 'step', at: isTime(step.at) ? step.at : null, depth: 0, step })
127 const orphans = agents.filter(agent => str(agent.relay) && !relays.has(agent.id) && !placed.has(agent.id))
128 for (const agent of orphans) {
129 top.push({ key: `agent-${agent.id}`, kind: 'relay', at: isTime(agent.firstAt) ? agent.firstAt : null, depth: 0, agent })
130 placed.add(agent.id)
131 }
132
133 const out = []
134 for (const entry of byTime(top)) {
135 out.push(entry)
136 if (entry.kind !== 'step') continue
137 const i = steps.indexOf(entry.step)
138 const joined = []
139 const waiting = []
140 for (const [j, spawn] of spawnsOf(entry.step).entries()) {
141 const held = joinedBy.get(`${i}:${j}`) || []
142 if (!held.length) {
143 waiting.push({ key: `spawn-${entry.step.n}-${spawn.label}`, kind: 'spawn', at: null, depth: 1, step: entry.step, spawn, agent: null })
144 }
145 for (const agent of held) {
146 const at = isTime(agent.firstAt) ? agent.firstAt : null
147 joined.push({ key: `agent-${agent.id}`, kind: 'spawn', at, depth: 1, step: entry.step, spawn, agent })
148 }
149 }
150 out.push(...byTime(joined), ...waiting)
151 }
152
153 const unmatched = agents.filter(agent => !relays.has(agent.id) && !placed.has(agent.id))
154 if (unmatched.length) {
155 out.push({ key: 'agents-unmatched-head', kind: 'unmatched-head', at: null, depth: 0 })
156 for (const agent of unmatched) {
157 out.push({ key: `agent-${agent.id}`, kind: 'unmatched', at: isTime(agent.firstAt) ? agent.firstAt : null, depth: 1, agent })
158 }
159 }
160 return out
161}
162lib/meter-refusals.mjs 74 lines1// The ship meter's refusal words (speak-permission-prompts-and-guard-denials
2// design D4-D6): the four guard names a settings guard's denial is read by,
3// the slice of an errored result's text the meter repeats, and the pane lines.
4//
5// Pure and Node-free: `hooks/mod.mjs` imports it inside the engine, where there
6// is no Node, and `test/spine/mod-pins.test.mjs` walks the import and pins the
7// names against the four guards' own `GUARD` constants. Nothing here judges a
8// denial: the words are the guard's, and the ruling is `lib/launch-rule.mjs`'s.
9
10/** The settings guards whose denials the meter repeats, as each prints its name. */
11export const GUARD_NAMES = Object.freeze(['guard-tests', 'guard-tasks', 'guard-commit', 'guard-relaunch'])
12
13// Anywhere in the text, not only at its start: the engine puts
14// `PreToolUse:<tool> hook error: ` in front of a settings hook's reason (probe 2).
15const NAMED = new RegExp(`\\b(${GUARD_NAMES.join('|')}): `)
16
17/**
18 * The text from the first guard name on, or `null` for a text that names no
19 * guard (another plugin's deny, a command that failed) or is not a string.
20 *
21 * @param {unknown} text an errored tool result's `text`
22 * @returns {string|null}
23 */
24export function guardReason(text) {
25 if (typeof text !== 'string') return null
26 const m = NAMED.exec(text)
27 return m ? text.slice(m.index) : null
28}
29
30/**
31 * The guard that spoke `reason`, as `guardReason` sliced it; `null` for none.
32 *
33 * @param {unknown} reason
34 * @returns {string|null}
35 */
36export function guardOf(reason) {
37 if (typeof reason !== 'string') return null
38 const m = NAMED.exec(reason)
39 return m && m.index === 0 ? m[1] : null
40}
41
42/**
43 * `guard denials: <n> (<guard> <n>, …)` in the order each guard was first
44 * seen, or `null` when none was: the pane draws no line for nothing.
45 *
46 * @param {Map<string, number>|undefined} counts
47 * @returns {string|null}
48 */
49export function guardDenialLine(counts) {
50 if (!(counts instanceof Map) || counts.size === 0) return null
51 let total = 0
52 const parts = []
53 for (const [guard, n] of counts) {
54 total += n
55 parts.push(`${guard} ${n}`)
56 }
57 return `guard denials: ${total} (${parts.join(', ')})`
58}
59
60/**
61 * The launch guard's current ruling over the session's record, in the rule's
62 * own words. `now` is `null` only when the guard's facts could not be read,
63 * which allows the next launch (design D5).
64 *
65 * @param {{ decision: string, reason: string|null }} ruling what `decideLaunch` returned
66 * @param {number|null} now
67 * @returns {string}
68 */
69export function launchGuardLine(ruling, now) {
70 if (now === null) return 'launch guard: facts unreadable; the next launch is allowed'
71 if (ruling && ruling.decision === 'deny' && ruling.reason) return `launch guard: next launch refused: ${ruling.reason}`
72 return 'launch guard: next launch allowed'
73}
74lib/preflight-file.mjs 267 lines1// The session start's report file: what `hooks/preflight.mjs` leaves in
2// `.claude/ship/preflight.json`, and every line the hooks module draws from it
3// (show-preflight-and-interrupted-runs-at-session-start design D1, D5-D8).
4//
5// Two readers of one shape. The hook builds the report from what it already
6// holds: the doctor's verdict and checks, the interrupted-run notes it spoke
7// and marked, the halt resume cards it listed. The hooks module reads it at a
8// prompt the person submits and draws the `AbovePrompt` band and two panes.
9// Neither side derives a fact the other did not say: every word drawn is the
10// doctor's, the hook's or the card's, and the time is a date, never a
11// staleness verdict.
12//
13// Node-free, and importing only `./limits.mjs`, because the hooks module runs
14// in the engine with no Node (test/spine/mod-pins.test.mjs walks it). The
15// handoff pane's cap is read here and restated nowhere in the module.
16
17import { LIMITS } from './limits.mjs'
18
19/** Bump when the report's shape changes, so a reader can tell an older one. */
20export const PREFLIGHT_SCHEMA = 'interlock.preflight/1'
21
22/** Where the report lands, relative to the working root. Already gitignored with `.claude/ship/`. */
23export const PREFLIGHT_FILE = '.claude/ship/preflight.json'
24
25/** The most characters of a card the handoff pane renders: the `Markdown` element's own bound (design D7). */
26export const HANDOFF_PANE_CHARS = LIMITS.handoffPaneChars
27
28/** The SessionStart sources the host names; anything else is recorded as null. */
29const SOURCES = new Set(['startup', 'resume', 'clear', 'compact'])
30
31const str = v => (typeof v === 'string' && v ? v : null)
32const text = v => (typeof v === 'string' ? v : v === null || v === undefined ? '' : String(v))
33const list = v => (Array.isArray(v) ? v.filter(x => x && typeof x === 'object') : [])
34const count = v => (Number.isInteger(v) && v >= 0 ? v : 0)
35
36/** The report file's path under `cwd`, or the relative path the engine resolves under the session's directory. */
37export function preflightFilePath(cwd) {
38 if (typeof cwd !== 'string' || !cwd) return PREFLIGHT_FILE
39 return `${cwd.endsWith('/') ? cwd.slice(0, -1) : cwd}/${PREFLIGHT_FILE}`
40}
41
42function doctorOf(d) {
43 const doctor = d && typeof d === 'object' ? d : {}
44 const counts =
45 doctor.counts && typeof doctor.counts === 'object'
46 ? Object.fromEntries(['ok', 'warn', 'fail', 'skip'].map(k => [k, count(doctor.counts[k])]))
47 : null
48 return {
49 ran: doctor.ran === true,
50 parsed: doctor.parsed === true,
51 ok: typeof doctor.ok === 'boolean' ? doctor.ok : null,
52 counts,
53 // `evidence` is not copied: no reader draws it, and the module reads the file whole.
54 checks: list(doctor.checks).map(c => ({ id: text(c.id), status: text(c.status), detail: text(c.detail), fix: str(c.fix) }))
55 }
56}
57
58function notesOf(n) {
59 const notes = n && typeof n === 'object' ? n : {}
60 return {
61 spoken: list(notes.spoken).map(s => ({
62 runId: text(s.runId),
63 change: str(s.change),
64 stage: str(s.stage),
65 banner: text(s.banner),
66 marked: s.marked === true,
67 marks: list(s.marks).map(m => ({ root: text(m.root), marked: m.marked === true, reason: str(m.reason) }))
68 })),
69 unreadable: list(notes.unreadable).map(u => ({ file: text(u.file), reason: text(u.reason) }))
70 }
71}
72
73function cardsOf(c) {
74 const cards = c && typeof c === 'object' ? c : {}
75 return {
76 listed: list(cards.listed).map(k => ({ path: text(k.path), change: text(k.change), runId: text(k.runId), writtenAt: str(k.writtenAt) })),
77 archived: count(cards.archived),
78 unreadable: list(cards.unreadable).map(u => ({ path: text(u.path), reason: text(u.reason) })),
79 lookedIn: Array.isArray(cards.lookedIn) ? cards.lookedIn.filter(d => typeof d === 'string' && d) : []
80 }
81}
82
83/**
84 * The report, every field present: an absent fact is `null` or `[]`, never
85 * omitted (design D1). Pure: the caller passes the time it wrote at.
86 */
87export function buildPreflightReport(input) {
88 const i = input && typeof input === 'object' ? input : {}
89 return {
90 schema: PREFLIGHT_SCHEMA,
91 writtenAt: str(i.writtenAt),
92 source: SOURCES.has(i.source) ? i.source : null,
93 root: str(i.root),
94 stateHome: str(i.stateHome),
95 surface: str(i.surface),
96 message: typeof i.message === 'string' ? i.message : null,
97 doctor: doctorOf(i.doctor),
98 notes: notesOf(i.notes),
99 cards: cardsOf(i.cards)
100 }
101}
102
103/** `{ report }` from the file's text, or `{ problem }` naming why there is none. */
104export function readPreflightReport(raw) {
105 if (typeof raw !== 'string') return { problem: `not text: the read returned ${raw === null ? 'null' : typeof raw}` }
106 let value
107 try {
108 value = JSON.parse(raw)
109 } catch (err) {
110 return { problem: `not JSON: ${(err && err.message) || err}` }
111 }
112 if (!value || typeof value !== 'object' || Array.isArray(value)) return { problem: 'not an object' }
113 if (value.schema !== PREFLIGHT_SCHEMA) return { problem: `stamped ${JSON.stringify(value.schema)}, not ${PREFLIGHT_SCHEMA}` }
114 return { report: buildPreflightReport(value) }
115}
116
117const flagged = report => report.doctor.checks.filter(c => c.status === 'fail' || c.status === 'warn')
118
119/** Whether the band has anything to say: a fail or warn check, a doctor that did not run or parse, a spoken note, a card. */
120export function shouldDraw(report) {
121 return (
122 flagged(report).length > 0 ||
123 !report.doctor.ran ||
124 !report.doctor.parsed ||
125 report.notes.spoken.length > 0 ||
126 report.cards.listed.length > 0
127 )
128}
129
130/** One listed card's line: where it is, which change and run, when it was written. */
131export function cardLine(card) {
132 return `${card.path} · ${card.change} · run ${card.runId} · written ${card.writtenAt || 'at an unknown time'}`
133}
134
135/** The listed cards, newest written first; equal or unknown times keep the listing's order. */
136export function orderCards(report) {
137 const at = c => {
138 const t = Date.parse(c.writtenAt)
139 return Number.isNaN(t) ? -Infinity : t
140 }
141 return report.cards.listed
142 .map((card, i) => ({ card, i }))
143 .sort((a, b) => at(b.card) - at(a.card) || a.i - b.i)
144 .map(x => x.card)
145}
146
147const writtenLine = report => `preflight written ${report.writtenAt || 'at an unknown time'}`
148
149/**
150 * The band's lines, in order (design D6): the message's first line, each
151 * `fail` or `warn` check in the doctor's words, each spoken note's banner,
152 * each card with the command that shows it, and when the report was written.
153 * Empty when there is nothing to say.
154 */
155export function bandLines(report) {
156 if (!shouldDraw(report)) return []
157 const lines = []
158 const first = (report.message || '').split('\n')[0]
159 if (first) lines.push(first)
160 for (const c of flagged(report)) lines.push(`${c.status} ${c.id}: ${c.detail}`)
161 for (const n of report.notes.spoken) lines.push(n.banner)
162 for (const card of orderCards(report)) lines.push(`${cardLine(card)} — /interlock-handoff`)
163 lines.push(`${writtenLine(report)} · /interlock-preflight`)
164 return lines
165}
166
167/**
168 * The `/interlock-preflight` pane's lines: the message verbatim, every check
169 * with its fix lines indented as the hook prints them, the notes spoken at
170 * this session start with each root's mark, the cards and what was not
171 * listed, and every note and card the hook could not read. No empty line.
172 */
173export function preflightPaneLines(report) {
174 const lines = []
175 for (const line of (report.message || '').split('\n')) if (line) lines.push(line)
176 lines.push(writtenLine(report))
177
178 lines.push(report.doctor.ran ? (report.doctor.parsed ? 'checks' : 'checks: the doctor ran and its output could not be parsed') : 'checks: the doctor did not run')
179 for (const c of report.doctor.checks) {
180 lines.push(`${c.status} ${c.id}: ${c.detail}`)
181 if (c.fix) {
182 const [first, ...rest] = c.fix.split('\n')
183 lines.push(` fix: ${first}`)
184 for (const more of rest) if (more.trim()) lines.push(` ${more.trim()}`)
185 }
186 }
187
188 lines.push(report.notes.spoken.length ? 'interrupted runs spoken at this session start' : 'no interrupted run was spoken at this session start')
189 for (const n of report.notes.spoken) {
190 lines.push(n.banner)
191 for (const m of n.marks) lines.push(m.marked ? ` marked · ${m.root}` : ` not marked: ${m.reason || 'no reason given'}`)
192 }
193 for (const u of report.notes.unreadable) lines.push(`note unreadable: ${u.file}: ${u.reason}`)
194
195 const ordered = orderCards(report)
196 lines.push(ordered.length ? 'halt resume cards — /interlock-handoff' : 'no halt resume card is on disk for an open change')
197 for (const card of ordered) lines.push(cardLine(card))
198 if (report.cards.archived) lines.push(`${report.cards.archived} card(s) of archived changes not listed`)
199 for (const u of report.cards.unreadable) lines.push(`card unreadable: ${u.path}: ${u.reason}`)
200 for (const dir of report.cards.lookedIn) lines.push(`looked in: ${dir}`)
201 return lines
202}
203
204/** What a pane says with no report held: the file's path and why, or that it has not been read yet. */
205export function noReportText(path, problem) {
206 return `no preflight report is held for this session start: ${path}: ${problem || 'not read yet'}`
207}
208
209/** What the handoff pane says with a report and no card: that, and every directory looked in. */
210export function noCardText(report) {
211 return ['no halt resume card is on disk for an open change', ...report.cards.lookedIn.map(dir => `looked in: ${dir}`)]
212}
213
214// Every control character but tab and newline: the `Markdown` element refuses them.
215const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g
216
217/**
218 * A card's text as the handoff pane renders it: control characters made
219 * spaces, and when longer than `cap`, cut so that the text and a note naming
220 * the characters left out and the card's path fit within `cap` (design D7).
221 */
222export function clampCard(raw, path, cap = LIMITS.handoffPaneChars) {
223 const clean = text(raw).replace(CONTROL, ' ')
224 if (clean.length <= cap) return { text: clean, cut: 0 }
225 const note = n => `\n\n_… ${n} characters left out; the whole card is ${path}_`
226 // The note for the whole text is the longest the note can be, so the kept head plus any shorter note still fits.
227 let keep = Math.max(0, cap - note(clean.length).length)
228 const code = clean.charCodeAt(keep - 1)
229 if (keep > 0 && code >= 0xd800 && code <= 0xdbff) keep -= 1
230 const cut = clean.length - keep
231 return { text: (clean.slice(0, keep) + note(cut)).slice(0, cap), cut }
232}
233
234/** What the module holds between reads (design D8): the report or the problem, the hide flag, whether the problem was named. */
235export function freshStart() {
236 return { report: null, path: null, problem: null, hidden: false, named: false }
237}
238
239/**
240 * The module's next `start` after a read (design D5, D8), whether the band
241 * must be redrawn, and the one debug line to name, if any. A re-read of the
242 * report already held changes nothing, hide flag and all. A new report is
243 * held, and stays hidden only when it continues a report already held across
244 * a compaction. A problem drops the held report, since the file is no longer
245 * this session's, and is named once until a report is held again.
246 *
247 * @param {object} start what `freshStart` returns, as last adopted
248 * @param {{report?: object, problem?: string}} outcome `readPreflightReport`'s result, or a read's rejection as a problem
249 * @param {string} path the file's path, named on the debug line and the panes
250 * @returns {{start: object, changed: boolean, log: string|null}}
251 */
252export function adoptPreflight(start, outcome, path) {
253 const held = start && typeof start === 'object' ? start : freshStart()
254 const report = outcome && outcome.report
255 if (report) {
256 if (held.report && held.report.writtenAt === report.writtenAt && held.report.root === report.root) {
257 return { start: held, changed: false, log: null }
258 }
259 const hidden = held.report !== null && held.hidden && report.source === 'compact'
260 return { start: { report, path, problem: null, hidden, named: false }, changed: true, log: null }
261 }
262 const problem = (outcome && outcome.problem) || 'no report was read'
263 const log = held.named ? null : `interlock meter: no preflight report at ${path}: ${problem}`
264 const changed = held.report !== null || held.problem !== problem || held.path !== path
265 return { start: { report: null, path, problem, hidden: held.hidden, named: true }, changed, log }
266}
267lib/spec-meter.mjs 592 lines1// The spec meter's text rules (observe-the-spec-run-live design D1-D9).
2//
3// `/interlock:spec` has no CLI spine to draw from: it is a prose skill that
4// drives `openspec` and `interlock` through the Bash tool and writes its
5// artifacts with the Write tool. The hooks module draws it anyway, from what
6// already crossed the engine, and this module holds every rule it draws by:
7// which load starts a run (D1), which command lines are the flow's keyed lines
8// and how a compound line is cut (D3), where a line names its change (D2), the
9// fields each line's JSON must carry (D3), which writes are counted (D4), and
10// every word the status line and the pane say (D6). The module keeps the
11// record and makes the engine calls; nothing here does either.
12//
13// Every word drawn is the CLI's: a field it printed, composed from the field
14// and never from its text, a count of events the engine raised, or a time the
15// engine's clock gave. A line that cannot be read is said to be unread, with
16// its first line. Nothing here compares a count against a number, names a
17// threshold, or reads OpenSpec's own roll-up of the artifact ladder: the table
18// is the ladder as printed, and the record is the artifacts on disk, the
19// findings file and the gate's exit, which the pane says.
20//
21// It runs inside the engine through `hooks/mod.mjs`, so it is pure and imports
22// nothing (`test/spine/mod-pins.test.mjs` walks it). It does not import the
23// CLI's own modules for their shapes: they reach `node:fs`, and re-deriving a
24// verdict from them is what the meter must not do (D9).
25
26export const SPEC_PANE = 'interlock-spec'
27export const SPEC_TITLE = 'Interlock spec'
28export const NO_SPEC_LINE = 'no spec run is live in this session'
29export const SPEC_RECORD_LINE = "the artifacts on disk, the findings file and the gate's exit are the record"
30export const SPEC_UNPLACED_TOAST = '/interlock-spec opens the spec meter'
31export const UNKNOWN_CHANGE = 'change: unknown until named'
32export const SPEC_REPLACED_LINE = 'interlock spec: a new spec run replaced the previous one'
33
34/**
35 * The flow's keyed lines, by their command words, in the flow's order: the
36 * spec skill's drift, change, ladder, ledger, validate, autonomy and checkpoint
37 * lines, with review-artifacts' gate and continuity's readiness where they
38 * fall (D3). The skill-lines pin reads the three skills against this list.
39 */
40export const KEYED_LINES = Object.freeze([
41 Object.freeze({ kind: 'drift', words: Object.freeze(['interlock', 'drift']) }),
42 Object.freeze({ kind: 'new-change', words: Object.freeze(['openspec', 'new', 'change']) }),
43 Object.freeze({ kind: 'status', words: Object.freeze(['openspec', 'status']) }),
44 Object.freeze({ kind: 'ledger', words: Object.freeze(['interlock', 'ledger']) }),
45 Object.freeze({ kind: 'validate', words: Object.freeze(['interlock', 'validate']) }),
46 Object.freeze({ kind: 'gate', words: Object.freeze(['interlock', 'gate']) }),
47 Object.freeze({ kind: 'autonomy', words: Object.freeze(['interlock', 'autonomy', 'record']) }),
48 Object.freeze({ kind: 'autonomy', words: Object.freeze(['interlock', 'autonomy', 'clean']) }),
49 Object.freeze({ kind: 'ready', words: Object.freeze(['interlock', 'ready']) }),
50 Object.freeze({ kind: 'checkpoint', words: Object.freeze(['interlock', 'notify', 'checkpoint']) })
51])
52
53/** Lines whose argv may name the change and whose result is never read (D2). */
54export const NAMING_ONLY = Object.freeze([Object.freeze(['openspec', 'instructions'])])
55
56/** The kinds whose result text is read; the rest are read by their argv alone (D3). */
57export const TEXT_KINDS = Object.freeze(['status', 'drift', 'ledger', 'validate', 'gate', 'ready'])
58
59/** The skills a load can be, by the name the engine carries (D1, as probe 1 left it). */
60const SKILL_ROLES = new Map([
61 ['interlock:spec', 'spec'],
62 ['interlock:explore', 'explore'],
63 ['interlock:review-artifacts', 'review-artifacts']
64])
65
66/** The line the engine puts before a failed command's output (probe 3). */
67const ENGINE_PREFIX = /^Exit code \d+$/
68
69const UNPARSED_CUT = 200
70
71const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v)
72const isString = v => typeof v === 'string' && v !== ''
73const countOf = v => (Array.isArray(v) ? v.length : null)
74const shown = v => (v === null || v === undefined ? '?' : String(v))
75
76/**
77 * What a skill name loads: `spec`, `explore`, `review-artifacts`, or `null`.
78 * Only the plugin's namespaced names count, a leading `/` dropped: neither
79 * event that carries a load carries the skill's text, so a bare `spec` could
80 * be anyone's (D1).
81 */
82export function skillRole(name) {
83 if (typeof name !== 'string') return null
84 return SKILL_ROLES.get(name.trim().replace(/^\//, '')) || null
85}
86
87/** The skill a submitted prompt names by its first word (`/interlock:spec …`), or `null`. Nothing else is read. */
88export function promptSkill(text) {
89 if (typeof text !== 'string') return null
90 const m = text.match(/^\s*\/([A-Za-z0-9_.-]+(?::[A-Za-z0-9_.-]+)?)(?=\s|$)/)
91 return m ? m[1] : null
92}
93
94/**
95 * A shell command's words, unquoted, up to the first unquoted `|`, `;`, `&`,
96 * `>` or `<`. Each word is `{ value, plain }`, `plain` when no part of it was
97 * quoted. Never throws.
98 */
99export function argvOf(command) {
100 if (typeof command !== 'string') return []
101 const words = []
102 let value = ''
103 let plain = true
104 let open = false
105 let quote = null
106 const end = () => {
107 if (open) words.push({ value, plain })
108 value = ''
109 plain = true
110 open = false
111 }
112 for (let i = 0; i < command.length; i++) {
113 const c = command[i]
114 if (quote === "'") {
115 if (c === "'") quote = null
116 else value += c
117 continue
118 }
119 if (quote === '"') {
120 if (c === '"') quote = null
121 else if (c === '\\' && i + 1 < command.length && '"\\$`'.includes(command[i + 1])) value += command[++i]
122 else value += c
123 continue
124 }
125 if (c === "'" || c === '"') {
126 quote = c
127 plain = false
128 open = true
129 } else if (c === '\\' && i + 1 < command.length) {
130 value += command[++i]
131 open = true
132 } else if ('|;&><'.includes(c)) {
133 break
134 } else if (/\s/.test(c)) {
135 end()
136 } else {
137 value += c
138 open = true
139 }
140 }
141 end()
142 return words
143}
144
145/** Whether `words` begins with the plain words `prefix`. */
146const startsWith = (words, prefix) => prefix.every((w, i) => words[i] && words[i].plain && words[i].value === w)
147
148/**
149 * The kind of one command line by its first words, after leading whitespace:
150 * a keyed kind, `naming-only`, or `null`. It keys on the command words and not
151 * on `--json`, so a line on the previous skill text is classified and shown
152 * unparsed rather than ignored (D3).
153 */
154export function lineKind(command) {
155 const words = argvOf(command)
156 for (const { kind, words: prefix } of KEYED_LINES) if (startsWith(words, prefix)) return kind
157 for (const prefix of NAMING_ONLY) if (startsWith(words, prefix)) return 'naming-only'
158 return null
159}
160
161/**
162 * A command cut at every unquoted `&&`, `||`, `;` and line break, each segment
163 * trimmed; past an unquoted `<<` the rest is one segment, so a heredoc's body
164 * is never read as commands. Never throws.
165 */
166export function segmentsOf(command) {
167 if (typeof command !== 'string') return []
168 const out = []
169 let start = 0
170 let quote = null
171 for (let i = 0; i < command.length; i++) {
172 const c = command[i]
173 if (quote) {
174 if (c === quote) quote = null
175 else if (c === '\\' && quote === '"') i++
176 continue
177 }
178 if (c === "'" || c === '"') quote = c
179 else if (c === '\\') i++
180 else if (c === '<' && command[i + 1] === '<') break
181 else if (c === ';' || c === '\n') {
182 out.push(command.slice(start, i))
183 start = i + 1
184 } else if ((c === '&' && command[i + 1] === '&') || (c === '|' && command[i + 1] === '|')) {
185 out.push(command.slice(start, i))
186 start = i + 2
187 i++
188 }
189 }
190 out.push(command.slice(start))
191 return out.map(s => s.trim()).filter(Boolean)
192}
193
194/**
195 * The keyed segments of a command, in order: `{ kind, segment }` for each
196 * segment `lineKind` classifies, `naming-only` included (D3, the compound
197 * split probe 1 asked for).
198 */
199export function specLines(command) {
200 return segmentsOf(command)
201 .map(segment => ({ kind: lineKind(segment), segment }))
202 .filter(line => line.kind !== null)
203}
204
205/** A name the CLI could have meant: a non-empty string with no unexpanded `$`, backtick or `<`. */
206const nameOf = v => (isString(v) && !/[$`<]/.test(v) ? v : null)
207
208/** The value of `--<flag> v` or `--<flag>=v` in `words`, or `null`. */
209function flagValue(words, flag) {
210 for (let i = 0; i < words.length; i++) {
211 const w = words[i].value
212 if (w === `--${flag}`) return words[i + 1] ? words[i + 1].value : null
213 if (w.startsWith(`--${flag}=`)) return w.slice(flag.length + 3)
214 }
215 return null
216}
217
218/** The positional at `index` when it is not a flag. */
219const positional = (words, index) => (words[index] && !words[index].value.startsWith('-') ? words[index].value : null)
220
221/**
222 * The change a line names (D2): from its argv first, the positional after
223 * `openspec new change`, `--change` on any `openspec` line, the name after
224 * `interlock ledger|validate|ready` and `interlock notify checkpoint`,
225 * `--metrics` on `interlock gate`; then a non-empty `changeName` or `change`
226 * in the parsed result. Never a path, a prompt or another tool's input.
227 */
228export function changeOf(kind, command, value) {
229 const words = argvOf(command)
230 let named = null
231 if (kind === 'new-change') named = positional(words, 3)
232 else if (words[0] && words[0].value === 'openspec') named = flagValue(words, 'change')
233 else if (kind === 'ledger' || kind === 'validate' || kind === 'ready') named = positional(words, 2)
234 else if (kind === 'checkpoint') named = positional(words, 3)
235 else if (kind === 'gate') named = flagValue(words, 'metrics')
236 if (nameOf(named)) return named
237 if (isObject(value)) return nameOf(value.changeName) || nameOf(value.change)
238 return null
239}
240
241/**
242 * The one JSON object a command printed, as `emit` prints it: from the first
243 * line that is exactly `{` to the last that is exactly `}`, so an engine
244 * prefix line and a stderr tail do not defeat it. `{ value }` or `{ problem }`.
245 */
246export function findJson(text) {
247 if (typeof text !== 'string') return { problem: 'no result text' }
248 const lines = text.split('\n').map(l => l.replace(/\r$/, ''))
249 const first = lines.indexOf('{')
250 const last = lines.lastIndexOf('}')
251 if (first === -1 || last < first) return { problem: 'output not JSON' }
252 try {
253 const value = JSON.parse(lines.slice(first, last + 1).join('\n'))
254 return isObject(value) ? { value } : { problem: 'output not JSON' }
255 } catch {
256 return { problem: 'output not JSON' }
257 }
258}
259
260/** The text a result carries: the command's own stdout when the engine kept it, else what the model reads. */
261function resultText(r) {
262 if (!isObject(r)) return null
263 if (isObject(r.result) && typeof r.result.stdout === 'string') return r.result.stdout
264 return typeof r.text === 'string' ? r.text : null
265}
266
267/** The first non-empty line of a result, past the engine's failed-command prefix, cut. */
268function firstLine(text) {
269 const lines = (typeof text === 'string' ? text : '').split('\n').map(l => l.trim())
270 const line = lines.find(l => l && !ENGINE_PREFIX.test(l)) || ''
271 return line.length > UNPARSED_CUT ? line.slice(0, UNPARSED_CUT) : line
272}
273
274/** An entry for a line that ran and was not read: its first line and why (D3). */
275export const unparsedEntry = (kind, text, reason) => ({ kind, unparsed: firstLine(text), reason })
276
277const isBool = v => typeof v === 'boolean'
278const isCount = v => Number.isInteger(v)
279
280/** Each text kind's needed fields, and what it keeps of them (D3). */
281const READERS = {
282 status(v) {
283 const ok = Array.isArray(v.artifacts) && v.artifacts.every(a => isObject(a) && isString(a.id) && isString(a.status))
284 if (!ok) return { missing: ['artifacts'] }
285 return {
286 entry: {
287 artifacts: v.artifacts.map(a => ({
288 id: a.id,
289 status: a.status,
290 outputPath: isString(a.outputPath) ? a.outputPath : null,
291 requires: Array.isArray(a.requires) ? a.requires.filter(isString) : []
292 })),
293 applyRequires: Array.isArray(v.applyRequires) ? v.applyRequires.filter(isString) : null,
294 changeName: isString(v.changeName) ? v.changeName : null,
295 schemaName: isString(v.schemaName) ? v.schemaName : null
296 }
297 }
298 },
299 drift(v) {
300 const stale = isObject(v.stale) ? v.stale : {}
301 const missing = [
302 ...(Array.isArray(v.unarchived) ? [] : ['unarchived']),
303 ...(Array.isArray(stale.broken) ? [] : ['stale.broken']),
304 ...(Array.isArray(stale.aging) ? [] : ['stale.aging'])
305 ]
306 if (missing.length) return { missing }
307 return { entry: { unarchived: v.unarchived.length, broken: stale.broken.length, aging: stale.aging.length } }
308 },
309 ledger(v) {
310 if (!isBool(v.blocking)) return { missing: ['blocking'] }
311 const entry = { blocking: v.blocking }
312 for (const f of ['needsHuman', 'invalidCount']) if (isCount(v[f])) entry[f] = v[f]
313 for (const f of ['missing', 'unparseable']) if (isBool(v[f])) entry[f] = v[f]
314 return { entry }
315 },
316 validate(v) {
317 const missing = [...(isBool(v.ready) ? [] : ['ready']), ...(Array.isArray(v.problems) ? [] : ['problems'])]
318 if (missing.length) return { missing }
319 return { entry: { ready: v.ready, problems: v.problems.map(p => (typeof p === 'string' ? p : JSON.stringify(p))) } }
320 },
321 gate(v) {
322 const counts = isObject(v.counts) ? v.counts : {}
323 const missing = [
324 ...(isBool(v.passed) ? [] : ['passed']),
325 ...['blocker', 'warning', 'suggestion'].filter(s => !isCount(counts[s])).map(s => `counts.${s}`),
326 ...(Array.isArray(v.malformed) ? [] : ['malformed'])
327 ]
328 if (missing.length) return { missing }
329 const entry = {
330 passed: v.passed,
331 counts: { blocker: counts.blocker, warning: counts.warning, suggestion: counts.suggestion },
332 malformed: v.malformed.length,
333 metrics: null
334 }
335 if (isObject(v.metrics)) {
336 entry.metrics = {
337 written: isBool(v.metrics.written) ? v.metrics.written : null,
338 path: isString(v.metrics.path) ? v.metrics.path : null,
339 reason: isString(v.metrics.reason) ? v.metrics.reason : null
340 }
341 }
342 return { entry }
343 },
344 ready(v) {
345 const missing = [...(isBool(v.ready) ? [] : ['ready']), ...(Array.isArray(v.blockers) ? [] : ['blockers'])]
346 if (missing.length) return { missing }
347 return { entry: { ready: v.ready, blockers: v.blockers.length } }
348 }
349}
350
351/** The kinds a name that does not resolve answers `{ error, candidates }` for (`requireChange`). */
352const RESOLVING = new Set(['ledger', 'validate', 'ready'])
353const VERDICT = { ledger: 'blocking', validate: 'ready', ready: 'ready' }
354
355/**
356 * What one keyed line's result says, as an entry the record keeps, with the
357 * change it names as `name` (D2, D3). Only the fields a kind needs are kept:
358 * a result lacking them, or not JSON, or too large to have been kept inline,
359 * is an unparsed entry with its first line and the reason. `new-change`,
360 * `autonomy` and `checkpoint` read no text; `autonomy` keeps only `record` or
361 * `clean`. OpenSpec's own roll-up of the ladder is never read.
362 */
363export function readResult(kind, command, r) {
364 if (kind === 'new-change' || kind === 'checkpoint') return { kind, name: changeOf(kind, command, null) }
365 if (kind === 'autonomy') {
366 const words = argvOf(command)
367 return { kind, command: words[2] ? words[2].value : null, name: null }
368 }
369 if (!READERS[kind]) return null
370 if (isObject(r) && isObject(r.result) && isString(r.result.persistedOutputPath)) {
371 return { ...unparsedEntry(kind, resultText(r), 'output too large to read inline'), name: changeOf(kind, command, null) }
372 }
373 const text = resultText(r)
374 if (text === null) return { ...unparsedEntry(kind, '', 'no result text'), name: changeOf(kind, command, null) }
375 const { value, problem } = findJson(text)
376 if (!value) return { ...unparsedEntry(kind, text, problem), name: changeOf(kind, command, null) }
377 const name = changeOf(kind, command, value)
378 if (RESOLVING.has(kind) && isString(value.error) && Array.isArray(value.candidates) && !(VERDICT[kind] in value)) {
379 return { kind, error: value.error, candidates: value.candidates.filter(isString), name }
380 }
381 const { entry, missing } = READERS[kind](value)
382 if (!entry) return { ...unparsedEntry(kind, text, `fields missing: ${missing.join(', ')}`), name }
383 return { kind, ...entry, name }
384}
385
386/**
387 * Which write a path is (D4): an artifact under `openspec/changes/<dir>/`
388 * (`archive` excluded), the explore brief, or a review-artifacts findings
389 * file; otherwise `null`. A path files a write under its directory and never
390 * names the change.
391 */
392export function writeOf(path) {
393 if (typeof path !== 'string' || !path) return null
394 const p = path.replace(/\\/g, '/')
395 const artifact = p.match(/(?:^|\/)openspec\/changes\/([^/]+)\/(.+)$/)
396 if (artifact) return artifact[1] === 'archive' ? null : { kind: 'artifact', change: artifact[1], file: artifact[2] }
397 if (/(?:^|\/)\.claude\/handoff\/explore-[^/]*\.md$/.test(p)) return { kind: 'brief', path }
398 const findings = p.match(/(?:^|\/)\.claude\/metrics\/review-artifacts-(.+)-\d{8}-\d{6}\.json$/)
399 if (findings) return { kind: 'findings', change: findings[1], path }
400 return null
401}
402
403/** The status line's stage for an entry, in the CLI's words (D6's table). */
404export function stageText(entry, spawned = 0) {
405 if (!isObject(entry)) return null
406 const { kind } = entry
407 if (typeof entry.unparsed === 'string') return `${kind} (unparsed)`
408 if (isString(entry.error)) return `${kind}: change not resolved (candidates ${countOf(entry.candidates) ?? 0})`
409 switch (kind) {
410 case 'explore':
411 return spawned ? `explore (${spawned} investigators)` : 'explore'
412 case 'review-artifacts':
413 return 'review-artifacts'
414 case 'new-change':
415 return 'new change'
416 case 'status': {
417 const done = entry.artifacts.filter(a => a.status === 'done').length
418 const next = entry.artifacts.find(a => a.status === 'ready')
419 return `status ${done}/${entry.artifacts.length} done${next ? ` · next: ${next.id}` : ''}`
420 }
421 case 'drift':
422 return `drift (unarchived ${entry.unarchived} · broken ${entry.broken} · aging ${entry.aging})`
423 case 'ledger':
424 if (entry.missing === true) return 'ledger missing'
425 if (entry.unparseable === true) return 'ledger unparseable'
426 return `ledger ${entry.blocking ? 'blocking' : 'clear'} (needs_human ${shown(entry.needsHuman)} · invalid ${shown(entry.invalidCount)})`
427 case 'validate':
428 return entry.ready ? 'validate READY' : `validate NOT READY (problems ${entry.problems.length})`
429 case 'gate': {
430 const notes = [
431 ...(entry.malformed ? [`malformed ${entry.malformed}`] : []),
432 ...(entry.metrics && entry.metrics.written === false ? ['metrics not written'] : [])
433 ]
434 if (entry.passed) return `gate PASS${notes.length ? ` (${notes.join(' · ')})` : ''}`
435 const { blocker, warning } = entry.counts
436 return `gate BLOCKED (${[`blocker ${blocker}`, `warning ${warning}`, ...notes].join(' · ')})`
437 }
438 case 'autonomy':
439 return `autonomy ${entry.command || '?'}`
440 case 'ready':
441 return entry.ready ? 'ready true' : `ready false (blockers ${entry.blockers})`
442 case 'checkpoint':
443 return 'checkpoint'
444 default:
445 return null
446 }
447}
448
449/** `interlock spec: <change> · <stage>`, the stage omitted until there is one (D6). */
450export function specStatusText(spec) {
451 const stage = stageText(spec.stage, spec.explore.spawned)
452 return `interlock spec: ${spec.current || UNKNOWN_CHANGE}${stage ? ` · ${stage}` : ''}`
453}
454
455const iso = at => (Number.isFinite(at) ? new Date(at).toISOString() : null)
456
457/** A result line on the pane, by its JSON names, or why it is absent (D6). */
458function resultLine(kind, entry) {
459 if (!entry) return `${kind}: not run yet`
460 if (typeof entry.unparsed === 'string') return `${kind}: not read (${entry.reason}) · ${entry.unparsed}`
461 if (isString(entry.error)) return `${kind}: change not resolved · candidates ${entry.candidates.join(', ') || 'none'}`
462 switch (kind) {
463 case 'drift':
464 return `drift: unarchived ${entry.unarchived} · broken ${entry.broken} · aging ${entry.aging}`
465 case 'ledger':
466 return `ledger: ${['blocking', 'needsHuman', 'invalidCount', 'missing', 'unparseable']
467 .filter(f => f in entry)
468 .map(f => `${f} ${entry[f]}`)
469 .join(' · ')}`
470 case 'validate':
471 return `validate: ready ${entry.ready} · problems ${entry.problems.length}`
472 case 'gate': {
473 const { blocker, warning, suggestion } = entry.counts
474 const m = entry.metrics
475 const metrics = !m
476 ? ''
477 : m.written === false
478 ? ` · metrics not written (${m.reason || 'no reason given'})`
479 : m.path
480 ? ` · metrics ${m.path}`
481 : ''
482 return `gate: passed ${entry.passed} · blocker ${blocker} · warning ${warning} · suggestion ${suggestion} · malformed ${entry.malformed}${metrics}`
483 }
484 case 'ready':
485 return `ready: ready ${entry.ready} · blockers ${entry.blockers}`
486 default:
487 return `${kind}: not run yet`
488 }
489}
490
491/**
492 * The `/interlock-spec` pane as keyed lines, `{ key, text, bold?, dim? }`
493 * (D6): the module maps them onto the engine's `Box` and `Text` and nothing
494 * else. No row carries a tick, a colour or a threshold.
495 */
496export function specPaneLines(spec) {
497 if (!spec || spec.phase === 'idle') return [{ key: 'spec-none', text: NO_SPEC_LINE, dim: true }]
498 const change = spec.current ? spec.changes.get(spec.current) || {} : {}
499 const status = change.status
500 const out = []
501 const schema = status && !status.unparsed && !status.error && status.schemaName
502 out.push({ key: 'spec-header', text: `${spec.current || UNKNOWN_CHANGE}${schema ? ` · schema ${schema}` : ''}`, bold: true })
503 if (!status || typeof status.unparsed === 'string' || isString(status.error)) {
504 out.push({ key: 'spec-status', text: resultLine('status', status) })
505 } else {
506 for (const a of status.artifacts) out.push({ key: `spec-artifact-${a.id}`, text: `${a.id} · ${a.status} · ${a.outputPath || '?'}` })
507 if (status.applyRequires) out.push({ key: 'spec-apply-requires', text: `applyRequires: ${status.applyRequires.join(', ') || 'none'}` })
508 }
509 const spawned = spec.explore.spawned
510 out.push({
511 key: 'spec-explore',
512 text: `explore: ${spawned ? `${spawned} investigators spawned` : 'none reported by this host'} · ${
513 spec.explore.brief ? `brief ${spec.explore.brief}` : 'no brief written yet'
514 }`
515 })
516 out.push({ key: 'spec-drift', text: resultLine('drift', spec.drift) })
517 for (const kind of ['ledger', 'validate', 'gate', 'ready']) {
518 out.push({ key: `spec-${kind}`, text: resultLine(kind, change[kind]) })
519 if (kind === 'validate' && change.validate && Array.isArray(change.validate.problems)) {
520 change.validate.problems.forEach((p, i) => out.push({ key: `spec-validate-problem-${i}`, text: p }))
521 }
522 }
523 out.push({ key: 'spec-autonomy', text: `autonomy: ${spec.autonomy || 'not run yet'}` })
524 out.push({ key: 'spec-writes', text: 'writes', bold: true })
525 const writes = spec.current ? spec.writes.get(spec.current) : null
526 if (!writes || !writes.size) out.push({ key: 'spec-writes-none', text: 'no artifact writes yet', dim: true })
527 for (const [file, w] of writes || []) {
528 out.push({ key: `spec-write-${file}`, text: `${file} · written ${w.count}× · ${iso(w.at) ? `last ${iso(w.at)}` : 'last time unknown'}` })
529 }
530 const findings = spec.current ? spec.findings.get(spec.current) : null
531 if (findings) out.push({ key: 'spec-findings', text: `findings file: ${findings}` })
532 out.push({ key: 'spec-last-activity', text: iso(spec.lastActivityAt) ? `last activity ${iso(spec.lastActivityAt)}` : 'last activity unknown' })
533 if (spec.checkpointCrossed) {
534 out.push({
535 key: 'spec-checkpoint',
536 text: iso(spec.checkpointAt) ? `checkpoint reached ${iso(spec.checkpointAt)}` : 'checkpoint reached, time unknown'
537 })
538 }
539 if (spec.handedOver) {
540 out.push({
541 key: 'spec-handover',
542 text: iso(spec.handedOverAt) ? `handed over to ship at ${iso(spec.handedOverAt)}` : 'handed over to ship, time unknown'
543 })
544 }
545 const others = spec.order.filter(name => name !== spec.current)
546 if (others.length) out.push({ key: 'spec-other-changes', text: `other changes named this run: ${others.join(', ')}` })
547 out.push({ key: 'spec-record', text: SPEC_RECORD_LINE, dim: true })
548 return out
549}
550
551/** The debug line for a keyed line that crossed when no spec run could draw it (D3). */
552export function lateLineText(kind, phase) {
553 return phase === 'checkpoint'
554 ? `interlock spec: a ${kind} line crossed after the checkpoint; not drawn`
555 : `interlock spec: a ${kind} line crossed with no live spec run; not drawn`
556}
557
558/** The debug line for a keyed line's result that was not read (D3). */
559export function unreadLineText(entry, command) {
560 return `interlock spec: ${entry.kind} result not read (${entry.reason}): ${String(command).slice(0, 80)}`
561}
562
563/**
564 * A spec run record (D7): `phase` is `idle`, `live`, `checkpoint` or
565 * `handed-over`. `changes` holds each named change's results; the unnamed
566 * slot is `null`'s, never drawn. Writes and findings are filed by directory.
567 */
568export function freshSpec(flags = {}) {
569 return {
570 phase: 'idle',
571 current: null,
572 changes: new Map(),
573 order: [],
574 explore: { spawned: 0, brief: null },
575 drift: null,
576 autonomy: null,
577 stage: null,
578 writes: new Map(),
579 findings: new Map(),
580 startedAt: null,
581 lastActivityAt: null,
582 checkpointCrossed: false,
583 checkpointAt: null,
584 handedOver: false,
585 handedOverAt: null,
586 clockFailed: false,
587 afterBoundary: flags.afterBoundary === true,
588 // Whether a keyed line with no run to draw it has been named on the debug log.
589 lateNamed: false
590 }
591}
592lib/lane.mjs 177 lines1// The lane rule (draw-wave-plan-and-handoff-graph-from-the-cli design D3): the
2// tier, label, title, model and effort a lane dispatches under, the cut that
3// fits a drawn row to a width, and the one reader of a task's `dependsOn`.
4//
5// Moved out of `lib/waves.mjs` unchanged, because that module imports
6// `node:crypto` and the ship meter's hooks module, which runs in the engine with
7// no Node, needs the same names the planner gives a lane. `lib/waves.mjs`
8// re-exports the lane functions it always exported, so its importers are
9// unchanged.
10//
11// Pure and Node-free: `test/spine/draw-plan.test.mjs` walks this closure.
12
13import { EFFORT, LANE_CAPS } from './limits.mjs'
14
15/** The tier a lane dispatches on: the highest among its tasks. */
16export function laneTier(lane) {
17 return lane.reduce((m, t) => (Number.isInteger(t.tier) && t.tier > m ? t.tier : m), 0)
18}
19
20/**
21 * The label a lane runs under — the name its agent, its trajectory line and its
22 * briefing file all carry.
23 *
24 * Stable across replays, so a resumed run cache-hits the lane it already ran.
25 * This derivation was written three times (the workflow script, the ACP driver,
26 * and `bin/interlock`'s own trajectory logger); it is stated here once because a
27 * trajectory line and the agent it describes must carry one name, not two.
28 */
29export function laneLabel(lane) {
30 return lane.length === 1 ? lane[0].id : `${lane[0].id}+${lane.length - 1}`
31}
32
33/** Separates a spawn's label from the words its title adds. */
34export const TITLE_SEPARATOR = ' · '
35
36/** The longest title a lane displays under, ellipsis included. */
37const TITLE_MAX = 48
38
39/**
40 * The words a lane's title is built from: the first six words of a task
41 * description, stripped of markdown emphasis and trailing punctuation; '' when
42 * there are none. A fixed point — the gist of a gist is itself — so a plan
43 * summary that stores it in place of the description titles every lane exactly
44 * as the plan does (draw-the-wave-board-in-the-meter-pane design D3).
45 *
46 * @param {unknown} description
47 * @returns {string}
48 */
49export function titleGist(description) {
50 const text = typeof description === 'string' ? description : ''
51 const words = text.replace(/[`*_]/g, '').replace(/\s+/g, ' ').trim().split(' ').slice(0, 6).join(' ')
52 return words.replace(/[\s.,;:—-]+$/, '')
53}
54
55/**
56 * What a lane is called in words: `task 1.1` for a one-task lane, `tasks 1.1+2`
57 * for a lane of several. The role word names what the agent is doing for a
58 * reader of /workflows and the meter, where a bare `1.5` reads as an outline
59 * sub-number. Display only: the join key stays `laneLabel`.
60 */
61export function laneName(lane) {
62 return `${lane.length === 1 ? 'task' : 'tasks'} ${laneLabel(lane)}`
63}
64
65/**
66 * The words a lane's title adds after its name: the first words of its first
67 * task's description, cut so that label + separator + gist fits TITLE_MAX
68 * (ellipsis included); '' when the description says nothing. The role word does
69 * not count against the cap, so a gist is cut exactly where it always was.
70 */
71export function laneGist(lane) {
72 const gist = titleGist(lane[0] && lane[0].description)
73 if (!gist) return ''
74 const head = `${laneLabel(lane)}${TITLE_SEPARATOR}`
75 const full = `${head}${gist}`
76 const cut = full.length <= TITLE_MAX ? full : `${full.slice(0, TITLE_MAX - 1).trimEnd()}…`
77 return cut.slice(head.length)
78}
79
80/**
81 * The name a lane is SHOWN under — its role word and label, then the first words
82 * of its first task's description: `tasks 1.1+5 · Add the relaunch guard to…`.
83 * The role word names what the agent is doing for a reader of /workflows and the
84 * meter; the cap is on the label and gist, so a gist is cut where it always was.
85 *
86 * Display only. Everything that joins on a lane — its briefing file, worktree,
87 * trajectory line, record-batch fold — keys on `laneLabel`, because a key built
88 * from prose would change whenever a task was reworded. It is still a pure
89 * function of the lane, so a replay displays, and cache-hits, the same agent.
90 */
91export function laneTitle(lane) {
92 const gist = laneGist(lane)
93 return gist ? `${laneName(lane)}${TITLE_SEPARATOR}${gist}` : laneName(lane)
94}
95
96/**
97 * One task's declared dependency edges, deduplicated, in the order written.
98 *
99 * THE ONLY READER of `task.dependsOn`, for the same reason `predictedPaths` is
100 * the only reader of `task.paths`: validation, layering and the replan path all
101 * ask the same question, and three readers each doing their own trimming is how
102 * one of them ends up matching an id the others do not. Here rather than in
103 * `lib/waves.mjs`, which imports it, so the Node-free plan summary can ask it
104 * too (draw-the-wave-board-in-the-meter-pane design D3).
105 *
106 * Entries are trimmed. A whitespace-only entry is dropped here and rejected by
107 * `validate`, which runs first on every path into the planner.
108 */
109export function dependsOnIds(task) {
110 const raw = task && Array.isArray(task.dependsOn) ? task.dependsOn : []
111 const out = []
112 for (const entry of raw) {
113 if (typeof entry !== 'string' || !entry.trim()) continue
114 const id = entry.trim()
115 if (!out.includes(id)) out.push(id)
116 }
117 return out
118}
119
120/**
121 * The model a lane dispatches on, derived from the lane's shape and hardest tier.
122 *
123 * A lane of one task dispatches on that task's clamped model — haiku or sonnet,
124 * or opus for a tier-5 task or a solo promotion. A lane of two or more tasks is
125 * the work agent the planner chose not to split: it dispatches on opus when its
126 * hardest tier is at or above `LANE_CAPS.opusMinTier`, or when every task already
127 * carries opus (solo promotion of the whole change). Otherwise it runs on
128 * sonnet — routine multi-task work no longer pays the flagship model by default.
129 * Nothing here rewrites `task.model`: a narrowed lane of one returns to the
130 * task's model.
131 */
132export function laneModel(lane) {
133 if (!Array.isArray(lane) || lane.length === 0) return 'sonnet'
134 if (lane.length === 1) {
135 const only = lane[0]
136 return (only && only.model) || 'sonnet'
137 }
138 // Solo promotions rewrite every task to opus after the clamp; an all-opus
139 // multi-task lane is that decision (or a pure tier-5 pack), not the floor.
140 if (lane.every(t => t && t.model === 'opus')) return 'opus'
141 return laneTier(lane) >= LANE_CAPS.opusMinTier ? 'opus' : 'sonnet'
142}
143
144/**
145 * The reasoning effort a lane dispatches at — a sibling of `laneModel`, derived
146 * from the same hardest-task tier, looked up in the table published by
147 * `lib/limits.mjs`. A lane is one agent, and that agent must be capable of the
148 * hardest thing in the lane, so the effort is the hardest task's, never the
149 * first task's — the one direction of this trade that is not survivable.
150 *
151 * Returns `null` (inherit the session default — do not force) for tiers 3–4 by
152 * policy and for an untiered lane (`laneTier` → 0) by fallback; the plan's
153 * effort report distinguishes the two. Effort is derived from tier alone. The
154 * model a lane dispatches on follows shape and the opus floor, so a multi-task
155 * lane of tier-1 or tier-2 tasks runs on sonnet at `low`.
156 */
157export function laneEffort(lane) {
158 const tier = laneTier(lane)
159 return EFFORT.byTier[tier] ?? null
160}
161
162/**
163 * A drawn row fitted to `columns` code points: unchanged when it fits; else its
164 * first `columns - 1` code points and `…`, so the result is exactly `columns`
165 * wide. Both renderers cut every row with it, so a plan board and a run board
166 * cut alike, and at the place the ship meter's pane cuts a line.
167 *
168 * @param {string} text
169 * @param {number} columns
170 * @returns {string}
171 */
172export function fitRow(text, columns) {
173 const points = [...text]
174 if (points.length <= columns) return text
175 return `${points.slice(0, Math.max(0, columns - 1)).join('')}…`
176}
177