@renzrollon/interlock 1.3.1 → 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.1",
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",
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ 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
+ ## 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
+
8
24
  ## 1.3.1 — 2026-10-08
9
25
 
10
26
  ### Changed
package/hooks/mod.mjs CHANGED
@@ -65,10 +65,20 @@
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
67
  // The module lays those rows as one bordered card per wave, wraps the gist,
68
- // and colours only the state word the renderer chose. It keys the lanes,
68
+ // and colours the state word the renderer chose and the lane's label. It keys the lanes,
69
69
  // appends what the host observed of a lane, and speaks every fallback: a CLI
70
70
  // that relays no plan, a pane narrower than the board, a renderer that throws.
71
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
+ //
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
74
84
  // report `hooks/preflight.mjs` leaves at `.claude/ship/preflight.json`: 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.
@@ -611,6 +633,72 @@ async function noteSpawn($, input, spawned) {
611
633
  return spawned
612
634
  }
613
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
+
614
702
  /**
615
703
  * A clear, resume or fork ends the run the meter holds, before the chain
616
704
  * beneath runs. Any other source, and a non-interactive session, changes
@@ -658,11 +746,14 @@ function drawSpecPane($, e) {
658
746
  return h(Box, { flexDirection: 'column' }, ...specPaneLines(spec).map(l => h(Box, { key: l.key }, h(Text, props(l), l.text))))
659
747
  }
660
748
 
661
- /** 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
+ */
662
753
  async function launchGuardText($) {
663
754
  const { record, now } = await guardFacts($)
664
755
  const ruling = decideLaunch(record, now)
665
- return launchGuardLine(ruling, now)
756
+ return { text: launchGuardLine(ruling, now), refused: now !== null && ruling.decision !== 'allow' && Boolean(ruling.reason) }
666
757
  }
667
758
 
668
759
  /** Names, once per run record, a step that crossed with no live run (design D4). */
@@ -719,6 +810,8 @@ function agentOf(id) {
719
810
  reason: null,
720
811
  durationMs: null,
721
812
  sha: null,
813
+ // The two words after `interlock` in the first driver line it ran while no row named it.
814
+ relay: null,
722
815
  // Whether its turn end has been spoken: once per agent per run (speak-lane-turn-ends design D1).
723
816
  endToasted: false
724
817
  }
@@ -831,6 +924,8 @@ async function applyRecord($, record) {
831
924
  model: str(s.model),
832
925
  effort: str(s.effort),
833
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),
834
929
  // Spawned by a step that dispatches a batch: a lane, planned or not.
835
930
  batch: BATCH_ACTIONS.has(record.action)
836
931
  })
@@ -979,9 +1074,14 @@ function rowForAgent(a) {
979
1074
  return null
980
1075
  }
981
1076
 
982
- function laneForAgent(a) {
983
- const r = rowForAgent(a)
984
- 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
985
1085
  }
986
1086
 
987
1087
  /**
@@ -1007,24 +1107,53 @@ function sessionLines(usage, failure) {
1007
1107
 
1008
1108
  /**
1009
1109
  * `last activity <ISO>` or `last activity unknown`, then the quiet word while a
1010
- * live run is quiet. The word is recomputed from the clock at each draw, so a
1011
- * 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).
1012
1113
  */
1013
1114
  async function lastActivityText($) {
1014
- if (run.lastActivityAt === null) return 'last activity unknown'
1115
+ if (run.lastActivityAt === null) return { line: 'last activity unknown', word: null }
1015
1116
  const at = new Date(run.lastActivityAt).toISOString()
1016
1117
  const word = run.phase === 'live' ? quietWord(quietMs(run.lastActivityAt, await clockNow($))) : null
1017
- return `last activity ${at}${word ? ` · ${word}` : ''}`
1118
+ return { line: `last activity ${at}`, word }
1018
1119
  }
1019
1120
 
1020
- /** One spawn as a flat row, as the section drew every spawn before the board. */
1021
- 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)
1022
1145
  const a = agentForSha(r.sha)
1023
1146
  const served = a && a.models.length ? a.models.join(', ') : '?'
1024
1147
  const state = a ? a.reason || 'running' : 'waiting'
1025
- return keyed(
1026
- `wave-row-${r.label}`,
1027
- `${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
+ )
1028
1157
  )
1029
1158
  }
1030
1159
 
@@ -1064,17 +1193,6 @@ function boardKey(key) {
1064
1193
  return `${kind === 'wave' ? 'wave-block' : `wave-${kind}`}-${rest.join('-')}`
1065
1194
  }
1066
1195
 
1067
- /** Colour only a state word the board printed: hue for ok/failed/current, dim for the rest. */
1068
- function stateProps(word) {
1069
- if (word === 'ok') return { color: 'green' }
1070
- if (word === 'failed') return { color: 'red' }
1071
- if (word === 'current') return { color: 'yellow' }
1072
- if (word === 'pending' || word === 'not reached' || word === 'not recorded' || word === 'per task') {
1073
- return { dimColor: true }
1074
- }
1075
- return null
1076
- }
1077
-
1078
1196
  /** The width a card and the renderer share: the body, capped at the published default. */
1079
1197
  function boardColumns(e) {
1080
1198
  const body = e.props && isInt(e.props.bodyColumns) && e.props.bodyColumns > 0
@@ -1107,6 +1225,22 @@ function idSpans(Text, parts) {
1107
1225
  return parts.ids ? [` [${parts.ids}]`] : []
1108
1226
  }
1109
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
+
1110
1244
  /**
1111
1245
  * A lane in its card: the fixed cells as one Text, the gist wrapping beneath,
1112
1246
  * then the host's note, dim. The coloured words are spans nested in that one
@@ -1118,9 +1252,9 @@ function drawLane($, e, row) {
1118
1252
  const key = boardKey(row.key)
1119
1253
  const parts = row.parts
1120
1254
  if (!parts) return h(Box, { key }, h(Text, { wrap: 'truncate-end' }, row.text))
1121
- const head = [parts.batch, parts.label, parts.model, parts.tier, parts.effort].filter(Boolean).join(' ')
1122
1255
  const line = [
1123
- ...(parts.state ? [`${head} `, h(Text, stateProps(parts.state), parts.state)] : [head]),
1256
+ ...headSpans(Text, parts),
1257
+ ...(parts.state ? [' ', h(Text, stateProps(parts.state), parts.state)] : []),
1124
1258
  ...idSpans(Text, parts),
1125
1259
  ...(Array.isArray(parts.after) && parts.after.length ? [` ←${parts.after.join(',')}`] : [])
1126
1260
  ]
@@ -1138,6 +1272,8 @@ function drawBoardCards($, e, board, width) {
1138
1272
  const out = []
1139
1273
  let index = null
1140
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
1141
1277
  const flush = () => {
1142
1278
  if (index === null) return
1143
1279
  out.push(
@@ -1147,7 +1283,7 @@ function drawBoardCards($, e, board, width) {
1147
1283
  key: `wave-card-${index}`,
1148
1284
  flexDirection: 'column',
1149
1285
  borderStyle: 'single',
1150
- borderDimColor: true,
1286
+ ...(current ? { borderColor: PALETTE.current } : { borderDimColor: true }),
1151
1287
  width,
1152
1288
  paddingLeft: 1,
1153
1289
  paddingRight: 1,
@@ -1158,6 +1294,7 @@ function drawBoardCards($, e, board, width) {
1158
1294
  )
1159
1295
  index = null
1160
1296
  kids = null
1297
+ current = false
1161
1298
  }
1162
1299
  for (const row of board) {
1163
1300
  if (row.key.startsWith('wave:') && row.key.endsWith(':end')) continue
@@ -1169,8 +1306,10 @@ function drawBoardCards($, e, board, width) {
1169
1306
  }
1170
1307
  if (row.key.startsWith('lane:')) {
1171
1308
  const lane = drawLane($, e, row)
1172
- if (kids) kids.push(lane)
1173
- else out.push(lane)
1309
+ if (kids) {
1310
+ kids.push(lane)
1311
+ if (isCurrentLane(row.parts)) current = true
1312
+ } else out.push(lane)
1174
1313
  continue
1175
1314
  }
1176
1315
  flush()
@@ -1197,7 +1336,7 @@ function drawWaves($, e) {
1197
1336
  if (!run.plan) {
1198
1337
  if (!rows.length) return [h(Text, { dimColor: true }, NO_LANES_LINE)]
1199
1338
  const dispatched = run.dispatched !== null || rows.some(r => r.batch)
1200
- 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))]
1201
1340
  }
1202
1341
  const width = boardColumns(e)
1203
1342
  let board
@@ -1205,29 +1344,41 @@ function drawWaves($, e) {
1205
1344
  board = drawPlanBoardRows(run.plan, { columns: width, state: boardOverlay(run.plan), notes: laneNotes() })
1206
1345
  } catch (err) {
1207
1346
  $.ui.log(`interlock meter: ${BOARD_FAILED_LINE}: ${messageOf(err)}`, { to: 'debug' })
1208
- 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))]
1209
1348
  }
1210
1349
  // Every board ends in its tail; the renderer's spoken line for a narrow body
1211
1350
  // is one row without one, and the lanes are then drawn flat beneath it.
1212
1351
  const drawn = board.length > 0 && board[board.length - 1].key === 'tail'
1213
1352
  const planned = new Set(drawn ? board.filter(row => row.key.startsWith('lane:')).map(row => row.key.slice('lane:'.length)) : [])
1214
1353
  const body = drawn ? drawBoardCards($, e, board, width) : board.map(row => line(boardKey(row.key), row.text))
1215
- return [...body, ...rows.filter(r => !planned.has(r.label)).map(r => flatRow(keyed, r))]
1354
+ return [...body, ...rows.filter(r => !planned.has(r.label)).map(r => flatRow($, e, r))]
1216
1355
  }
1217
1356
 
1218
1357
  async function drawPane($, e) {
1219
1358
  const { Box, Text } = $.ui.resolve(e)
1220
1359
  const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
1221
- const heading = text => h(Box, { marginTop: 1 }, h(Text, { bold: true }, 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
+ }
1222
1366
  if (run.phase === 'idle' && !run.summary) {
1223
1367
  // The guard runs in every session, so its line stands here too (design D5).
1224
- 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())
1225
1369
  }
1226
1370
 
1227
1371
  const out = []
1228
- 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}` : ''}`))
1229
1373
  out.push(keyed('action', `action: ${run.action || 'starting'}${run.phase === 'closed' ? ' (closed)' : ''}`))
1230
- 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
+ )
1231
1382
  out.push(h(Text, { dimColor: true }, RECORD_LINE))
1232
1383
 
1233
1384
  out.push(heading('waves'))
@@ -1236,18 +1387,18 @@ async function drawPane($, e) {
1236
1387
  out.push(heading('agents'))
1237
1388
  if (!run.agents.size) out.push(h(Text, { dimColor: true }, 'no agent has reported a request yet'))
1238
1389
  for (const a of run.agents.values()) {
1239
- const lane = laneForAgent(a)
1240
1390
  const n = a.requests === 1 ? '1 request' : `${a.requests} requests`
1241
1391
  const done = a.reason ? `${a.reason}${a.durationMs !== null ? ` ${(a.durationMs / 1000).toFixed(1)}s` : ''}` : 'running'
1242
1392
  out.push(
1243
1393
  h(
1244
1394
  Box,
1245
1395
  { key: `agent-${a.id}`, flexDirection: 'column' },
1246
- h(Box, null, h(Text, null, `${lane || 'lane unknown'} · ${a.id} · ${a.models.join(', ') || 'model not reported'} · ${done}`)),
1396
+ h(Box, null, agentLine(Text, a, done)),
1247
1397
  h(Box, null, h(Text, { dimColor: true }, `${n} · ${tokensText(a)}`))
1248
1398
  )
1249
1399
  )
1250
1400
  }
1401
+ if ([...run.agents.values()].some(a => !agentName(a))) out.push(keyed('agents-unmatched', UNMATCHED_LINE, { dimColor: true }))
1251
1402
 
1252
1403
  // One plain read feeds the session lines and the plan windows; a breakdown
1253
1404
  // is never asked for, because only the plain call is free (design D4).
@@ -1279,14 +1430,14 @@ async function drawPane($, e) {
1279
1430
  h(
1280
1431
  Box,
1281
1432
  { key: 'banners', flexDirection: 'column' },
1282
- ...(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')])
1283
1434
  )
1284
1435
  )
1285
1436
 
1286
1437
  out.push(heading('refusals'))
1287
1438
  const denials = guardDenialLine(run.guardDenials)
1288
- if (denials) out.push(keyed('guard-denials', denials))
1289
- out.push(keyed('launch-guard', await launchGuardText($)))
1439
+ if (denials) out.push(keyed('guard-denials', denials, { color: PALETTE.alarm }))
1440
+ out.push(await guardLine())
1290
1441
 
1291
1442
  if (run.summary) {
1292
1443
  out.push(heading('close summary'))
@@ -1444,6 +1595,12 @@ export function register(on) {
1444
1595
  return r
1445
1596
  }
1446
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
+ }
1447
1604
  const r = await next(e)
1448
1605
  // Any Bash call during the run is read for a guard's refusal (guard-commit); only a driver line for a step.
1449
1606
  if (held === run) speakRefusal($, r)
@@ -1470,6 +1627,14 @@ export function register(on) {
1470
1627
  return r
1471
1628
  })
1472
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
+
1473
1638
  // The tools the manifest's `Edit|Write` matcher names for guard-tests and guard-tasks (design D4).
1474
1639
  on('tool.call', { tool: 'Edit' }, editCall)
1475
1640
  on('tool.call', { tool: 'Write' }, editCall)
@@ -1499,9 +1664,8 @@ export function register(on) {
1499
1664
  // (speak-lane-turn-ends-and-session-cost design D1-D3).
1500
1665
  if (a.reason && a.reason !== 'answer' && !a.endToasted) {
1501
1666
  a.endToasted = true
1502
- const row = rowForAgent(a)
1503
1667
  try {
1504
- $.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}`)
1505
1669
  } catch (err) {
1506
1670
  $.ui.log(`interlock meter: the turn-end toast for ${a.id} was refused: ${messageOf(err)}`, { to: 'debug' })
1507
1671
  }
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. */
@@ -281,7 +281,6 @@ function lanesOf(positions, plan) {
281
281
  for (const lane of batch) {
282
282
  const label = laneLabel(lane)
283
283
  const title = laneTitle(lane)
284
- const prefix = `${label}${TITLE_SEPARATOR}`
285
284
  const tier = laneTier(lane)
286
285
  out.push({
287
286
  lane,
@@ -289,7 +288,7 @@ function lanesOf(positions, plan) {
289
288
  batch: b,
290
289
  label,
291
290
  title,
292
- gist: title.startsWith(prefix) ? title.slice(prefix.length) : '',
291
+ gist: laneGist(lane),
293
292
  model: laneModel(lane),
294
293
  tier: tier > 0 ? String(tier) : '?',
295
294
  effort: laneEffort(lane) ?? 'inherit',
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.1",
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": {