@renzrollon/interlock 1.3.1 → 1.4.1

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.1",
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,44 @@ 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.1 — 2026-10-09
9
+
10
+ ### Changed
11
+
12
+ - **The ship meter pane reads as a timeline.** In 1.4.0 the `agents` section was a flat list in first-seen order: relay pings, the planner and lane workers were interleaved, nothing said when anything happened, and lanes that ran in parallel looked like lanes that ran in turn.
13
+ - **The `timeline` section replaces it.** There is one line per CLI step as it crossed, such as `run-batch · wave 2 · batch 1/2 · 3 in parallel` or `verify · wave 2 · skipped: <reason>`. The relay agent that carried the step is folded into that line: `cli <id>`, its served model, its request count and its duration.
14
+ - **Each step's agents** are indented beneath it in the order they started, with name, id, model, turn word and a dim requests-and-tokens line. A spawn no agent has picked up yet reads `waiting`. A relay whose line printed no step, such as `interlock limits`, stands at its own time, and agents nothing names are listed last under `unmatched`.
15
+ - **Every timed line has a local `HH:MM:SS` gutter**, blank where the clock gave no reading, never a guessed time.
16
+ - **The header** reads `started <date> <time> · last activity <time>`, replacing `last activity <ISO>`.
17
+ - **Durations** read `41.2s`, `2m 14s` or `1h 03m`, replacing `134.2s`.
18
+ - **Wave cards group their lanes by batch**, under a line with the batch's dispatch time and `N in parallel` or `1 lane`. The lanes are indented beneath it and no longer repeat the batch cell. A lane's note adds its duration once its turn ended.
19
+ - **Each section's body is indented** under its heading.
20
+ - **Relay token figures:** a relay's token breakdown is no longer drawn. The trajectory's `agent-result` line still carries it.
21
+ - **New modules:** the words and the order come from two new Node-free modules, `lib/meter-time.mjs` and `lib/meter-timeline.mjs`, pinned by `test/spine/meter-time.test.mjs` and `test/spine/meter-timeline.test.mjs`.
22
+ - **Layout probe:** a probe on 2.1.295 showed the gutter keeps a hanging indent when a line wraps, inside a card too. The pane is as tall as the engine seats it, and a long run scrolls.
23
+ - **Unchanged:** `interlock waves --format board` prints exactly what it printed before.
24
+
25
+ ### Fixed
26
+
27
+ - **A run resumed across the 1.4.0 upgrade does not re-run its lanes.** The 1.4.0 entry said it would, because the runtime caches an agent by its label. The runtime keys an agent on its prompt and its options together, the label among them, but a lane's label is the title its step carried, and on a resume the completed relays hand back the steps they first printed. So every lane the run had already dispatched is spawned under its old title and returns its saved result. `docs/04` now says the replay cache keys on the prompt and the options, not on the prompt text alone, and says why an upgrade does not rename the lanes a resume replays.
28
+ - **`docs/07` no longer says the board names a lane exactly as its spawn.** Since 1.4.0 the spawn is shown as `task 1.1 · …`, while the board, the briefing file, the worktree and the trajectory line name the lane by its label, `1.1`.
29
+
30
+ ## 1.4.0 — 2026-10-09
31
+
32
+ ### Changed
33
+
34
+ - **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.
35
+ - **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.
36
+ - **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.
37
+ - State words: `success`, `error` and `warning` for `ok`, `failed` and `current`, replacing raw green, red and yellow.
38
+ - Names: lane labels and agent names in `suggestion`, bold.
39
+ - Headings: the run header and section headings in `claude`.
40
+ - Cards: the card of the wave holding a `current` lane is bordered in `warning`; every other card stays dim.
41
+ - 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.
42
+ - Warnings: the quiet word and banners in `warning`.
43
+ - Refusals: the guard-denial line, and the launch-guard line when it refuses, in `error`.
44
+ - 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`.
45
+
8
46
  ## 1.3.1 — 2026-10-08
9
47
 
10
48
  ### Changed
package/hooks/mod.mjs CHANGED
@@ -65,10 +65,31 @@
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
+ //
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
+ //
72
93
  // THE SESSION START (show-preflight-and-interrupted-runs-at-session-start
73
94
  // design D5-D8). Outside a run the module draws two things, both from the
74
95
  // report `hooks/preflight.mjs` leaves at `.claude/ship/preflight.json`: the
@@ -101,7 +122,10 @@
101
122
  import { drawPlanBoardRows } from '../lib/draw-plan.mjs'
102
123
  import { decideLaunch, emptyRecord, isAcceptedLaunch, isShipLaunch, withLaunch, withPrompt } from '../lib/launch-rule.mjs'
103
124
  import { LIMITS } from '../lib/limits.mjs'
125
+ import { PALETTE, stateProps, turnProps } from '../lib/meter-palette.mjs'
104
126
  import { TICK_MS, quietMs, quietWord } from '../lib/meter-quiet.mjs'
127
+ import { clockText, durationText, stampText } from '../lib/meter-time.mjs'
128
+ import { BATCH_ACTIONS, batchText, laneCountText, planWaveCount, stepText, timeline } from '../lib/meter-timeline.mjs'
105
129
  import { guardDenialLine, guardOf, guardReason, launchGuardLine } from '../lib/meter-refusals.mjs'
106
130
  import {
107
131
  adoptPreflight,
@@ -144,8 +168,6 @@ const UNPLACED_TOAST = '/interlock-meter opens the ship meter'
144
168
  const NO_LANES_LINE = 'no lanes dispatched yet'
145
169
  const UNRELAYED_LINE = 'plan structure not relayed by this CLI'
146
170
  const BOARD_FAILED_LINE = 'wave board could not be drawn'
147
- // The actions that dispatch a batch of lanes, and so place the board's cursor on one.
148
- const BATCH_ACTIONS = new Set(['run-batch', 'test-wave'])
149
171
  // The session start's two panes, each opened by the command of its name (design D6).
150
172
  const PREFLIGHT_PANE = 'interlock-preflight'
151
173
  const HANDOFF_PANE = 'interlock-handoff'
@@ -166,6 +188,23 @@ const GUARD = 'interlock guard'
166
188
  const DRIVER_LINE = /^interlock\s/
167
189
  // The bootstrap line a lane's spawn prompt carries (as `lib/agent-usage.mjs` reads it).
168
190
  const 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.
192
+ const UNMATCHED = 'unmatched agent'
193
+ const 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.
196
+ const PATH_BEFORE = /[\s\/'"=]/
197
+ const PATH_AFTER = /[\w.+-]/
198
+ /** The props a name is drawn with: a lane label, a spawn's title, a joined agent's name. */
199
+ const NAME_PROPS = { bold: true, color: PALETTE.identity }
200
+ const 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).
203
+ const GUTTER_COLUMNS = 10
204
+ const INDENT = 2
205
+ const NO_STEP_LINE = 'no step has crossed yet'
206
+ /** The props the run header and each section heading are drawn with. */
207
+ const HEADING_PROPS = { bold: true, color: PALETTE.accent }
169
208
  const TOKEN_FIELDS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']
170
209
  // The classic session-start sources that start the session over, as the person
171
210
  // typed them (design D4). `startup` and `compact` are not among them.
@@ -208,6 +247,13 @@ function freshRun() {
208
247
  // activity (null until the clock answers), the word it makes, and the one
209
248
  // interval that recomputes it, known by its handle and its generation.
210
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(),
211
257
  quietWord: null,
212
258
  tick: null,
213
259
  generation: 0,
@@ -286,7 +332,8 @@ function position() {
286
332
  if (run.change) parts.push(run.change)
287
333
  if (run.action) parts.push(run.action)
288
334
  if (run.wave !== undefined && run.wave !== null) parts.push(`wave ${run.wave}`)
289
- if (isInt(run.batchIndex) && isInt(run.batchCount)) parts.push(`batch ${run.batchIndex + 1}/${run.batchCount}`)
335
+ const batch = batchText(run.batchIndex, run.batchCount)
336
+ if (batch) parts.push(batch)
290
337
  return parts.join(' · ')
291
338
  }
292
339
 
@@ -354,6 +401,7 @@ async function stamp($) {
354
401
  const held = run
355
402
  const now = await clockNow($)
356
403
  if (held !== run) return false
404
+ run.stampedAt = now
357
405
  if (now !== null) run.lastActivityAt = run.lastActivityAt === null ? now : Math.max(run.lastActivityAt, now)
358
406
  return setWord(null)
359
407
  }
@@ -611,6 +659,72 @@ async function noteSpawn($, input, spawned) {
611
659
  return spawned
612
660
  }
613
661
 
662
+ /** Whether `text` names `path` whole: behind its start or a separator, and not followed by more of a file name. */
663
+ function 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
+ */
681
+ function 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
+ */
705
+ function 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. */
713
+ function 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
+
614
728
  /**
615
729
  * A clear, resume or fork ends the run the meter holds, before the chain
616
730
  * beneath runs. Any other source, and a non-interactive session, changes
@@ -658,11 +772,14 @@ function drawSpecPane($, e) {
658
772
  return h(Box, { flexDirection: 'column' }, ...specPaneLines(spec).map(l => h(Box, { key: l.key }, h(Text, props(l), l.text))))
659
773
  }
660
774
 
661
- /** The launch guard's ruling now, over the record its branch would read, in the rule's words (design D5). */
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
+ */
662
779
  async function launchGuardText($) {
663
780
  const { record, now } = await guardFacts($)
664
781
  const ruling = decideLaunch(record, now)
665
- return launchGuardLine(ruling, now)
782
+ return { text: launchGuardLine(ruling, now), refused: now !== null && ruling.decision !== 'allow' && Boolean(ruling.reason) }
666
783
  }
667
784
 
668
785
  /** Names, once per run record, a step that crossed with no live run (design D4). */
@@ -719,14 +836,27 @@ function agentOf(id) {
719
836
  reason: null,
720
837
  durationMs: null,
721
838
  sha: null,
839
+ // The two words after `interlock` in the first driver line it ran while no row named it.
840
+ relay: null,
722
841
  // Whether its turn end has been spoken: once per agent per run (speak-lane-turn-ends design D1).
723
- endToasted: false
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
724
847
  }
725
848
  run.agents.set(id, a)
726
849
  }
727
850
  return a
728
851
  }
729
852
 
853
+ /** The agent's first request, timed once by the stamp just taken: a later reading never moves it. */
854
+ function firstRequest(a) {
855
+ if (a.stepped) return
856
+ a.stepped = true
857
+ a.firstAt = run.stampedAt
858
+ }
859
+
730
860
  /** Field by field: a field one request omitted leaves that figure unknown (`null`), never a guessed zero (design D5). */
731
861
  function addUsage(a, usage) {
732
862
  a.requests++
@@ -812,7 +942,34 @@ function takeBoard($, record) {
812
942
  if (record.action === 'halt') run.halt = { reason: str(record.reason) || (run.halt ? run.halt.reason : null) }
813
943
  }
814
944
 
815
- async function applyRecord($, record) {
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
+ */
950
+ function 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
+
972
+ async function applyRecord($, record, relayId) {
816
973
  const held = run
817
974
  await stamp($)
818
975
  if (held !== run) return
@@ -822,8 +979,10 @@ async function applyRecord($, record) {
822
979
  if (record.wave !== undefined) run.wave = record.wave
823
980
  run.batchIndex = record.batchIndex
824
981
  run.batchCount = record.batchCount
982
+ const spawns = []
825
983
  for (const s of Array.isArray(record.spawns) ? record.spawns : []) {
826
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) })
827
986
  run.rows.set(s.label, {
828
987
  label: s.label,
829
988
  title: str(s.title) || s.label,
@@ -831,10 +990,13 @@ async function applyRecord($, record) {
831
990
  model: str(s.model),
832
991
  effort: str(s.effort),
833
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),
834
995
  // Spawned by a step that dispatches a batch: a lane, planned or not.
835
996
  batch: BATCH_ACTIONS.has(record.action)
836
997
  })
837
998
  }
999
+ noteStep(record, relayId, spawns)
838
1000
  takeBoard($, record)
839
1001
  for (const banner of Array.isArray(record.banners) ? record.banners : []) {
840
1002
  if (typeof banner !== 'string' || !banner) continue
@@ -979,9 +1141,14 @@ function rowForAgent(a) {
979
1141
  return null
980
1142
  }
981
1143
 
982
- function laneForAgent(a) {
983
- const r = rowForAgent(a)
984
- return r ? r.label : null
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
+ */
1148
+ function agentName(a) {
1149
+ const row = rowForAgent(a)
1150
+ if (row) return row.title
1151
+ return a.relay ? `cli · ${a.relay}` : null
985
1152
  }
986
1153
 
987
1154
  /**
@@ -1006,25 +1173,133 @@ function sessionLines(usage, failure) {
1006
1173
  }
1007
1174
 
1008
1175
  /**
1009
- * `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).
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).
1012
1181
  */
1013
1182
  async function lastActivityText($) {
1014
- if (run.lastActivityAt === null) return 'last activity unknown'
1015
- const at = new Date(run.lastActivityAt).toISOString()
1183
+ const started = run.launchedAt === null ? '' : `started ${stampText(run.launchedAt)} · `
1184
+ if (run.lastActivityAt === null) return { line: `${started}last activity unknown`, word: null }
1016
1185
  const word = run.phase === 'live' ? quietWord(quietMs(run.lastActivityAt, await clockNow($))) : null
1017
- return `last activity ${at}${word ? ` · ${word}` : ''}`
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. */
1190
+ const id8 = id => String(id).slice(0, 8)
1191
+
1192
+ /** `1 request`, `N requests`. */
1193
+ const 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. */
1196
+ const 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. */
1200
+ const abnormal = a => Boolean(a.reason) && a.reason !== 'answer'
1201
+
1202
+ /**
1203
+ * An agent's first line, one wrapping text: its name, its id and models dim,
1204
+ * then its turn word. A joined agent is named in the identity colour, a relay
1205
+ * dim, an unmatched agent dim and italic. The turn word is the host's, coloured
1206
+ * by what it means; a relay's running or answered turn stays dim, and only its
1207
+ * abnormal end is coloured.
1208
+ */
1209
+ function agentLine(Text, a, name, kind) {
1210
+ const nameProps = kind === 'joined' ? NAME_PROPS : kind === 'relay' ? DIM : { dimColor: true, italic: true }
1211
+ const quiet = kind === 'relay' && !abnormal(a)
1212
+ return h(
1213
+ Text,
1214
+ { wrap: 'wrap' },
1215
+ h(Text, nameProps, name),
1216
+ h(Text, DIM, ` · ${id8(a.id)} · ${a.models.join(', ') || 'model not reported'}`),
1217
+ ' · ',
1218
+ h(Text, quiet ? DIM : turnProps(a.reason), turnText(a))
1219
+ )
1220
+ }
1221
+
1222
+ /**
1223
+ * One timed line (layout probe P3): the local time in a fixed dim gutter,
1224
+ * blank when no time was read, then the content in a column that wraps beside
1225
+ * it, `depth` steps in. Each content line is one Text with its coloured words
1226
+ * nested inside it, never a row of sibling Texts.
1227
+ */
1228
+ function gutterRow(Box, Text, key, at, depth, ...content) {
1229
+ return h(
1230
+ Box,
1231
+ { key, flexDirection: 'row' },
1232
+ h(Box, { width: GUTTER_COLUMNS, flexShrink: 0 }, h(Text, DIM, clockText(at))),
1233
+ h(Box, { flexGrow: 1, flexShrink: 1, flexDirection: 'column', paddingLeft: depth * INDENT }, ...content)
1234
+ )
1235
+ }
1236
+
1237
+ /**
1238
+ * A step's line: its own words, then the agent whose Bash call printed it,
1239
+ * dim, with its served models, its requests and, once its turn ended, how long
1240
+ * it took; the relay's turn word only when the host ended it without an answer.
1241
+ */
1242
+ function stepLine(Text, step) {
1243
+ const spans = [stepText(step)]
1244
+ if (step.relayId) {
1245
+ const a = run.agents.get(step.relayId)
1246
+ const figures = a ? ` · ${a.models.join(', ') || 'model not reported'} · ${requestsText(a)}` : ''
1247
+ spans.push(h(Text, DIM, ` · cli ${id8(step.relayId)}${figures}`))
1248
+ if (a && abnormal(a)) spans.push(' · ', h(Text, turnProps(a.reason), turnText(a)))
1249
+ else if (a && a.durationMs !== null) spans.push(h(Text, DIM, ` · ${durationText(a.durationMs)}`))
1250
+ }
1251
+ return h(Text, { wrap: 'wrap' }, ...spans)
1018
1252
  }
1019
1253
 
1020
- /** One spawn as a flat row, as the section drew every spawn before the board. */
1021
- function flatRow(keyed, r) {
1254
+ /**
1255
+ * The timeline section: `lib/meter-timeline.mjs`'s entries, each a gutter
1256
+ * line. An agent a step spawned and an agent no step names keep the requests
1257
+ * and tokens line beneath their own; a relay's figures ride its step's line or
1258
+ * its own one line, and its token breakdown is the trajectory's.
1259
+ */
1260
+ function drawTimeline($, e) {
1261
+ const { Box, Text } = $.ui.resolve(e)
1262
+ const entries = timeline({
1263
+ steps: run.steps,
1264
+ agents: [...run.agents.values()],
1265
+ launch: { at: run.launchedAt, name: run.workflowName }
1266
+ })
1267
+ const figures = a => h(Text, { dimColor: true, wrap: 'wrap' }, `${requestsText(a)} · ${tokensText(a)}`)
1268
+ const out = []
1269
+ for (const entry of entries) {
1270
+ const row = (...content) => gutterRow(Box, Text, entry.key, entry.at, entry.depth, ...content)
1271
+ if (entry.kind === 'launch') out.push(row(h(Text, { wrap: 'wrap' }, `launch · ${entry.name || 'interlock:ship'}`)))
1272
+ else if (entry.kind === 'step') out.push(row(stepLine(Text, entry.step)))
1273
+ else if (entry.kind === 'spawn' && entry.agent) {
1274
+ out.push(row(agentLine(Text, entry.agent, entry.spawn.title, 'joined'), figures(entry.agent)))
1275
+ } else if (entry.kind === 'spawn') {
1276
+ out.push(row(h(Text, { wrap: 'wrap' }, h(Text, NAME_PROPS, entry.spawn.title), ' · ', h(Text, DIM, 'waiting'))))
1277
+ } else if (entry.kind === 'relay') out.push(row(agentLine(Text, entry.agent, `cli · ${entry.agent.relay}`, 'relay')))
1278
+ else if (entry.kind === 'unmatched-head') out.push(h(Box, { key: entry.key }, h(Text, DIM, 'unmatched')))
1279
+ else if (entry.kind === 'unmatched') out.push(row(agentLine(Text, entry.agent, UNMATCHED, 'unmatched'), figures(entry.agent)))
1280
+ }
1281
+ if (entries.some(entry => entry.kind === 'unmatched')) {
1282
+ out.push(h(Box, { key: 'agents-unmatched', paddingLeft: INDENT }, h(Text, { dimColor: true, wrap: 'wrap' }, UNMATCHED_LINE)))
1283
+ }
1284
+ if (!run.steps.length) out.push(h(Box, { key: 'timeline-no-step' }, h(Text, DIM, NO_STEP_LINE)))
1285
+ return out
1286
+ }
1287
+
1288
+ /** One spawn as a flat row, as the section drew every spawn before the board: its title in the identity colour. */
1289
+ function flatRow($, e, r) {
1290
+ const { Box, Text } = $.ui.resolve(e)
1022
1291
  const a = agentForSha(r.sha)
1023
1292
  const served = a && a.models.length ? a.models.join(', ') : '?'
1024
1293
  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}`
1294
+ return h(
1295
+ Box,
1296
+ { key: `wave-row-${r.label}` },
1297
+ h(
1298
+ Text,
1299
+ null,
1300
+ h(Text, NAME_PROPS, r.title),
1301
+ ` · ${r.kind || 'agent'} · routed ${r.model || '?'} · served ${served} · effort ${r.effort || '?'} · ${state}`
1302
+ )
1028
1303
  )
1029
1304
  }
1030
1305
 
@@ -1051,7 +1326,7 @@ function laneNotes() {
1051
1326
  for (const r of run.rows.values()) {
1052
1327
  const a = agentForSha(r.sha)
1053
1328
  const served = a && a.models.length ? a.models.join(', ') : '?'
1054
- notes[r.label] = `served ${served}${a ? ` · ${a.reason || 'running'}` : ''}`
1329
+ notes[r.label] = `served ${served}${a ? ` · ${turnText(a)}` : ''}`
1055
1330
  }
1056
1331
  return notes
1057
1332
  }
@@ -1064,22 +1339,13 @@ function boardKey(key) {
1064
1339
  return `${kind === 'wave' ? 'wave-block' : `wave-${kind}`}-${rest.join('-')}`
1065
1340
  }
1066
1341
 
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
- /** The width a card and the renderer share: the body, capped at the published default. */
1342
+ /**
1343
+ * The width a card and the renderer share: the section body (the pane's body
1344
+ * less the body's indent beneath its heading), capped at the published default.
1345
+ */
1079
1346
  function boardColumns(e) {
1080
- const body = e.props && isInt(e.props.bodyColumns) && e.props.bodyColumns > 0
1081
- ? e.props.bodyColumns
1082
- : LIMITS.waveBoardDefaultColumns
1347
+ const pane = e.props && isInt(e.props.bodyColumns) && e.props.bodyColumns > 0 ? e.props.bodyColumns : null
1348
+ const body = pane === null ? LIMITS.waveBoardDefaultColumns : Math.max(1, pane - INDENT)
1083
1349
  return body < LIMITS.waveBoardMinColumns ? body : Math.min(body, LIMITS.waveBoardDefaultColumns)
1084
1350
  }
1085
1351
 
@@ -1108,28 +1374,71 @@ function idSpans(Text, parts) {
1108
1374
  }
1109
1375
 
1110
1376
  /**
1111
- * A lane in its card: the fixed cells as one Text, the gist wrapping beneath,
1112
- * then the host's note, dim. The coloured words are spans nested in that one
1113
- * Text, because a row of separate elements shrinks each into its own column
1114
- * and breaks words apart once the line is wider than the card.
1377
+ * A lane's label, model, tier and effort cells, space-joined as the board
1378
+ * prints them, the label a span in the identity colour. The batch cell is the
1379
+ * sub-header the lane sits under, so the lane does not repeat it.
1380
+ */
1381
+ function headSpans(Text, parts) {
1382
+ const after = [parts.model, parts.tier, parts.effort].filter(Boolean).join(' ')
1383
+ if (!parts.label) return [after]
1384
+ return [h(Text, NAME_PROPS, parts.label), ...(after ? [` ${after}`] : [])]
1385
+ }
1386
+
1387
+ /** Whether a lane the board drew holds the cursor: its own state word, or a task's, is `current`. */
1388
+ const isCurrentLane = parts =>
1389
+ Boolean(parts) &&
1390
+ (parts.state === 'current' || (Array.isArray(parts.tasks) && parts.tasks.some(task => task && task.word === 'current')))
1391
+
1392
+ /**
1393
+ * A lane in its card, one step in beneath its batch's sub-header with a blank
1394
+ * gutter: the fixed cells as one Text, the gist wrapping beneath, then the
1395
+ * host's note, dim. The coloured words are spans nested in that one Text,
1396
+ * because a row of separate elements shrinks each into its own column and
1397
+ * breaks words apart once the line is wider than the card.
1115
1398
  */
1116
1399
  function drawLane($, e, row) {
1117
1400
  const { Box, Text } = $.ui.resolve(e)
1118
1401
  const key = boardKey(row.key)
1119
1402
  const parts = row.parts
1120
1403
  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
1404
  const line = [
1123
- ...(parts.state ? [`${head} `, h(Text, stateProps(parts.state), parts.state)] : [head]),
1405
+ ...headSpans(Text, parts),
1406
+ ...(parts.state ? [' ', h(Text, stateProps(parts.state), parts.state)] : []),
1124
1407
  ...idSpans(Text, parts),
1125
1408
  ...(Array.isArray(parts.after) && parts.after.length ? [` ←${parts.after.join(',')}`] : [])
1126
1409
  ]
1127
1410
  const kids = [
1128
1411
  h(Text, { wrap: 'wrap' }, ...line),
1129
1412
  parts.gist ? h(Box, { key: `${key}-gist` }, h(Text, { wrap: 'wrap' }, parts.gist)) : null,
1130
- parts.note ? h(Box, { key: `${key}-note` }, h(Text, { dimColor: true }, parts.note)) : null
1413
+ parts.note ? h(Box, { key: `${key}-note` }, h(Text, { dimColor: true, wrap: 'wrap' }, parts.note)) : null
1131
1414
  ].filter(Boolean)
1132
- return h(Box, { key, flexDirection: 'column' }, ...kids)
1415
+ return gutterRow(Box, Text, key, null, 1, ...kids)
1416
+ }
1417
+
1418
+ /**
1419
+ * A card's lanes, grouped by the batch the board printed for each: before each
1420
+ * group a gutter line with the time that batch was first dispatched and how
1421
+ * many lanes it runs side by side. It carries no state word: the cursor card's
1422
+ * border already marks where the run is.
1423
+ */
1424
+ function drawBatches($, e, index, lanes) {
1425
+ const { Box, Text } = $.ui.resolve(e)
1426
+ const groups = new Map()
1427
+ for (const row of lanes) {
1428
+ const batch = row.parts && typeof row.parts.batch === 'string' ? row.parts.batch : null
1429
+ if (!groups.has(batch)) groups.set(batch, [])
1430
+ groups.get(batch).push(row)
1431
+ }
1432
+ const out = []
1433
+ for (const [batch, rows] of groups) {
1434
+ const k = batch && /^b\d+$/.test(batch) ? batch.slice(1) : null
1435
+ if (k !== null) {
1436
+ const at = run.batchAt.has(`${index}:${k}`) ? run.batchAt.get(`${index}:${k}`) : null
1437
+ out.push(gutterRow(Box, Text, `wave-batch-${index}-${k}`, at, 0, h(Text, { wrap: 'wrap' }, `${batch} · ${laneCountText(rows.length)}`)))
1438
+ }
1439
+ out.push(...rows.map(row => drawLane($, e, row)))
1440
+ }
1441
+ return out
1133
1442
  }
1134
1443
 
1135
1444
  function drawBoardCards($, e, board, width) {
@@ -1138,8 +1447,12 @@ function drawBoardCards($, e, board, width) {
1138
1447
  const out = []
1139
1448
  let index = null
1140
1449
  let kids = null
1450
+ let lanes = null
1451
+ // Whether the card being filled holds a lane at the cursor: its border is then the current colour.
1452
+ let current = false
1141
1453
  const flush = () => {
1142
1454
  if (index === null) return
1455
+ kids.push(...drawBatches($, e, index, lanes))
1143
1456
  out.push(
1144
1457
  h(
1145
1458
  Box,
@@ -1147,7 +1460,7 @@ function drawBoardCards($, e, board, width) {
1147
1460
  key: `wave-card-${index}`,
1148
1461
  flexDirection: 'column',
1149
1462
  borderStyle: 'single',
1150
- borderDimColor: true,
1463
+ ...(current ? { borderColor: PALETTE.current } : { borderDimColor: true }),
1151
1464
  width,
1152
1465
  paddingLeft: 1,
1153
1466
  paddingRight: 1,
@@ -1158,6 +1471,8 @@ function drawBoardCards($, e, board, width) {
1158
1471
  )
1159
1472
  index = null
1160
1473
  kids = null
1474
+ lanes = null
1475
+ current = false
1161
1476
  }
1162
1477
  for (const row of board) {
1163
1478
  if (row.key.startsWith('wave:') && row.key.endsWith(':end')) continue
@@ -1165,12 +1480,14 @@ function drawBoardCards($, e, board, width) {
1165
1480
  flush()
1166
1481
  index = row.key.slice('wave:'.length)
1167
1482
  kids = [h(Box, { key: boardKey(row.key) }, h(Text, { bold: true }, waveTitleOf(row)))]
1483
+ lanes = []
1168
1484
  continue
1169
1485
  }
1170
1486
  if (row.key.startsWith('lane:')) {
1171
- const lane = drawLane($, e, row)
1172
- if (kids) kids.push(lane)
1173
- else out.push(lane)
1487
+ if (lanes) {
1488
+ lanes.push(row)
1489
+ if (isCurrentLane(row.parts)) current = true
1490
+ } else out.push(drawLane($, e, row))
1174
1491
  continue
1175
1492
  }
1176
1493
  flush()
@@ -1197,7 +1514,7 @@ function drawWaves($, e) {
1197
1514
  if (!run.plan) {
1198
1515
  if (!rows.length) return [h(Text, { dimColor: true }, NO_LANES_LINE)]
1199
1516
  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))]
1517
+ return [...(dispatched ? [keyed('wave-board-unrelayed', UNRELAYED_LINE)] : []), ...rows.map(r => flatRow($, e, r))]
1201
1518
  }
1202
1519
  const width = boardColumns(e)
1203
1520
  let board
@@ -1205,49 +1522,50 @@ function drawWaves($, e) {
1205
1522
  board = drawPlanBoardRows(run.plan, { columns: width, state: boardOverlay(run.plan), notes: laneNotes() })
1206
1523
  } catch (err) {
1207
1524
  $.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))]
1525
+ return [keyed('wave-board-failed', BOARD_FAILED_LINE), ...rows.map(r => flatRow($, e, r))]
1209
1526
  }
1210
1527
  // Every board ends in its tail; the renderer's spoken line for a narrow body
1211
1528
  // is one row without one, and the lanes are then drawn flat beneath it.
1212
1529
  const drawn = board.length > 0 && board[board.length - 1].key === 'tail'
1213
1530
  const planned = new Set(drawn ? board.filter(row => row.key.startsWith('lane:')).map(row => row.key.slice('lane:'.length)) : [])
1214
1531
  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))]
1532
+ return [...body, ...rows.filter(r => !planned.has(r.label)).map(r => flatRow($, e, r))]
1216
1533
  }
1217
1534
 
1218
1535
  async function drawPane($, e) {
1219
1536
  const { Box, Text } = $.ui.resolve(e)
1220
1537
  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))
1538
+ const heading = text => h(Box, { marginTop: 1 }, h(Text, HEADING_PROPS, text))
1539
+ // Each section's body sits one indent in beneath its heading.
1540
+ const body = (key, ...kids) => h(Box, { key, flexDirection: 'column', paddingLeft: INDENT }, ...kids)
1541
+ // The launch-guard line, in the alarm colour only while its ruling refuses the next launch.
1542
+ const guardLine = async () => {
1543
+ const guard = await launchGuardText($)
1544
+ return keyed('launch-guard', guard.text, guard.refused ? { color: PALETTE.alarm } : null)
1545
+ }
1222
1546
  if (run.phase === 'idle' && !run.summary) {
1223
1547
  // 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($)))
1548
+ return h(Box, { flexDirection: 'column' }, h(Text, { dimColor: true }, NO_RUN_LINE), await guardLine())
1225
1549
  }
1226
1550
 
1227
1551
  const out = []
1228
- out.push(h(Text, { bold: true }, `${run.change || 'ship run'}${run.runId ? ` · run ${run.runId}` : ''}`))
1552
+ out.push(h(Text, HEADING_PROPS, `${run.change || 'ship run'}${run.runId ? ` · run ${run.runId}` : ''}`))
1229
1553
  out.push(keyed('action', `action: ${run.action || 'starting'}${run.phase === 'closed' ? ' (closed)' : ''}`))
1230
- out.push(keyed('last-activity', await lastActivityText($)))
1554
+ const activity = await lastActivityText($)
1555
+ out.push(
1556
+ h(
1557
+ Box,
1558
+ { key: 'last-activity' },
1559
+ h(Text, null, activity.line, ...(activity.word ? [' · ', h(Text, { color: PALETTE.warn }, activity.word)] : []))
1560
+ )
1561
+ )
1231
1562
  out.push(h(Text, { dimColor: true }, RECORD_LINE))
1232
1563
 
1233
1564
  out.push(heading('waves'))
1234
- out.push(...drawWaves($, e))
1235
-
1236
- out.push(heading('agents'))
1237
- if (!run.agents.size) out.push(h(Text, { dimColor: true }, 'no agent has reported a request yet'))
1238
- for (const a of run.agents.values()) {
1239
- const lane = laneForAgent(a)
1240
- const n = a.requests === 1 ? '1 request' : `${a.requests} requests`
1241
- const done = a.reason ? `${a.reason}${a.durationMs !== null ? ` ${(a.durationMs / 1000).toFixed(1)}s` : ''}` : 'running'
1242
- out.push(
1243
- h(
1244
- Box,
1245
- { 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}`)),
1247
- h(Box, null, h(Text, { dimColor: true }, `${n} · ${tokensText(a)}`))
1248
- )
1249
- )
1250
- }
1565
+ out.push(body('waves-body', ...drawWaves($, e)))
1566
+
1567
+ out.push(heading('timeline'))
1568
+ out.push(body('timeline-body', ...drawTimeline($, e)))
1251
1569
 
1252
1570
  // One plain read feeds the session lines and the plan windows; a breakdown
1253
1571
  // is never asked for, because only the plain call is free (design D4).
@@ -1261,36 +1579,35 @@ async function drawPane($, e) {
1261
1579
  }
1262
1580
  const [contextLine, costLine] = sessionLines(usage, failure)
1263
1581
  out.push(heading('session'))
1264
- out.push(keyed('session-context', contextLine))
1265
- out.push(keyed('session-cost', costLine))
1582
+ out.push(body('session-body', keyed('session-context', contextLine), keyed('session-cost', costLine)))
1266
1583
 
1267
1584
  out.push(heading('plan windows'))
1268
1585
  const windows = usage && Array.isArray(usage.rateLimits) ? usage.rateLimits : []
1269
- if (failure !== null) out.push(h(Text, { dimColor: true }, `plan windows unavailable: ${failure}`))
1270
- else if (!windows.length) out.push(h(Text, { dimColor: true }, 'none reported (off a subscription the session has none)'))
1586
+ const planLines = []
1587
+ if (failure !== null) planLines.push(h(Text, { dimColor: true }, `plan windows unavailable: ${failure}`))
1588
+ else if (!windows.length) planLines.push(h(Text, { dimColor: true }, 'none reported (off a subscription the session has none)'))
1271
1589
  for (const w of windows) {
1272
1590
  if (!w || typeof w !== 'object') continue
1273
1591
  const kind = String(w.kind)
1274
- out.push(keyed(`plan-window-${kind}`, `${kind} ${w.percentUsed}% used${w.resetsAt ? ` · resets ${w.resetsAt}` : ''}`))
1592
+ planLines.push(keyed(`plan-window-${kind}`, `${kind} ${w.percentUsed}% used${w.resetsAt ? ` · resets ${w.resetsAt}` : ''}`))
1275
1593
  }
1594
+ out.push(body('plan-windows-body', ...planLines))
1276
1595
 
1277
1596
  out.push(heading('banners'))
1278
1597
  out.push(
1279
- h(
1280
- Box,
1281
- { key: 'banners', flexDirection: 'column' },
1282
- ...(run.banners.length ? run.banners.map(b => h(Text, null, b)) : [h(Text, { dimColor: true }, 'none so far')])
1598
+ body(
1599
+ 'banners',
1600
+ ...(run.banners.length ? run.banners.map(b => h(Text, { color: PALETTE.warn }, b)) : [h(Text, { dimColor: true }, 'none so far')])
1283
1601
  )
1284
1602
  )
1285
1603
 
1286
1604
  out.push(heading('refusals'))
1287
1605
  const denials = guardDenialLine(run.guardDenials)
1288
- if (denials) out.push(keyed('guard-denials', denials))
1289
- out.push(keyed('launch-guard', await launchGuardText($)))
1606
+ out.push(body('refusals-body', ...(denials ? [keyed('guard-denials', denials, { color: PALETTE.alarm })] : []), await guardLine()))
1290
1607
 
1291
1608
  if (run.summary) {
1292
1609
  out.push(heading('close summary'))
1293
- out.push(h(Box, { key: 'summary', flexDirection: 'column' }, h(Text, null, run.summary)))
1610
+ out.push(body('summary', h(Text, null, run.summary)))
1294
1611
  }
1295
1612
  return h(Box, { flexDirection: 'column' }, ...out)
1296
1613
  }
@@ -1305,7 +1622,7 @@ export function register(on) {
1305
1622
  try {
1306
1623
  await $.command.register({
1307
1624
  name: PANE,
1308
- description: 'Open the Interlock ship meter: the live run, its waves, agents, plan windows and banners',
1625
+ description: 'Open the Interlock ship meter: the live run, its waves, step timeline, plan windows and banners',
1309
1626
  immediate: true
1310
1627
  })
1311
1628
  } catch (err) {
@@ -1422,6 +1739,7 @@ export function register(on) {
1422
1739
  run.workflowName = str(r.result.workflowName)
1423
1740
  run.transcriptDir = str(r.result.transcriptDir)
1424
1741
  run.lastActivityAt = now
1742
+ run.launchedAt = now
1425
1743
  $.ui.invalidate('ui.render')
1426
1744
  const placed = await openPane($)
1427
1745
  if (!placed || placed.isPlaced !== true) $.ui.toast(UNPLACED_TOAST)
@@ -1444,6 +1762,12 @@ export function register(on) {
1444
1762
  return r
1445
1763
  }
1446
1764
  const held = run
1765
+ // A run agent's command names it: a
1766
+ // briefing it reads joins it to its lane, and a driver line names a relay.
1767
+ if (run.phase === 'live' && str(e.agentId)) {
1768
+ noteBriefing($, e.agentId, command)
1769
+ if (driver) noteRelay($, e.agentId, command)
1770
+ }
1447
1771
  const r = await next(e)
1448
1772
  // Any Bash call during the run is read for a guard's refusal (guard-commit); only a driver line for a step.
1449
1773
  if (held === run) speakRefusal($, r)
@@ -1451,7 +1775,7 @@ export function register(on) {
1451
1775
  try {
1452
1776
  const { record, problem } = readRecord(r.text)
1453
1777
  if (!record) $.ui.log(`interlock meter: ${command.slice(0, 80)}: not a step record (${problem})`, { to: 'debug' })
1454
- else if (held === run) await applyRecord($, record)
1778
+ else if (held === run) await applyRecord($, record, str(e.agentId))
1455
1779
  else if (run.phase === 'idle' && !run.idleStepNamed) nameIdleStep($)
1456
1780
  } catch (err) {
1457
1781
  $.ui.log(`interlock meter: step read failed: ${(err && err.message) || err}`, { to: 'debug' })
@@ -1470,6 +1794,14 @@ export function register(on) {
1470
1794
  return r
1471
1795
  })
1472
1796
 
1797
+ // A run agent's Read, observed for the briefing it names and passed on as it
1798
+ // came: the Workflow host raises no spawn event, so a briefing read is how an
1799
+ // agent is joined to its lane.
1800
+ on('tool.call', { tool: 'Read' }, async ($, e, next) => {
1801
+ if (session.interactive && run.phase === 'live' && e.agentId) noteBriefing($, e.agentId, e.file_path)
1802
+ return next(e)
1803
+ })
1804
+
1473
1805
  // The tools the manifest's `Edit|Write` matcher names for guard-tests and guard-tasks (design D4).
1474
1806
  on('tool.call', { tool: 'Edit' }, editCall)
1475
1807
  on('tool.call', { tool: 'Write' }, editCall)
@@ -1477,7 +1809,9 @@ export function register(on) {
1477
1809
  on('turn.step', async function* ($, e, next) {
1478
1810
  if (!session.interactive || run.phase !== 'live' || !e.agentId) return yield* next(e)
1479
1811
  // A run agent's request starting, and its answer arriving, are each activity (design D1).
1812
+ const held = run
1480
1813
  if (await stamp($)) redrawWord($)
1814
+ if (held === run) firstRequest(agentOf(e.agentId))
1481
1815
  const result = yield* next(e)
1482
1816
  if (await stamp($)) redrawWord($)
1483
1817
  try {
@@ -1499,14 +1833,15 @@ export function register(on) {
1499
1833
  // (speak-lane-turn-ends-and-session-cost design D1-D3).
1500
1834
  if (a.reason && a.reason !== 'answer' && !a.endToasted) {
1501
1835
  a.endToasted = true
1502
- const row = rowForAgent(a)
1503
1836
  try {
1504
- $.ui.toast(`${row ? row.title : 'lane unknown'} · agent ${a.id} · turn ended: ${a.reason}`)
1837
+ $.ui.toast(`${agentName(a) || UNMATCHED} · agent ${a.id} · turn ended: ${a.reason}`)
1505
1838
  } catch (err) {
1506
1839
  $.ui.log(`interlock meter: the turn-end toast for ${a.id} was refused: ${messageOf(err)}`, { to: 'debug' })
1507
1840
  }
1508
1841
  }
1842
+ const held = run
1509
1843
  if (await stamp($)) redrawWord($)
1844
+ if (held === run) a.endAt = run.stampedAt
1510
1845
  $.ui.invalidate('ui.render')
1511
1846
  }
1512
1847
  return next(e)
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
+ }
@@ -0,0 +1,43 @@
1
+ // 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
+
14
+ const isTime = ms => typeof ms === 'number' && Number.isFinite(ms)
15
+ const pad = n => String(n).padStart(2, '0')
16
+
17
+ /** The local wall-clock time of a reading, `HH:MM:SS`; `''` for no reading. */
18
+ export 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. */
25
+ export 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
+ */
37
+ export 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
+ }
@@ -0,0 +1,161 @@
1
+ // 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. */
30
+ export const BATCH_ACTIONS = new Set(['run-batch', 'test-wave'])
31
+
32
+ const isInt = v => Number.isInteger(v)
33
+ const isTime = v => typeof v === 'number' && Number.isFinite(v)
34
+ const str = v => (typeof v === 'string' && v ? v : null)
35
+ const 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. */
38
+ export 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. */
43
+ export 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. */
49
+ export 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. */
55
+ export 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. */
72
+ function 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
+ */
94
+ export 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
+ }
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.1",
4
4
  "description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec",
5
5
  "type": "module",
6
6
  "engines": {