@renzrollon/interlock 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "interlock",
3
3
  "displayName": "Interlock",
4
- "version": "1.3.0",
4
+ "version": "1.4.0",
5
5
  "description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec. Onboard a repo, produce a reviewed spec, then ship it start-to-commit with parallel agents and a deterministic gate spine.",
6
6
  "author": {
7
7
  "name": "Carl Rollon",
@@ -9,7 +9,6 @@
9
9
  },
10
10
  "homepage": "https://github.com/renzrollon/interlock",
11
11
  "repository": "https://github.com/renzrollon/interlock",
12
- "icon": ".claude-plugin/icon.png",
13
12
  "license": "MIT",
14
13
  "keywords": [
15
14
  "openspec",
package/CHANGELOG.md CHANGED
@@ -5,7 +5,32 @@ All notable changes to this project are documented here.
5
5
  The format follows Keep a Changelog 1.1.0, and
6
6
  this project adheres to Semantic Versioning 2.0.0.
7
7
 
8
- ## [Unreleased]
8
+ ## 1.4.0 — 2026-10-09
9
+
10
+ ### Changed
11
+
12
+ - **A lane's worker is shown as `task 1.1 · <gist>`.** `/workflows`, `interlock-run`'s log, the meter's rows and toasts and the Mermaid plan all name a lane by `laneTitle`, which read `1.1 · Add the relaunch guard`. A bare `1.1` among `plan-waves`, `cli-3 · run next` and `verify` looked like an outline number rather than a worker. The title now opens with what the agent is doing: `task 1.1 · …` for a one-task lane and `tasks 1.1+2 · …` for a lane of several. The 48-character cap still applies to the label and gist, so a gist is cut where it was and `interlock waves --format board` prints byte-for-byte what it printed before. The label (`1.1`, `1.1+2`) is unchanged and still names the briefing file, the worktree, the trajectory line and `run record-batch`. Non-lane spawns keep their labels as titles. The workflow runtime caches an agent by its label, so resuming a run from `/workflows` that started before this upgrade re-runs its lane agents.
13
+ - **The ship meter names every agent on the Workflow host.** On that host every agent row and turn-end toast read `lane unknown`. The meter joined an agent to its lane only through `agent.spawn`, which the host never raises for a workflow agent, so the board's lane notes never left `served ?` either. Every run agent's first act is to read its own briefing, so the meter now joins on that read. A `Read` whose path names a dispatched spawn's briefing, or a Bash command that names it (a worker that `cat`s it), matches the agent to that spawn's row by the same briefing hash. A new `tool.call` observer on `Read` makes that join and passes the call on unchanged. An agent is named by its spawn's title (`task 1.1 · …`, `plan-waves`, `verify`). A relay ping is named by its own `interlock` line (`cli · run next`). An agent seen doing neither reads `unmatched agent`, and one line beneath the rows says why. The pane and the toast now use one name; before, the pane printed the label and the toast the title.
14
+ - **The meter pane draws in your theme's colours.** Every colour is a theme key, so the pane follows a light, dark or colour-blind theme. A probe on 2.1.295 showed theme keys resolve in a module's pane and an unknown key renders uncoloured.
15
+ - State words: `success`, `error` and `warning` for `ok`, `failed` and `current`, replacing raw green, red and yellow.
16
+ - Names: lane labels and agent names in `suggestion`, bold.
17
+ - Headings: the run header and section headings in `claude`.
18
+ - Cards: the card of the wave holding a `current` lane is bordered in `warning`; every other card stays dim.
19
+ - Agent rows: an agent's turn word in `warning` while running, `success` for `answer` and `error` for any other host word, with relay pings dim.
20
+ - Warnings: the quiet word and banners in `warning`.
21
+ - Refusals: the guard-denial line, and the launch-guard line when it refuses, in `error`.
22
+ - Uncoloured, as before: the context, cost and plan-window lines (colour there would read as a threshold) and a `RED` marker in a wave title. The rules are `lib/meter-palette.mjs`'s, a Node-free module pinned by `test/spine/meter-palette.test.mjs`.
23
+
24
+ ## 1.3.1 — 2026-10-08
25
+
26
+ ### Changed
27
+
28
+ - **The ship meter draws each wave as a card, and colours only the state word.** The 1.3.0 pane stretched every wave rule to the pane's width and cut each lane's title with `…`. Each wave is now one bordered card, as wide as the pane up to the board's published default width. A lane is its fixed cells, then its title wrapping beneath them, then, once its agent has spawned, what the host reported serving it, dim. The state word is the only coloured text: `ok` green, `failed` red, `current` yellow, the rest dim, and a lane whose tasks disagree colours each task's own word beside its id. The fixed cells are one text with the coloured words inside it, so in a narrow docked pane the line wraps instead of breaking words apart. `interlock waves --format board` prints exactly what it printed before.
29
+ - **The renderer's rows carry their fields.** `drawPlanBoardRows` gives a wave-header row `parts.title` and a lane row `parts`: batch, label, model, tier, effort, state word, gist, note, task ids, ordered-after ids and each task's own word. A drawer colours a word without searching the cut line. The lines form and the Mermaid output are byte-identical to 1.3.0.
30
+ - **A hook parameter renamed from `$` is pinned.** The meter's spawn, session-start and command hooks name their parameters for a reader. The allow-list scan reads only `$.`, so `test/spine/mod-pins.test.mjs` now fails when a renamed engine handle is read through, or a renamed forward is called with anything but the hook's own input.
31
+ - **The listing image moved out of the manifest folder.** It sat in `.claude-plugin/`, where the plugin layout keeps only the manifest. It now ships from the plugin's assets folder, `package.json` lists that folder in `files`, and `plugin.json` no longer names the image.
32
+
33
+ ## 1.3.0 — 2026-10-08
9
34
 
10
35
  ### Added
11
36
 
package/hooks/mod.mjs CHANGED
@@ -64,10 +64,20 @@
64
64
  // drawn from one source: the plan summary the adoption and replan steps carry,
65
65
  // with an overlay built from the steps alone (the cursor, the ids each recorded
66
66
  // batch names, the skipped verifications, the halt). No file is read for it.
67
- // The layout, the state words and the cut are the renderer's; the module keys
68
- // its rows, appends what the host observed of a lane, and speaks every
69
- // fallback: a CLI that relays no plan, a pane narrower than the board, a
70
- // renderer that throws.
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.
71
81
  //
72
82
  // THE SESSION START (show-preflight-and-interrupted-runs-at-session-start
73
83
  // design D5-D8). Outside a run the module draws two things, both from the
@@ -101,6 +111,7 @@
101
111
  import { drawPlanBoardRows } from '../lib/draw-plan.mjs'
102
112
  import { decideLaunch, emptyRecord, isAcceptedLaunch, isShipLaunch, withLaunch, withPrompt } from '../lib/launch-rule.mjs'
103
113
  import { LIMITS } from '../lib/limits.mjs'
114
+ import { PALETTE, stateProps, turnProps } from '../lib/meter-palette.mjs'
104
115
  import { TICK_MS, quietMs, quietWord } from '../lib/meter-quiet.mjs'
105
116
  import { guardDenialLine, guardOf, guardReason, launchGuardLine } from '../lib/meter-refusals.mjs'
106
117
  import {
@@ -166,6 +177,17 @@ const GUARD = 'interlock guard'
166
177
  const DRIVER_LINE = /^interlock\s/
167
178
  // The bootstrap line a lane's spawn prompt carries (as `lib/agent-usage.mjs` reads it).
168
179
  const BRIEFING_SHA = /Expected sha256: ([0-9a-f]{64})/g
180
+ // What an agent row and a turn-end toast read for an agent no row and no driver line names.
181
+ const UNMATCHED = 'unmatched agent'
182
+ const UNMATCHED_LINE = 'unmatched: no briefing read or interlock line seen from this agent yet'
183
+ // The characters a briefing path may stand behind in a Read path or a command,
184
+ // and the ones that would make it part of a longer file name after it.
185
+ const PATH_BEFORE = /[\s\/'"=]/
186
+ const PATH_AFTER = /[\w.+-]/
187
+ /** The props a name is drawn with: a lane label, a spawn's title, a joined agent's name. */
188
+ const NAME_PROPS = { bold: true, color: PALETTE.identity }
189
+ /** The props the run header and each section heading are drawn with. */
190
+ const HEADING_PROPS = { bold: true, color: PALETTE.accent }
169
191
  const TOKEN_FIELDS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']
170
192
  // The classic session-start sources that start the session over, as the person
171
193
  // typed them (design D4). `startup` and `compact` are not among them.
@@ -595,6 +617,117 @@ async function fileWrite($, path) {
595
617
  }
596
618
  }
597
619
 
620
+ /** What an `agent.spawn` chain returned, read for the meter and handed back unchanged. */
621
+ async function noteSpawn($, input, spawned) {
622
+ if (session.interactive && run.phase === 'live') {
623
+ if (spawned && str(spawned.agentId) && input && typeof input.prompt === 'string') {
624
+ const found = [...input.prompt.matchAll(BRIEFING_SHA)]
625
+ if (found.length) agentOf(spawned.agentId).sha = found[found.length - 1][1]
626
+ }
627
+ if (await stamp($)) redrawWord($)
628
+ }
629
+ // An Explore investigator the chain let through (observe-the-spec-run-live design D5).
630
+ if (specWatching() && input && input.subagentType === 'Explore' && spawned && typeof spawned.model === 'string') {
631
+ await countInvestigator($)
632
+ }
633
+ return spawned
634
+ }
635
+
636
+ /** Whether `text` names `path` whole: behind its start or a separator, and not followed by more of a file name. */
637
+ function namesPath(text, path) {
638
+ for (let at = text.indexOf(path); at !== -1; at = text.indexOf(path, at + 1)) {
639
+ const before = at === 0 ? '' : text[at - 1]
640
+ const after = text[at + path.length] ?? ''
641
+ if ((before === '' || PATH_BEFORE.test(before)) && (after === '' || !PATH_AFTER.test(after))) return true
642
+ }
643
+ return false
644
+ }
645
+
646
+ /**
647
+ * The Workflow host's join: it raises no
648
+ * `agent.spawn` for a Workflow agent, but every run agent's first act is to
649
+ * read its own briefing. A Read path or a Bash command that names a
650
+ * dispatched spawn's briefing path joins the agent to that spawn's row,
651
+ * through the same briefing hash the spawn event joins on. The first briefing
652
+ * an agent reads wins; an agent that names none is left as it was, and no
653
+ * agent record is made for it. Never throws.
654
+ */
655
+ function noteBriefing($, agentId, text) {
656
+ try {
657
+ const id = str(agentId)
658
+ if (!id || typeof text !== 'string') return
659
+ const known = run.agents.get(id)
660
+ if (known && known.sha) return
661
+ const said = text.split('\\').join('/')
662
+ for (const r of run.rows.values()) {
663
+ if (!r.sha || !r.promptPath) continue
664
+ if (!namesPath(said, r.promptPath.split('\\').join('/'))) continue
665
+ agentOf(id).sha = r.sha
666
+ $.ui.invalidate('ui.render')
667
+ return
668
+ }
669
+ } catch (err) {
670
+ $.ui.log(`interlock meter: a briefing read was not joined to its lane: ${messageOf(err)}`, { to: 'debug' })
671
+ }
672
+ }
673
+
674
+ /**
675
+ * `run next` for `interlock run next --results … --json`: the subcommand a
676
+ * driver line relays, as `workflows/ship.js` names its relay (`argv.slice(0, 2)`),
677
+ * the second word dropped when it is a flag or a shell operator.
678
+ */
679
+ function relayOf(command) {
680
+ const words = command.trim().split(/\s+/).slice(1, 3)
681
+ if (!words.length || !words[0]) return null
682
+ const second = words[1]
683
+ return second && !/^(--|[;&|<>])/.test(second) ? `${words[0]} ${second}` : words[0]
684
+ }
685
+
686
+ /** An agent that ran a driver line before any briefing named it is a relay, shown by what it relayed. Never throws. */
687
+ function noteRelay($, agentId, command) {
688
+ try {
689
+ const id = str(agentId)
690
+ if (!id) return
691
+ const known = run.agents.get(id)
692
+ if (known && known.sha) return
693
+ const relay = relayOf(command)
694
+ if (!relay || (known && known.relay === relay)) return
695
+ agentOf(id).relay = relay
696
+ $.ui.invalidate('ui.render')
697
+ } catch (err) {
698
+ $.ui.log(`interlock meter: a driver line was not read for its agent: ${messageOf(err)}`, { to: 'debug' })
699
+ }
700
+ }
701
+
702
+ /**
703
+ * A clear, resume or fork ends the run the meter holds, before the chain
704
+ * beneath runs. Any other source, and a non-interactive session, changes
705
+ * nothing. Never throws.
706
+ */
707
+ function endRunAtBoundary($, input) {
708
+ if (!session.interactive) return
709
+ try {
710
+ const boundary = BOUNDARIES.get(input.source)
711
+ if (!boundary) return
712
+ const ended = run
713
+ stopTick()
714
+ run = freshRun()
715
+ start = freshStart()
716
+ spec = freshSpec({ afterBoundary: true })
717
+ setStatus($)
718
+ $.ui.invalidate('ui.render')
719
+ const what = ended.phase === 'idle' ? 'no run was held' : `run ${ended.runId || '(no id)'} is no longer drawn`
720
+ $.ui.log(`interlock meter: ${boundary} started the session over; ${what}`, { to: 'debug' })
721
+ } catch (err) {
722
+ $.ui.log(`interlock meter: the session boundary was not applied: ${messageOf(err)}`, { to: 'debug' })
723
+ }
724
+ }
725
+
726
+ /** Once the chain beneath has run: the preflight's report, read for every source. */
727
+ async function readPreflightAfterStart($, input) {
728
+ if (session.interactive) await readPreflight($, str(input.cwd) || session.cwd)
729
+ }
730
+
598
731
  /** One Explore investigator the chain let through (design D5). Never throws. */
599
732
  async function countInvestigator($) {
600
733
  try {
@@ -613,11 +746,14 @@ function drawSpecPane($, e) {
613
746
  return h(Box, { flexDirection: 'column' }, ...specPaneLines(spec).map(l => h(Box, { key: l.key }, h(Text, props(l), l.text))))
614
747
  }
615
748
 
616
- /** The launch guard's ruling now, over the record its branch would read, in the rule's words (design D5). */
749
+ /**
750
+ * The launch guard's ruling now, over the record its branch would read, in the
751
+ * rule's words (design D5), and whether that ruling refuses the next launch.
752
+ */
617
753
  async function launchGuardText($) {
618
754
  const { record, now } = await guardFacts($)
619
755
  const ruling = decideLaunch(record, now)
620
- return launchGuardLine(ruling, now)
756
+ return { text: launchGuardLine(ruling, now), refused: now !== null && ruling.decision !== 'allow' && Boolean(ruling.reason) }
621
757
  }
622
758
 
623
759
  /** Names, once per run record, a step that crossed with no live run (design D4). */
@@ -674,6 +810,8 @@ function agentOf(id) {
674
810
  reason: null,
675
811
  durationMs: null,
676
812
  sha: null,
813
+ // The two words after `interlock` in the first driver line it ran while no row named it.
814
+ relay: null,
677
815
  // Whether its turn end has been spoken: once per agent per run (speak-lane-turn-ends design D1).
678
816
  endToasted: false
679
817
  }
@@ -786,6 +924,8 @@ async function applyRecord($, record) {
786
924
  model: str(s.model),
787
925
  effort: str(s.effort),
788
926
  sha: str(s.promptSha256),
927
+ // The briefing the spawn names, read off the agent's own first read of it.
928
+ promptPath: str(s.promptPath),
789
929
  // Spawned by a step that dispatches a batch: a lane, planned or not.
790
930
  batch: BATCH_ACTIONS.has(record.action)
791
931
  })
@@ -934,9 +1074,14 @@ function rowForAgent(a) {
934
1074
  return null
935
1075
  }
936
1076
 
937
- function laneForAgent(a) {
938
- const r = rowForAgent(a)
939
- return r ? r.label : null
1077
+ /**
1078
+ * What an agent is called: its joined spawn's title, else `cli · <what it
1079
+ * relayed>` for a driver-line agent, else `null`, which reads `unmatched agent`.
1080
+ */
1081
+ function agentName(a) {
1082
+ const row = rowForAgent(a)
1083
+ if (row) return row.title
1084
+ return a.relay ? `cli · ${a.relay}` : null
940
1085
  }
941
1086
 
942
1087
  /**
@@ -962,24 +1107,53 @@ function sessionLines(usage, failure) {
962
1107
 
963
1108
  /**
964
1109
  * `last activity <ISO>` or `last activity unknown`, then the quiet word while a
965
- * live run is quiet. The word is recomputed from the clock at each draw, so a
966
- * pane opened after the host stopped the interval still shows it (design D2, D5).
1110
+ * live run is quiet, as the line and the word apart. The word is recomputed
1111
+ * from the clock at each draw, so a pane opened after the host stopped the
1112
+ * interval still shows it (design D2, D5).
967
1113
  */
968
1114
  async function lastActivityText($) {
969
- if (run.lastActivityAt === null) return 'last activity unknown'
1115
+ if (run.lastActivityAt === null) return { line: 'last activity unknown', word: null }
970
1116
  const at = new Date(run.lastActivityAt).toISOString()
971
1117
  const word = run.phase === 'live' ? quietWord(quietMs(run.lastActivityAt, await clockNow($))) : null
972
- return `last activity ${at}${word ? ` · ${word}` : ''}`
1118
+ return { line: `last activity ${at}`, word }
973
1119
  }
974
1120
 
975
- /** One spawn as a flat row, as the section drew every spawn before the board. */
976
- function flatRow(keyed, r) {
1121
+ /**
1122
+ * An agent's first line: its name, its id and models dim, then its turn word.
1123
+ * A joined agent is named in the identity colour, a relay dim, an unmatched
1124
+ * agent dim and italic. The turn word is the host's, coloured by what it means;
1125
+ * a relay's running or answered turn stays dim, and only its abnormal end is coloured.
1126
+ */
1127
+ function agentLine(Text, a, done) {
1128
+ const row = rowForAgent(a)
1129
+ const name = agentName(a)
1130
+ const nameProps = row ? NAME_PROPS : a.relay ? { dimColor: true } : { dimColor: true, italic: true }
1131
+ const quiet = !row && a.relay && (!a.reason || a.reason === 'answer')
1132
+ return h(
1133
+ Text,
1134
+ null,
1135
+ h(Text, nameProps, name || UNMATCHED),
1136
+ h(Text, { dimColor: true }, ` · ${a.id} · ${a.models.join(', ') || 'model not reported'}`),
1137
+ ' · ',
1138
+ h(Text, quiet ? { dimColor: true } : turnProps(a.reason), done)
1139
+ )
1140
+ }
1141
+
1142
+ /** One spawn as a flat row, as the section drew every spawn before the board: its title in the identity colour. */
1143
+ function flatRow($, e, r) {
1144
+ const { Box, Text } = $.ui.resolve(e)
977
1145
  const a = agentForSha(r.sha)
978
1146
  const served = a && a.models.length ? a.models.join(', ') : '?'
979
1147
  const state = a ? a.reason || 'running' : 'waiting'
980
- return keyed(
981
- `wave-row-${r.label}`,
982
- `${r.title} · ${r.kind || 'agent'} · routed ${r.model || '?'} · served ${served} · effort ${r.effort || '?'} · ${state}`
1148
+ return h(
1149
+ Box,
1150
+ { key: `wave-row-${r.label}` },
1151
+ h(
1152
+ Text,
1153
+ null,
1154
+ h(Text, NAME_PROPS, r.title),
1155
+ ` · ${r.kind || 'agent'} · routed ${r.model || '?'} · served ${served} · effort ${r.effort || '?'} · ${state}`
1156
+ )
983
1157
  )
984
1158
  }
985
1159
 
@@ -1019,9 +1193,136 @@ function boardKey(key) {
1019
1193
  return `${kind === 'wave' ? 'wave-block' : `wave-${kind}`}-${rest.join('-')}`
1020
1194
  }
1021
1195
 
1196
+ /** The width a card and the renderer share: the body, capped at the published default. */
1197
+ function boardColumns(e) {
1198
+ const body = e.props && isInt(e.props.bodyColumns) && e.props.bodyColumns > 0
1199
+ ? e.props.bodyColumns
1200
+ : LIMITS.waveBoardDefaultColumns
1201
+ return body < LIMITS.waveBoardMinColumns ? body : Math.min(body, LIMITS.waveBoardDefaultColumns)
1202
+ }
1203
+
1204
+ function waveTitleOf(row) {
1205
+ if (row.parts && typeof row.parts.title === 'string' && row.parts.title) return row.parts.title
1206
+ const text = row.text || ''
1207
+ if (!text.startsWith('┌─ ')) return text
1208
+ const rest = text.slice(3)
1209
+ const cut = rest.search(/ ─/)
1210
+ return (cut === -1 ? rest : rest.slice(0, cut)).trimEnd()
1211
+ }
1212
+
1213
+ /** A lane's ids cell, as spans: each task's own state word coloured when its tasks disagree. */
1214
+ function idSpans(Text, parts) {
1215
+ if (parts.state === 'per task' && Array.isArray(parts.tasks) && parts.tasks.length) {
1216
+ const spans = [' [']
1217
+ parts.tasks.forEach((task, i) => {
1218
+ if (i) spans.push(', ')
1219
+ if (!task.word) return spans.push(task.id)
1220
+ spans.push(`${task.id} `, h(Text, stateProps(task.word), task.word))
1221
+ })
1222
+ spans.push(']')
1223
+ return spans
1224
+ }
1225
+ return parts.ids ? [` [${parts.ids}]`] : []
1226
+ }
1227
+
1228
+ /**
1229
+ * A lane's batch, label, model, tier and effort cells, space-joined as the
1230
+ * board prints them, the label a span in the identity colour.
1231
+ */
1232
+ function headSpans(Text, parts) {
1233
+ const before = [parts.batch].filter(Boolean).join(' ')
1234
+ const after = [parts.model, parts.tier, parts.effort].filter(Boolean).join(' ')
1235
+ if (!parts.label) return [[before, after].filter(Boolean).join(' ')]
1236
+ return [before ? `${before} ` : '', h(Text, NAME_PROPS, parts.label), after ? ` ${after}` : ''].filter(span => span !== '')
1237
+ }
1238
+
1239
+ /** Whether a lane the board drew holds the cursor: its own state word, or a task's, is `current`. */
1240
+ const isCurrentLane = parts =>
1241
+ Boolean(parts) &&
1242
+ (parts.state === 'current' || (Array.isArray(parts.tasks) && parts.tasks.some(task => task && task.word === 'current')))
1243
+
1244
+ /**
1245
+ * A lane in its card: the fixed cells as one Text, the gist wrapping beneath,
1246
+ * then the host's note, dim. The coloured words are spans nested in that one
1247
+ * Text, because a row of separate elements shrinks each into its own column
1248
+ * and breaks words apart once the line is wider than the card.
1249
+ */
1250
+ function drawLane($, e, row) {
1251
+ const { Box, Text } = $.ui.resolve(e)
1252
+ const key = boardKey(row.key)
1253
+ const parts = row.parts
1254
+ if (!parts) return h(Box, { key }, h(Text, { wrap: 'truncate-end' }, row.text))
1255
+ const line = [
1256
+ ...headSpans(Text, parts),
1257
+ ...(parts.state ? [' ', h(Text, stateProps(parts.state), parts.state)] : []),
1258
+ ...idSpans(Text, parts),
1259
+ ...(Array.isArray(parts.after) && parts.after.length ? [` ←${parts.after.join(',')}`] : [])
1260
+ ]
1261
+ const kids = [
1262
+ h(Text, { wrap: 'wrap' }, ...line),
1263
+ parts.gist ? h(Box, { key: `${key}-gist` }, h(Text, { wrap: 'wrap' }, parts.gist)) : null,
1264
+ parts.note ? h(Box, { key: `${key}-note` }, h(Text, { dimColor: true }, parts.note)) : null
1265
+ ].filter(Boolean)
1266
+ return h(Box, { key, flexDirection: 'column' }, ...kids)
1267
+ }
1268
+
1269
+ function drawBoardCards($, e, board, width) {
1270
+ const { Box, Text } = $.ui.resolve(e)
1271
+ const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
1272
+ const out = []
1273
+ let index = null
1274
+ let kids = null
1275
+ // Whether the card being filled holds a lane at the cursor: its border is then the current colour.
1276
+ let current = false
1277
+ const flush = () => {
1278
+ if (index === null) return
1279
+ out.push(
1280
+ h(
1281
+ Box,
1282
+ {
1283
+ key: `wave-card-${index}`,
1284
+ flexDirection: 'column',
1285
+ borderStyle: 'single',
1286
+ ...(current ? { borderColor: PALETTE.current } : { borderDimColor: true }),
1287
+ width,
1288
+ paddingLeft: 1,
1289
+ paddingRight: 1,
1290
+ marginBottom: 1
1291
+ },
1292
+ ...kids
1293
+ )
1294
+ )
1295
+ index = null
1296
+ kids = null
1297
+ current = false
1298
+ }
1299
+ for (const row of board) {
1300
+ if (row.key.startsWith('wave:') && row.key.endsWith(':end')) continue
1301
+ if (row.key.startsWith('wave:')) {
1302
+ flush()
1303
+ index = row.key.slice('wave:'.length)
1304
+ kids = [h(Box, { key: boardKey(row.key) }, h(Text, { bold: true }, waveTitleOf(row)))]
1305
+ continue
1306
+ }
1307
+ if (row.key.startsWith('lane:')) {
1308
+ const lane = drawLane($, e, row)
1309
+ if (kids) {
1310
+ kids.push(lane)
1311
+ if (isCurrentLane(row.parts)) current = true
1312
+ } else out.push(lane)
1313
+ continue
1314
+ }
1315
+ flush()
1316
+ out.push(keyed(boardKey(row.key), row.text, row.key === 'header' || row.key === 'tail' ? null : { wrap: 'truncate-end' }))
1317
+ }
1318
+ flush()
1319
+ return out
1320
+ }
1321
+
1022
1322
  /**
1023
1323
  * The waves section (design D7, D8): the board once a plan summary has
1024
- * crossed, at the pane's own width, one truncating text per row; then a flat
1324
+ * crossed, one bordered card per wave at the body width capped at the board's
1325
+ * default, the rows between cards one truncating text each; then a flat
1025
1326
  * row for each spawn no planned lane carries. Before a summary, the flat rows,
1026
1327
  * beneath a spoken line once a batch was dispatched. Below the board's minimum
1027
1328
  * the renderer's spoken line stands in for it, and a renderer that throws is
@@ -1035,57 +1336,69 @@ function drawWaves($, e) {
1035
1336
  if (!run.plan) {
1036
1337
  if (!rows.length) return [h(Text, { dimColor: true }, NO_LANES_LINE)]
1037
1338
  const dispatched = run.dispatched !== null || rows.some(r => r.batch)
1038
- return [...(dispatched ? [keyed('wave-board-unrelayed', UNRELAYED_LINE)] : []), ...rows.map(r => flatRow(keyed, r))]
1339
+ return [...(dispatched ? [keyed('wave-board-unrelayed', UNRELAYED_LINE)] : []), ...rows.map(r => flatRow($, e, r))]
1039
1340
  }
1040
- const props = e.props || {}
1041
- const width = isInt(props.bodyColumns) && props.bodyColumns > 0 ? props.bodyColumns : LIMITS.waveBoardDefaultColumns
1341
+ const width = boardColumns(e)
1042
1342
  let board
1043
1343
  try {
1044
1344
  board = drawPlanBoardRows(run.plan, { columns: width, state: boardOverlay(run.plan), notes: laneNotes() })
1045
1345
  } catch (err) {
1046
1346
  $.ui.log(`interlock meter: ${BOARD_FAILED_LINE}: ${messageOf(err)}`, { to: 'debug' })
1047
- return [keyed('wave-board-failed', BOARD_FAILED_LINE), ...rows.map(r => flatRow(keyed, r))]
1347
+ return [keyed('wave-board-failed', BOARD_FAILED_LINE), ...rows.map(r => flatRow($, e, r))]
1048
1348
  }
1049
1349
  // Every board ends in its tail; the renderer's spoken line for a narrow body
1050
1350
  // is one row without one, and the lanes are then drawn flat beneath it.
1051
1351
  const drawn = board.length > 0 && board[board.length - 1].key === 'tail'
1052
1352
  const planned = new Set(drawn ? board.filter(row => row.key.startsWith('lane:')).map(row => row.key.slice('lane:'.length)) : [])
1053
- return [
1054
- ...board.map(row => line(boardKey(row.key), row.text)),
1055
- ...rows.filter(r => !planned.has(r.label)).map(r => flatRow(keyed, r))
1056
- ]
1353
+ const body = drawn ? drawBoardCards($, e, board, width) : board.map(row => line(boardKey(row.key), row.text))
1354
+ return [...body, ...rows.filter(r => !planned.has(r.label)).map(r => flatRow($, e, r))]
1057
1355
  }
1058
1356
 
1059
1357
  async function drawPane($, e) {
1060
1358
  const { Box, Text } = $.ui.resolve(e)
1061
1359
  const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
1360
+ const heading = text => h(Box, { marginTop: 1 }, h(Text, HEADING_PROPS, text))
1361
+ // The launch-guard line, in the alarm colour only while its ruling refuses the next launch.
1362
+ const guardLine = async () => {
1363
+ const guard = await launchGuardText($)
1364
+ return keyed('launch-guard', guard.text, guard.refused ? { color: PALETTE.alarm } : null)
1365
+ }
1062
1366
  if (run.phase === 'idle' && !run.summary) {
1063
1367
  // The guard runs in every session, so its line stands here too (design D5).
1064
- return h(Box, { flexDirection: 'column' }, h(Text, { dimColor: true }, NO_RUN_LINE), keyed('launch-guard', await launchGuardText($)))
1368
+ return h(Box, { flexDirection: 'column' }, h(Text, { dimColor: true }, NO_RUN_LINE), await guardLine())
1065
1369
  }
1066
1370
 
1067
1371
  const out = []
1068
- out.push(h(Text, { bold: true }, `${run.change || 'ship run'}${run.runId ? ` · run ${run.runId}` : ''}`))
1372
+ out.push(h(Text, HEADING_PROPS, `${run.change || 'ship run'}${run.runId ? ` · run ${run.runId}` : ''}`))
1069
1373
  out.push(keyed('action', `action: ${run.action || 'starting'}${run.phase === 'closed' ? ' (closed)' : ''}`))
1070
- out.push(keyed('last-activity', await lastActivityText($)))
1374
+ const activity = await lastActivityText($)
1375
+ out.push(
1376
+ h(
1377
+ Box,
1378
+ { key: 'last-activity' },
1379
+ h(Text, null, activity.line, ...(activity.word ? [' · ', h(Text, { color: PALETTE.warn }, activity.word)] : []))
1380
+ )
1381
+ )
1071
1382
  out.push(h(Text, { dimColor: true }, RECORD_LINE))
1072
1383
 
1073
- out.push(h(Text, { bold: true }, 'waves'))
1384
+ out.push(heading('waves'))
1074
1385
  out.push(...drawWaves($, e))
1075
1386
 
1076
- out.push(h(Text, { bold: true }, 'agents'))
1387
+ out.push(heading('agents'))
1077
1388
  if (!run.agents.size) out.push(h(Text, { dimColor: true }, 'no agent has reported a request yet'))
1078
1389
  for (const a of run.agents.values()) {
1079
- const lane = laneForAgent(a)
1080
1390
  const n = a.requests === 1 ? '1 request' : `${a.requests} requests`
1081
1391
  const done = a.reason ? `${a.reason}${a.durationMs !== null ? ` ${(a.durationMs / 1000).toFixed(1)}s` : ''}` : 'running'
1082
1392
  out.push(
1083
- keyed(
1084
- `agent-${a.id}`,
1085
- `${lane || 'lane unknown'} · ${a.id} · ${a.models.join(', ') || 'model not reported'} · ${n} · ${tokensText(a)} · ${done}`
1393
+ h(
1394
+ Box,
1395
+ { key: `agent-${a.id}`, flexDirection: 'column' },
1396
+ h(Box, null, agentLine(Text, a, done)),
1397
+ h(Box, null, h(Text, { dimColor: true }, `${n} · ${tokensText(a)}`))
1086
1398
  )
1087
1399
  )
1088
1400
  }
1401
+ if ([...run.agents.values()].some(a => !agentName(a))) out.push(keyed('agents-unmatched', UNMATCHED_LINE, { dimColor: true }))
1089
1402
 
1090
1403
  // One plain read feeds the session lines and the plan windows; a breakdown
1091
1404
  // is never asked for, because only the plain call is free (design D4).
@@ -1098,11 +1411,11 @@ async function drawPane($, e) {
1098
1411
  failure = messageOf(err)
1099
1412
  }
1100
1413
  const [contextLine, costLine] = sessionLines(usage, failure)
1101
- out.push(h(Text, { bold: true }, 'session'))
1414
+ out.push(heading('session'))
1102
1415
  out.push(keyed('session-context', contextLine))
1103
1416
  out.push(keyed('session-cost', costLine))
1104
1417
 
1105
- out.push(h(Text, { bold: true }, 'plan windows'))
1418
+ out.push(heading('plan windows'))
1106
1419
  const windows = usage && Array.isArray(usage.rateLimits) ? usage.rateLimits : []
1107
1420
  if (failure !== null) out.push(h(Text, { dimColor: true }, `plan windows unavailable: ${failure}`))
1108
1421
  else if (!windows.length) out.push(h(Text, { dimColor: true }, 'none reported (off a subscription the session has none)'))
@@ -1112,22 +1425,22 @@ async function drawPane($, e) {
1112
1425
  out.push(keyed(`plan-window-${kind}`, `${kind} ${w.percentUsed}% used${w.resetsAt ? ` · resets ${w.resetsAt}` : ''}`))
1113
1426
  }
1114
1427
 
1115
- out.push(h(Text, { bold: true }, 'banners'))
1428
+ out.push(heading('banners'))
1116
1429
  out.push(
1117
1430
  h(
1118
1431
  Box,
1119
1432
  { key: 'banners', flexDirection: 'column' },
1120
- ...(run.banners.length ? run.banners.map(b => h(Text, null, b)) : [h(Text, { dimColor: true }, 'none so far')])
1433
+ ...(run.banners.length ? run.banners.map(b => h(Text, { color: PALETTE.warn }, b)) : [h(Text, { dimColor: true }, 'none so far')])
1121
1434
  )
1122
1435
  )
1123
1436
 
1124
- out.push(h(Text, { bold: true }, 'refusals'))
1437
+ out.push(heading('refusals'))
1125
1438
  const denials = guardDenialLine(run.guardDenials)
1126
- if (denials) out.push(keyed('guard-denials', denials))
1127
- out.push(keyed('launch-guard', await launchGuardText($)))
1439
+ if (denials) out.push(keyed('guard-denials', denials, { color: PALETTE.alarm }))
1440
+ out.push(await guardLine())
1128
1441
 
1129
1442
  if (run.summary) {
1130
- out.push(h(Text, { bold: true }, 'close summary'))
1443
+ out.push(heading('close summary'))
1131
1444
  out.push(h(Box, { key: 'summary', flexDirection: 'column' }, h(Text, null, run.summary)))
1132
1445
  }
1133
1446
  return h(Box, { flexDirection: 'column' }, ...out)
@@ -1282,6 +1595,12 @@ export function register(on) {
1282
1595
  return r
1283
1596
  }
1284
1597
  const held = run
1598
+ // A run agent's command names it: a
1599
+ // briefing it reads joins it to its lane, and a driver line names a relay.
1600
+ if (run.phase === 'live' && str(e.agentId)) {
1601
+ noteBriefing($, e.agentId, command)
1602
+ if (driver) noteRelay($, e.agentId, command)
1603
+ }
1285
1604
  const r = await next(e)
1286
1605
  // Any Bash call during the run is read for a guard's refusal (guard-commit); only a driver line for a step.
1287
1606
  if (held === run) speakRefusal($, r)
@@ -1308,6 +1627,14 @@ export function register(on) {
1308
1627
  return r
1309
1628
  })
1310
1629
 
1630
+ // A run agent's Read, observed for the briefing it names and passed on as it
1631
+ // came: the Workflow host raises no spawn event, so a briefing read is how an
1632
+ // agent is joined to its lane.
1633
+ on('tool.call', { tool: 'Read' }, async ($, e, next) => {
1634
+ if (session.interactive && run.phase === 'live' && e.agentId) noteBriefing($, e.agentId, e.file_path)
1635
+ return next(e)
1636
+ })
1637
+
1311
1638
  // The tools the manifest's `Edit|Write` matcher names for guard-tests and guard-tasks (design D4).
1312
1639
  on('tool.call', { tool: 'Edit' }, editCall)
1313
1640
  on('tool.call', { tool: 'Write' }, editCall)
@@ -1337,9 +1664,8 @@ export function register(on) {
1337
1664
  // (speak-lane-turn-ends-and-session-cost design D1-D3).
1338
1665
  if (a.reason && a.reason !== 'answer' && !a.endToasted) {
1339
1666
  a.endToasted = true
1340
- const row = rowForAgent(a)
1341
1667
  try {
1342
- $.ui.toast(`${row ? row.title : 'lane unknown'} · agent ${a.id} · turn ended: ${a.reason}`)
1668
+ $.ui.toast(`${agentName(a) || UNMATCHED} · agent ${a.id} · turn ended: ${a.reason}`)
1343
1669
  } catch (err) {
1344
1670
  $.ui.log(`interlock meter: the turn-end toast for ${a.id} was refused: ${messageOf(err)}`, { to: 'debug' })
1345
1671
  }
@@ -1350,18 +1676,12 @@ export function register(on) {
1350
1676
  return next(e)
1351
1677
  })
1352
1678
 
1353
- on('agent.spawn', async ($, e, next) => {
1354
- const r = await next(e)
1355
- if (session.interactive && run.phase === 'live') {
1356
- if (r && str(r.agentId) && typeof e.prompt === 'string') {
1357
- const found = [...e.prompt.matchAll(BRIEFING_SHA)]
1358
- if (found.length) agentOf(r.agentId).sha = found[found.length - 1][1]
1359
- }
1360
- if (await stamp($)) redrawWord($)
1361
- }
1362
- // An Explore investigator the chain let through, counted for the spec run (observe-the-spec-run-live design D5).
1363
- if (specWatching() && e.subagentType === 'Explore' && r && typeof r.model === 'string') await countInvestigator($)
1364
- return r
1679
+ // Pass the spawn on unchanged. The three parameter names are plain identifiers
1680
+ // and are not bound anywhere else in this file, so the value given to the
1681
+ // next handler is the event parameter. permissionMode is left as the parent
1682
+ // set it. The meter reads the chain's result and returns that same result.
1683
+ on('agent.spawn', async (meterSpawnApi, meterSpawnInput, meterSpawnForward) => {
1684
+ return meterSpawnForward(meterSpawnInput).then(spawned => noteSpawn(meterSpawnApi, meterSpawnInput, spawned))
1365
1685
  })
1366
1686
 
1367
1687
  // The session boundary (design D4). /clear, /resume and /branch start the
@@ -1374,27 +1694,15 @@ export function register(on) {
1374
1694
  // source (show-preflight-and-interrupted-runs-at-session-start design D5).
1375
1695
  // The kit raises this event; Claude Code 2.1.291 raises no classic event to
1376
1696
  // a module, so there the prompt hook above reads the report instead.
1377
- on('classic.SessionStart', async ($, e, next) => {
1378
- if (!session.interactive) return next(e)
1379
- try {
1380
- const boundary = BOUNDARIES.get(e.source)
1381
- if (boundary) {
1382
- const ended = run
1383
- stopTick()
1384
- run = freshRun()
1385
- start = freshStart()
1386
- spec = freshSpec({ afterBoundary: true })
1387
- setStatus($)
1388
- $.ui.invalidate('ui.render')
1389
- const what = ended.phase === 'idle' ? 'no run was held' : `run ${ended.runId || '(no id)'} is no longer drawn`
1390
- $.ui.log(`interlock meter: ${boundary} started the session over; ${what}`, { to: 'debug' })
1391
- }
1392
- } catch (err) {
1393
- $.ui.log(`interlock meter: the session boundary was not applied: ${messageOf(err)}`, { to: 'debug' })
1394
- }
1395
- const r = await next(e)
1396
- await readPreflight($, str(e.cwd) || session.cwd)
1397
- return r
1697
+ //
1698
+ // Pass the event on unchanged. The value this hook returns is the forward
1699
+ // itself: no first message, no permission decision. The meter's reset runs
1700
+ // before that chain and the preflight read after it; neither becomes the return.
1701
+ on('classic.SessionStart', async (meterSessionApi, meterSessionInput, meterSessionForward) => {
1702
+ endRunAtBoundary(meterSessionApi, meterSessionInput)
1703
+ const sessionForwarded = meterSessionForward(meterSessionInput)
1704
+ await sessionForwarded.then(() => readPreflightAfterStart(meterSessionApi, meterSessionInput))
1705
+ return sessionForwarded
1398
1706
  })
1399
1707
 
1400
1708
  on('ui.render', { component: 'Spinner' }, ($, e, next) => {
@@ -1413,27 +1721,27 @@ export function register(on) {
1413
1721
 
1414
1722
  on('ui.render', { component: 'Pane', requestId: SPEC_PANE }, async ($, e) => drawSpecPane($, e))
1415
1723
 
1416
- on('command.run', { command: PANE }, async $ => {
1417
- await openPane($)
1724
+ on('command.run', { command: PANE }, async paneHost => {
1725
+ await openPane(paneHost)
1418
1726
  return {}
1419
1727
  })
1420
1728
 
1421
1729
  // Typed before any prompt, the command reads the report itself, so it never
1422
1730
  // opens on a report no prompt has fetched yet (design D5).
1423
- on('command.run', { command: PREFLIGHT_PANE }, async $ => {
1424
- if (session.interactive) await readPreflight($, session.cwd)
1425
- await openPane($, PREFLIGHT_PANE, 'Interlock preflight')
1731
+ on('command.run', { command: PREFLIGHT_PANE }, async preflightHost => {
1732
+ if (session.interactive) await readPreflight(preflightHost, session.cwd)
1733
+ await openPane(preflightHost, PREFLIGHT_PANE, 'Interlock preflight')
1426
1734
  return {}
1427
1735
  })
1428
1736
 
1429
- on('command.run', { command: HANDOFF_PANE }, async $ => {
1430
- if (session.interactive) await readPreflight($, session.cwd)
1431
- await openPane($, HANDOFF_PANE, 'Interlock handoff')
1737
+ on('command.run', { command: HANDOFF_PANE }, async handoffHost => {
1738
+ if (session.interactive) await readPreflight(handoffHost, session.cwd)
1739
+ await openPane(handoffHost, HANDOFF_PANE, 'Interlock handoff')
1432
1740
  return {}
1433
1741
  })
1434
1742
 
1435
- on('command.run', { command: SPEC_PANE }, async $ => {
1436
- await openPane($, SPEC_PANE, SPEC_TITLE)
1743
+ on('command.run', { command: SPEC_PANE }, async specCmdHost => {
1744
+ await openPane(specCmdHost, SPEC_PANE, SPEC_TITLE)
1437
1745
  return {}
1438
1746
  })
1439
1747
  }
package/lib/draw-plan.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  // `lib/limits.mjs` only (`test/spine/draw-plan.test.mjs` walks it). The widths
17
17
  // are `interlock limits`'s.
18
18
 
19
- import { TITLE_SEPARATOR, fitRow, laneEffort, laneLabel, laneModel, laneTier, laneTitle } from './lane.mjs'
19
+ import { TITLE_SEPARATOR, fitRow, laneEffort, laneGist, laneLabel, laneModel, laneTier, laneTitle } from './lane.mjs'
20
20
  import { LIMITS } from './limits.mjs'
21
21
 
22
22
  /** The words a lane's state cell may read, each the state's own record. */
@@ -145,12 +145,13 @@ function taskWord(overlay, id, position, batch) {
145
145
  /** A lane's state cell and ids cell: one word when its tasks agree, else per task. */
146
146
  function laneCells(overlay, lane, position, batch) {
147
147
  const ids = lane.map(task => task.id)
148
- if (!overlay) return { word: null, ids: ids.length > 1 ? ids.join(', ') : null }
148
+ if (!overlay) return { word: null, ids: ids.length > 1 ? ids.join(', ') : null, tasks: null }
149
149
  const words = lane.map(task => taskWord(overlay, task.id, position, batch))
150
+ const tasks = ids.map((id, i) => ({ id, word: words[i] }))
150
151
  if (words.every(word => word === words[0])) {
151
- return { word: words[0], ids: ids.length > 1 ? ids.join(', ') : null }
152
+ return { word: words[0], ids: ids.length > 1 ? ids.join(', ') : null, tasks }
152
153
  }
153
- return { word: PER_TASK, ids: ids.map((id, i) => (words[i] ? `${id} ${words[i]}` : id)).join(', ') }
154
+ return { word: PER_TASK, ids: ids.map((id, i) => (words[i] ? `${id} ${words[i]}` : id)).join(', '), tasks }
154
155
  }
155
156
 
156
157
  /**
@@ -280,7 +281,6 @@ function lanesOf(positions, plan) {
280
281
  for (const lane of batch) {
281
282
  const label = laneLabel(lane)
282
283
  const title = laneTitle(lane)
283
- const prefix = `${label}${TITLE_SEPARATOR}`
284
284
  const tier = laneTier(lane)
285
285
  out.push({
286
286
  lane,
@@ -288,7 +288,7 @@ function lanesOf(positions, plan) {
288
288
  batch: b,
289
289
  label,
290
290
  title,
291
- gist: title.startsWith(prefix) ? title.slice(prefix.length) : '',
291
+ gist: laneGist(lane),
292
292
  model: laneModel(lane),
293
293
  tier: tier > 0 ? String(tier) : '?',
294
294
  effort: laneEffort(lane) ?? 'inherit',
@@ -309,7 +309,9 @@ function topRule(text, width) {
309
309
 
310
310
  /**
311
311
  * The plan as rows of `{ key, text }`: `header`, `wave:<i>`, `lane:<label>`,
312
- * `wave:<i>:end`, `verify:<i>`, `unplaced:<n>`, `tail`.
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.
313
315
  */
314
316
  function boardRows(plan, opts) {
315
317
  const width = boardWidth(opts.columns)
@@ -323,7 +325,11 @@ function boardRows(plan, opts) {
323
325
  const notes = new Map(Object.entries(isObject(opts.notes) ? opts.notes : {}).filter(([, text]) => isText(text)))
324
326
  const mode = overlayHeader(overlay, positions, opts.stateNote)
325
327
  const rows = []
326
- const push = (key, text) => rows.push({ key, text: fitRow(text, width) })
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
+ }
327
333
 
328
334
  push(
329
335
  'header',
@@ -340,9 +346,10 @@ function boardRows(plan, opts) {
340
346
  const i = position.index - 1
341
347
  push(`verify:${i}`, [` verify after idx ${i}`, ...texts[i]].join(' · '))
342
348
  }
343
- push(`wave:${position.index}`, topRule(waveTitle(position), width))
349
+ const title = waveTitle(position)
350
+ push(`wave:${position.index}`, topRule(title, width), { title })
344
351
  for (const l of lanes.filter(l => l.position === position.index)) {
345
- const { word, ids } = laneCells(overlay, l.lane, l.position, l.batch)
352
+ const { word, ids, tasks } = laneCells(overlay, l.lane, l.position, l.batch)
346
353
  const cells = [
347
354
  '│',
348
355
  `b${l.batch}`.padEnd(batchCell),
@@ -359,7 +366,19 @@ function boardRows(plan, opts) {
359
366
  if (ids) cells.push(`[${ids}]`)
360
367
  if (l.after.length) cells.push(`←${l.after.join(',')}`)
361
368
  const head = cells.join(' ')
362
- push(`lane:${l.label}`, l.gist ? `${head}${TITLE_SEPARATOR}${l.gist}` : head.trimEnd())
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
+ })
363
382
  }
364
383
  push(`wave:${position.index}:end`, `└${'─'.repeat(width - 1)}`)
365
384
  }
@@ -385,7 +404,7 @@ function boardRows(plan, opts) {
385
404
  *
386
405
  * @param {unknown} plan what `planWaves` emits, or a plan summary; any value draws
387
406
  * @param {{columns?: number, state?: object|null, stateNote?: string, notes?: Record<string, string>}} [opts]
388
- * @returns {{key: string, text: string}[]}
407
+ * @returns {{key: string, text: string, parts?: object}[]}
389
408
  */
390
409
  export function drawPlanBoardRows(plan, opts = {}) {
391
410
  return boardRows(plan, isObject(opts) ? opts : {})
package/lib/lane.mjs CHANGED
@@ -53,8 +53,35 @@ export function titleGist(description) {
53
53
  }
54
54
 
55
55
  /**
56
- * The name a lane is SHOWN under — its label, then the first words of its first
57
- * task's description: `1.1+5 · Add the relaunch guard to…`.
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
+ */
61
+ export 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
+ */
71
+ export 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.
58
85
  *
59
86
  * Display only. Everything that joins on a lane — its briefing file, worktree,
60
87
  * trajectory line, record-batch fold — keys on `laneLabel`, because a key built
@@ -62,11 +89,8 @@ export function titleGist(description) {
62
89
  * function of the lane, so a replay displays, and cache-hits, the same agent.
63
90
  */
64
91
  export function laneTitle(lane) {
65
- const label = laneLabel(lane)
66
- const gist = titleGist(lane[0] && lane[0].description)
67
- if (!gist) return label
68
- const full = `${label}${TITLE_SEPARATOR}${gist}`
69
- return full.length <= TITLE_MAX ? full : `${full.slice(0, TITLE_MAX - 1).trimEnd()}…`
92
+ const gist = laneGist(lane)
93
+ return gist ? `${laneName(lane)}${TITLE_SEPARATOR}${gist}` : laneName(lane)
70
94
  }
71
95
 
72
96
  /**
@@ -0,0 +1,51 @@
1
+ // 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
+
16
+ export 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
+
31
+ const 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. */
34
+ export 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
+ */
47
+ export 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
+ }
package/lib/waves.mjs CHANGED
@@ -484,7 +484,7 @@ function capForTier(caps, tier) {
484
484
  // The lane rule — tier, label, title, model, effort — lives in `lib/lane.mjs`,
485
485
  // a module the engine can load (draw-wave-plan-and-handoff-graph-from-the-cli
486
486
  // design D3). Re-exported so every importer of it from here is unchanged.
487
- export { laneEffort, laneLabel, laneModel, laneTier, laneTitle, TITLE_SEPARATOR } from './lane.mjs'
487
+ export { laneEffort, laneGist, laneLabel, laneModel, laneName, laneTier, laneTitle, TITLE_SEPARATOR } from './lane.mjs'
488
488
 
489
489
  /**
490
490
  * Group a group's tasks into lanes: connected components over canonical-path
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@renzrollon/interlock",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec",
5
5
  "type": "module",
6
6
  "engines": {
@@ -22,7 +22,7 @@
22
22
  "agents",
23
23
  "shared",
24
24
  ".claude-plugin/plugin.json",
25
- ".claude-plugin/icon.png",
25
+ "assets",
26
26
  "README.md",
27
27
  "LICENSE",
28
28
  "CHANGELOG.md"
File without changes