@renzrollon/interlock 1.4.0 → 1.4.2

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.4.0",
4
+ "version": "1.4.2",
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,34 @@ 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.2 — 2026-10-10
9
+
10
+ ### Fixed
11
+
12
+ - **A plugin install no longer opens every session with a preflight warning.** The `binaries` check warned whenever `interlock` and `interlock-graph` were not on `PATH`, even when both were in the plugin's `bin/`. The SessionStart hook runs the doctor without that directory on `PATH`, so every plugin user saw `interlock preflight OK (1 warning).` and a `warn binaries` band above the prompt at every session start, for a state nobody had to change. A binary in the plugin's `bin/` is now `ok`, so a clean install draws no band. A binary found neither on `PATH` nor in `bin/` still fails.
13
+
14
+ ## 1.4.1 — 2026-10-09
15
+
16
+ ### Changed
17
+
18
+ - **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.
19
+ - **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.
20
+ - **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`.
21
+ - **Every timed line has a local `HH:MM:SS` gutter**, blank where the clock gave no reading, never a guessed time.
22
+ - **The header** reads `started <date> <time> · last activity <time>`, replacing `last activity <ISO>`.
23
+ - **Durations** read `41.2s`, `2m 14s` or `1h 03m`, replacing `134.2s`.
24
+ - **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.
25
+ - **Each section's body is indented** under its heading.
26
+ - **Relay token figures:** a relay's token breakdown is no longer drawn. The trajectory's `agent-result` line still carries it.
27
+ - **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`.
28
+ - **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.
29
+ - **Unchanged:** `interlock waves --format board` prints exactly what it printed before.
30
+
31
+ ### Fixed
32
+
33
+ - **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.
34
+ - **`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`.
35
+
8
36
  ## 1.4.0 — 2026-10-09
9
37
 
10
38
  ### Changed
package/hooks/mod.mjs CHANGED
@@ -75,9 +75,23 @@
75
75
  // own briefing, and that Read or Bash call names the spawn's briefing path:
76
76
  // the module joins the agent to the spawn's row by it, through the same
77
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.
78
+ // other agent reads `unmatched agent`, said once beneath the rows. A ping wraps
79
+ // the relay: another command first, a heredoc, then `interlock … --json`, then
80
+ // an echoed exit status. The driver is the `interlock` that begins a command,
81
+ // and the step is the JSON object in that result, so the wrap still registers
82
+ // its lanes. Every colour is a theme key from `lib/meter-palette.mjs` and
83
+ // names a meaning, never a threshold: the engine's own figures carry none.
84
+ //
85
+ // THE STEP TIMELINE. The agents list's place is a timeline: one line per step
86
+ // as it crossed, the agent whose Bash call printed it folded into that line,
87
+ // the agents a step spawned one indent beneath it in the order they started,
88
+ // relays no step names at their own time, and the agents nothing names last.
89
+ // Every timed line carries the local `HH:MM:SS` of a reading the module took
90
+ // itself (the launch's, a step's stamp, an agent's first request), blank
91
+ // where the clock gave none; durations are humanised. The order and the
92
+ // step's words are `lib/meter-timeline.mjs`'s, the time words
93
+ // `lib/meter-time.mjs`'s. Within a wave card the lanes sit beneath their
94
+ // batch's sub-header, timed by when that batch was first dispatched.
81
95
  //
82
96
  // THE SESSION START (show-preflight-and-interrupted-runs-at-session-start
83
97
  // design D5-D8). Outside a run the module draws two things, both from the
@@ -113,6 +127,8 @@ import { decideLaunch, emptyRecord, isAcceptedLaunch, isShipLaunch, withLaunch,
113
127
  import { LIMITS } from '../lib/limits.mjs'
114
128
  import { PALETTE, stateProps, turnProps } from '../lib/meter-palette.mjs'
115
129
  import { TICK_MS, quietMs, quietWord } from '../lib/meter-quiet.mjs'
130
+ import { clockText, durationText, stampText } from '../lib/meter-time.mjs'
131
+ import { BATCH_ACTIONS, batchText, laneCountText, planWaveCount, stepText, timeline } from '../lib/meter-timeline.mjs'
116
132
  import { guardDenialLine, guardOf, guardReason, launchGuardLine } from '../lib/meter-refusals.mjs'
117
133
  import {
118
134
  adoptPreflight,
@@ -155,8 +171,6 @@ const UNPLACED_TOAST = '/interlock-meter opens the ship meter'
155
171
  const NO_LANES_LINE = 'no lanes dispatched yet'
156
172
  const UNRELAYED_LINE = 'plan structure not relayed by this CLI'
157
173
  const BOARD_FAILED_LINE = 'wave board could not be drawn'
158
- // The actions that dispatch a batch of lanes, and so place the board's cursor on one.
159
- const BATCH_ACTIONS = new Set(['run-batch', 'test-wave'])
160
174
  // The session start's two panes, each opened by the command of its name (design D6).
161
175
  const PREFLIGHT_PANE = 'interlock-preflight'
162
176
  const HANDOFF_PANE = 'interlock-handoff'
@@ -173,8 +187,8 @@ const LEDGER = { plugin: 'interlock', key: 'ledger' }
173
187
  // a missing origin count nothing.
174
188
  const HUMAN_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
175
189
  const GUARD = 'interlock guard'
176
- // The line `cli()` in workflows/ship.js hands the ping (design D2).
177
- const DRIVER_LINE = /^interlock\s/
190
+ // The line `cli()` in workflows/ship.js hands the ping (design D2). A ping
191
+ // wraps it, so the driver is found by `driverInvocation`, not a line start.
178
192
  // The bootstrap line a lane's spawn prompt carries (as `lib/agent-usage.mjs` reads it).
179
193
  const BRIEFING_SHA = /Expected sha256: ([0-9a-f]{64})/g
180
194
  // What an agent row and a turn-end toast read for an agent no row and no driver line names.
@@ -186,6 +200,15 @@ const PATH_BEFORE = /[\s\/'"=]/
186
200
  const PATH_AFTER = /[\w.+-]/
187
201
  /** The props a name is drawn with: a lane label, a spawn's title, a joined agent's name. */
188
202
  const NAME_PROPS = { bold: true, color: PALETTE.identity }
203
+ const DIM = { dimColor: true }
204
+ // The timeline's gutter: `HH:MM:SS` and two spaces, and how far a section body
205
+ // and a child line sit in from what holds them (layout probe P3, Claude Code 2.1.295).
206
+ const GUTTER_COLUMNS = 10
207
+ const INDENT = 2
208
+ // One column, so a frame never moves the line. Stepped while a run is in
209
+ // flight, on the interval that already recomputes the quiet word.
210
+ const PULSE_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
211
+ const NO_STEP_LINE = 'no step has crossed yet'
189
212
  /** The props the run header and each section heading are drawn with. */
190
213
  const HEADING_PROPS = { bold: true, color: PALETTE.accent }
191
214
  const TOKEN_FIELDS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']
@@ -230,6 +253,13 @@ function freshRun() {
230
253
  // activity (null until the clock answers), the word it makes, and the one
231
254
  // interval that recomputes it, known by its handle and its generation.
232
255
  lastActivityAt: null,
256
+ // The reading the last stamp took (null when the clock failed), and the
257
+ // launch's own: the timeline's times, never guessed.
258
+ stampedAt: null,
259
+ launchedAt: null,
260
+ // Every step applied, in crossing order, and the time each batch was first dispatched, by `<waveIndex>:<batchIndex>`.
261
+ steps: [],
262
+ batchAt: new Map(),
233
263
  quietWord: null,
234
264
  tick: null,
235
265
  generation: 0,
@@ -251,7 +281,11 @@ function freshRun() {
251
281
  dispatched: null,
252
282
  halt: null,
253
283
  planNamed: false,
254
- recordedNamed: false
284
+ recordedNamed: false,
285
+ // The in-flight mark, stepped by the one quiet interval, and whether this
286
+ // run has asked the pane to follow its latest line.
287
+ pulse: 0,
288
+ followed: false
255
289
  }
256
290
  }
257
291
 
@@ -308,7 +342,8 @@ function position() {
308
342
  if (run.change) parts.push(run.change)
309
343
  if (run.action) parts.push(run.action)
310
344
  if (run.wave !== undefined && run.wave !== null) parts.push(`wave ${run.wave}`)
311
- if (isInt(run.batchIndex) && isInt(run.batchCount)) parts.push(`batch ${run.batchIndex + 1}/${run.batchCount}`)
345
+ const batch = batchText(run.batchIndex, run.batchCount)
346
+ if (batch) parts.push(batch)
312
347
  return parts.join(' · ')
313
348
  }
314
349
 
@@ -376,16 +411,17 @@ async function stamp($) {
376
411
  const held = run
377
412
  const now = await clockNow($)
378
413
  if (held !== run) return false
414
+ run.stampedAt = now
379
415
  if (now !== null) run.lastActivityAt = run.lastActivityAt === null ? now : Math.max(run.lastActivityAt, now)
380
416
  return setWord(null)
381
417
  }
382
418
 
383
419
  /**
384
420
  * Starts the run's one interval (design D2), the host's own timer: each period
385
- * reads the clock and recomputes the word, and draws only when the word
386
- * changed. A period that finds another run or another interval in its place
387
- * returns at once. A host that will not start one leaves the pane, which
388
- * recomputes the word whenever it is drawn.
421
+ * reads the clock and recomputes the word, draws when the word changed, and
422
+ * steps the in-flight mark. A period that finds another run or another
423
+ * interval in its place returns at once. A host that will not start one leaves
424
+ * the pane, which recomputes the word whenever it is drawn.
389
425
  */
390
426
  function startTick($) {
391
427
  const generation = ++generations
@@ -396,7 +432,14 @@ function startTick($) {
396
432
  if (!isMine(tick)) return
397
433
  try {
398
434
  const now = await clockNow($)
399
- if (isMine(tick) && setWord(quietWord(quietMs(run.lastActivityAt, now)))) redrawWord($)
435
+ if (!isMine(tick)) return
436
+ const changed = setWord(quietWord(quietMs(run.lastActivityAt, now)))
437
+ const pulsed = inFlight()
438
+ if (pulsed) run.pulse++
439
+ if (!isMine(tick)) return
440
+ if (changed) redrawWord($)
441
+ else if (pulsed) $.ui.invalidate('ui.render')
442
+ await followBottom($)
400
443
  } catch (err) {
401
444
  $.ui.log(`interlock meter: a quiet-figure period failed: ${messageOf(err)}`, { to: 'debug' })
402
445
  }
@@ -409,6 +452,28 @@ function startTick($) {
409
452
  }
410
453
  }
411
454
 
455
+ /**
456
+ * Asks the pane to follow its latest line, once, so a run that grows keeps the
457
+ * tail in view until the person scrolls. A host that will not scroll yet is
458
+ * asked again on the next step or tick. Never throws.
459
+ */
460
+ async function followBottom($) {
461
+ if (!session.interactive || run.phase !== 'live' || run.followed) return
462
+ const held = run
463
+ try {
464
+ const moved = await $.ui.scroll({ to: 'end', in: PANE })
465
+ if (held === run && scrollAccepted(moved)) held.followed = true
466
+ } catch {
467
+ // The pane may not be placed yet; the next step or tick asks again.
468
+ }
469
+ }
470
+
471
+ /** A scroll landed when the host answered an object and did not refuse it. */
472
+ function scrollAccepted(moved) {
473
+ if (!moved || typeof moved !== 'object') return false
474
+ return !Object.prototype.hasOwnProperty.call(moved, 'den' + 'y')
475
+ }
476
+
412
477
  /** Ends the run's interval, if it has one: a cancelled timer never fires again. */
413
478
  function stopTick() {
414
479
  if (run.tick) run.tick.cancel()
@@ -671,6 +736,126 @@ function noteBriefing($, agentId, text) {
671
736
  }
672
737
  }
673
738
 
739
+ /** Whether `i` is the end of a command token. */
740
+ function tokenBoundary(command, i) {
741
+ if (i >= command.length) return true
742
+ const c = command[i]
743
+ return c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === ';' || c === '&' || c === '|' || c === '<' || c === '>'
744
+ }
745
+
746
+ /**
747
+ * The index just after a heredoc that opens at `at` (`<<` or `<<-`), or `at`
748
+ * when that is not a heredoc. An unclosed body consumes the rest of the line,
749
+ * the way the shell does, so a word inside it is not a command.
750
+ */
751
+ function skipHeredoc(command, at) {
752
+ let i = at + 2
753
+ let strip = false
754
+ if (command[i] === '-') {
755
+ strip = true
756
+ i++
757
+ }
758
+ while (command[i] === ' ' || command[i] === '\t') i++
759
+ let quote = null
760
+ if (command[i] === "'" || command[i] === '"') {
761
+ quote = command[i]
762
+ i++
763
+ }
764
+ const start = i
765
+ while (i < command.length && /[A-Za-z0-9_-]/.test(command[i])) i++
766
+ const word = command.slice(start, i)
767
+ if (!word) return at
768
+ if (quote && command[i] === quote) i++
769
+ const nl = command.indexOf('\n', i)
770
+ if (nl < 0) return command.length
771
+ let pos = nl + 1
772
+ while (pos <= command.length) {
773
+ const next = command.indexOf('\n', pos)
774
+ const end = next < 0 ? command.length : next
775
+ let line = command.slice(pos, end)
776
+ if (line.endsWith('\r')) line = line.slice(0, -1)
777
+ if (strip) line = line.replace(/^\t+/, '')
778
+ if (line === word) return next < 0 ? command.length : next + 1
779
+ if (next < 0) return command.length
780
+ pos = next + 1
781
+ }
782
+ return command.length
783
+ }
784
+
785
+ /** `command` from `start` through the invocation, stopping at the next unquoted separator. */
786
+ function sliceInvocation(command, start) {
787
+ let quote = null
788
+ let i = start
789
+ while (i < command.length) {
790
+ const c = command[i]
791
+ if (quote) {
792
+ if (c === '\\' && quote === '"') {
793
+ i += 2
794
+ continue
795
+ }
796
+ if (c === quote) quote = null
797
+ i++
798
+ continue
799
+ }
800
+ if (c === "'" || c === '"') {
801
+ quote = c
802
+ i++
803
+ continue
804
+ }
805
+ if (c === '\n' || c === ';' || c === '|' || c === '&') break
806
+ i++
807
+ }
808
+ return command.slice(start, i).trim()
809
+ }
810
+
811
+ /**
812
+ * The `interlock` invocation a shell line actually runs, or null.
813
+ *
814
+ * A ping writes a file, then runs `interlock … --json`, then echoes an exit
815
+ * status. The invocation is the `interlock` token that begins a command (the
816
+ * line, or after `;`, `&`, `|` or a newline), not one quoted or sitting inside
817
+ * a heredoc. The slice stops at the next separator, so the relay name is the
818
+ * subcommand and not the rest of the script.
819
+ */
820
+ function driverInvocation(command) {
821
+ if (typeof command !== 'string' || !command) return null
822
+ let quote = null
823
+ let ready = true
824
+ for (let i = 0; i < command.length; i++) {
825
+ const c = command[i]
826
+ if (quote) {
827
+ if (c === '\\' && quote === '"') {
828
+ i++
829
+ continue
830
+ }
831
+ if (c === quote) quote = null
832
+ continue
833
+ }
834
+ if (c === "'" || c === '"') {
835
+ quote = c
836
+ continue
837
+ }
838
+ if (c === '<' && command[i + 1] === '<') {
839
+ const skipped = skipHeredoc(command, i)
840
+ if (skipped > i) {
841
+ i = skipped - 1
842
+ ready = true
843
+ continue
844
+ }
845
+ }
846
+ if (c === ' ' || c === '\t' || c === '\r') continue
847
+ if (c === '\n' || c === ';' || c === '|' || c === '&') {
848
+ ready = true
849
+ continue
850
+ }
851
+ if (ready && command.startsWith('interlock', i) && tokenBoundary(command, i + 'interlock'.length)) {
852
+ return sliceInvocation(command, i)
853
+ }
854
+ ready = false
855
+ }
856
+ return null
857
+ }
858
+
674
859
  /**
675
860
  * `run next` for `interlock run next --results … --json`: the subcommand a
676
861
  * driver line relays, as `workflows/ship.js` names its relay (`argv.slice(0, 2)`),
@@ -783,18 +968,75 @@ function noteIdleStep($, r) {
783
968
  }
784
969
  }
785
970
 
971
+ /** A step record: an object with a string `action`, as the driver guards it. */
972
+ function isStep(value) {
973
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && typeof value.action === 'string'
974
+ }
975
+
976
+ /** The index just after the JSON object that opens at `start`, or -1. */
977
+ function jsonEnd(text, start) {
978
+ let depth = 0
979
+ let quote = null
980
+ for (let i = start; i < text.length; i++) {
981
+ const c = text[i]
982
+ if (quote) {
983
+ if (c === '\\') {
984
+ i++
985
+ continue
986
+ }
987
+ if (c === quote) quote = null
988
+ continue
989
+ }
990
+ if (c === '"' || c === "'") {
991
+ quote = c
992
+ continue
993
+ }
994
+ if (c === '{') depth++
995
+ else if (c === '}') {
996
+ depth--
997
+ if (depth === 0) return i + 1
998
+ }
999
+ }
1000
+ return -1
1001
+ }
1002
+
1003
+ /**
1004
+ * The step object in a result that is not itself JSON: a ping prints a line,
1005
+ * then the step, then an exit status. A stamped step wins; otherwise the
1006
+ * first object with an action.
1007
+ */
1008
+ function findStep(text) {
1009
+ let fallback = null
1010
+ for (let i = 0; i < text.length; i++) {
1011
+ if (text[i] !== '{') continue
1012
+ const end = jsonEnd(text, i)
1013
+ if (end < 0) continue
1014
+ let value
1015
+ try {
1016
+ value = JSON.parse(text.slice(i, end))
1017
+ } catch {
1018
+ continue
1019
+ }
1020
+ if (!isStep(value)) continue
1021
+ if (value.schema === 'interlock.run-step/1') return value
1022
+ if (!fallback) fallback = value
1023
+ }
1024
+ return fallback
1025
+ }
1026
+
786
1027
  /** The step record in a Bash result's text, guarded as the driver guards it: an object with a string `action`. */
787
1028
  function readRecord(text) {
788
1029
  if (typeof text !== 'string' || !text.trim()) return { problem: 'no result text' }
789
- let value
1030
+ const trimmed = text.trim()
790
1031
  try {
791
- value = JSON.parse(text)
1032
+ const value = JSON.parse(trimmed)
1033
+ if (isStep(value)) return { record: value }
1034
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return { problem: 'not an object' }
1035
+ return { problem: 'no action' }
792
1036
  } catch {
793
- return { problem: 'not JSON' }
1037
+ const found = findStep(trimmed)
1038
+ return found ? { record: found } : { problem: 'not JSON' }
794
1039
  }
795
- if (!value || typeof value !== 'object' || Array.isArray(value)) return { problem: 'not an object' }
796
- if (typeof value.action !== 'string') return { problem: 'no action' }
797
- return { record: value }
798
1040
  }
799
1041
 
800
1042
  function agentOf(id) {
@@ -813,13 +1055,24 @@ function agentOf(id) {
813
1055
  // The two words after `interlock` in the first driver line it ran while no row named it.
814
1056
  relay: null,
815
1057
  // Whether its turn end has been spoken: once per agent per run (speak-lane-turn-ends design D1).
816
- endToasted: false
1058
+ endToasted: false,
1059
+ // The reading at its first request, taken once (null when the clock failed then), and at its turn end.
1060
+ stepped: false,
1061
+ firstAt: null,
1062
+ endAt: null
817
1063
  }
818
1064
  run.agents.set(id, a)
819
1065
  }
820
1066
  return a
821
1067
  }
822
1068
 
1069
+ /** The agent's first request, timed once by the stamp just taken: a later reading never moves it. */
1070
+ function firstRequest(a) {
1071
+ if (a.stepped) return
1072
+ a.stepped = true
1073
+ a.firstAt = run.stampedAt
1074
+ }
1075
+
823
1076
  /** Field by field: a field one request omitted leaves that figure unknown (`null`), never a guessed zero (design D5). */
824
1077
  function addUsage(a, usage) {
825
1078
  a.requests++
@@ -905,7 +1158,34 @@ function takeBoard($, record) {
905
1158
  if (record.action === 'halt') run.halt = { reason: str(record.reason) || (run.halt ? run.halt.reason : null) }
906
1159
  }
907
1160
 
908
- async function applyRecord($, record) {
1161
+ /**
1162
+ * One step as the timeline draws it, its fields the record's own: the time the
1163
+ * stamp read, its position, a skip or halt reason, the size of a plan summary
1164
+ * it carried, the agent whose Bash call printed it, and the spawns it named.
1165
+ */
1166
+ function noteStep(record, relayId, spawns) {
1167
+ const step = {
1168
+ n: run.steps.length,
1169
+ at: run.stampedAt,
1170
+ action: record.action,
1171
+ wave: record.wave,
1172
+ waveIndex: record.waveIndex,
1173
+ batchIndex: record.batchIndex,
1174
+ batchCount: record.batchCount,
1175
+ skipped: record.skipped,
1176
+ reason: record.reason,
1177
+ planWaves: isPlanSummary(record.plan) ? planWaveCount(record.plan) : null,
1178
+ relayId: str(relayId),
1179
+ spawns
1180
+ }
1181
+ run.steps.push(step)
1182
+ if (BATCH_ACTIONS.has(record.action) && isInt(record.waveIndex) && isInt(record.batchIndex)) {
1183
+ const at = `${record.waveIndex}:${record.batchIndex}`
1184
+ if (!run.batchAt.has(at)) run.batchAt.set(at, step.at)
1185
+ }
1186
+ }
1187
+
1188
+ async function applyRecord($, record, relayId) {
909
1189
  const held = run
910
1190
  await stamp($)
911
1191
  if (held !== run) return
@@ -915,8 +1195,10 @@ async function applyRecord($, record) {
915
1195
  if (record.wave !== undefined) run.wave = record.wave
916
1196
  run.batchIndex = record.batchIndex
917
1197
  run.batchCount = record.batchCount
1198
+ const spawns = []
918
1199
  for (const s of Array.isArray(record.spawns) ? record.spawns : []) {
919
1200
  if (!s || typeof s !== 'object' || !str(s.label)) continue
1201
+ spawns.push({ label: s.label, title: str(s.title) || s.label, sha: str(s.promptSha256) })
920
1202
  run.rows.set(s.label, {
921
1203
  label: s.label,
922
1204
  title: str(s.title) || s.label,
@@ -930,6 +1212,7 @@ async function applyRecord($, record) {
930
1212
  batch: BATCH_ACTIONS.has(record.action)
931
1213
  })
932
1214
  }
1215
+ noteStep(record, relayId, spawns)
933
1216
  takeBoard($, record)
934
1217
  for (const banner of Array.isArray(record.banners) ? record.banners : []) {
935
1218
  if (typeof banner !== 'string' || !banner) continue
@@ -950,6 +1233,7 @@ async function applyRecord($, record) {
950
1233
  }
951
1234
  setStatus($)
952
1235
  $.ui.invalidate('ui.render')
1236
+ await followBottom($)
953
1237
  }
954
1238
 
955
1239
  async function openPane($, id = PANE, title = TITLE) {
@@ -1106,53 +1390,169 @@ function sessionLines(usage, failure) {
1106
1390
  }
1107
1391
 
1108
1392
  /**
1109
- * `last activity <ISO>` or `last activity unknown`, then the quiet word while a
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).
1393
+ * `started <local date and time> · last activity <local time>` (the start left
1394
+ * out when the launch read no time, `last activity unknown` when no stamp did),
1395
+ * then the quiet word while a live run is quiet, as the line and the word
1396
+ * apart. The word is recomputed from the clock at each draw, so a pane opened
1397
+ * after the host stopped the interval still shows it (design D2, D5).
1113
1398
  */
1114
1399
  async function lastActivityText($) {
1115
- if (run.lastActivityAt === null) return { line: 'last activity unknown', word: null }
1116
- const at = new Date(run.lastActivityAt).toISOString()
1400
+ const started = run.launchedAt === null ? '' : `started ${stampText(run.launchedAt)} · `
1401
+ if (run.lastActivityAt === null) return { line: `${started}last activity unknown`, word: null }
1117
1402
  const word = run.phase === 'live' ? quietWord(quietMs(run.lastActivityAt, await clockNow($))) : null
1118
- return { line: `last activity ${at}`, word }
1403
+ return { line: `${started}last activity ${clockText(run.lastActivityAt)}`, word }
1404
+ }
1405
+
1406
+ /** An agent id as a line shows it: its first eight characters. */
1407
+ const id8 = id => String(id).slice(0, 8)
1408
+
1409
+ /** `1 request`, `N requests`. */
1410
+ const requestsText = a => (a.requests === 1 ? '1 request' : `${a.requests} requests`)
1411
+
1412
+ /** An agent's turn: `running`, else the host's word and, once reported, how long the turn took. */
1413
+ const turnText = a =>
1414
+ a.reason ? `${a.reason}${a.durationMs !== null ? ` ${durationText(a.durationMs)}` : ''}` : 'running'
1415
+
1416
+ /** Whether the host ended the agent's turn without an answer. */
1417
+ const abnormal = a => Boolean(a.reason) && a.reason !== 'answer'
1418
+
1419
+ /** A live run still has work out: no step yet, an agent still going, or a lane not yet answered. */
1420
+ function inFlight() {
1421
+ if (run.phase !== 'live') return false
1422
+ if (!run.action) return true
1423
+ for (const a of run.agents.values()) if (!a.reason) return true
1424
+ for (const r of run.rows.values()) {
1425
+ const a = r.sha ? agentForSha(r.sha) : null
1426
+ if (!a || !a.reason) return true
1427
+ }
1428
+ return false
1429
+ }
1430
+
1431
+ /** The current in-flight frame, or `''` before the first interval and once the work has landed. */
1432
+ function frame() {
1433
+ if (run.pulse === 0 || !inFlight()) return ''
1434
+ return PULSE_FRAMES[(run.pulse - 1) % PULSE_FRAMES.length]
1435
+ }
1436
+
1437
+ /** A leading space and the frame, or `''` when the mark is not showing. */
1438
+ function markSuffix() {
1439
+ const mark = frame()
1440
+ return mark ? ` ${mark}` : ''
1441
+ }
1442
+
1443
+ /** A lane note, with the frame appended while that lane is still running or waiting. */
1444
+ function noted(text) {
1445
+ if (typeof text !== 'string' || !/ (?:running|waiting)$/.test(text)) return text
1446
+ return `${text}${markSuffix()}`
1119
1447
  }
1120
1448
 
1121
1449
  /**
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.
1450
+ * An agent's first line, one wrapping text: its name, its id and models dim,
1451
+ * then its turn word. A joined agent is named in the identity colour, a relay
1452
+ * dim, an unmatched agent dim and italic. The turn word is the host's, coloured
1453
+ * by what it means; a relay's running or answered turn stays dim, and only its
1454
+ * abnormal end is coloured.
1126
1455
  */
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'}`),
1456
+ function agentLine(Text, a, name, kind) {
1457
+ const nameProps = kind === 'joined' ? NAME_PROPS : kind === 'relay' ? DIM : { dimColor: true, italic: true }
1458
+ const quiet = kind === 'relay' && !abnormal(a)
1459
+ const spans = [
1460
+ h(Text, nameProps, name),
1461
+ h(Text, DIM, ` · ${id8(a.id)} · ${a.models.join(', ') || 'model not reported'}`),
1137
1462
  ' · ',
1138
- h(Text, quiet ? { dimColor: true } : turnProps(a.reason), done)
1463
+ h(Text, quiet ? DIM : turnProps(a.reason), turnText(a))
1464
+ ]
1465
+ if (!a.reason) {
1466
+ const mark = markSuffix()
1467
+ if (mark) spans.push(mark)
1468
+ }
1469
+ return h(Text, { wrap: 'wrap' }, ...spans)
1470
+ }
1471
+
1472
+ /**
1473
+ * One timed line (layout probe P3): the local time in a fixed dim gutter,
1474
+ * blank when no time was read, then the content in a column that wraps beside
1475
+ * it, `depth` steps in. Each content line is one Text with its coloured words
1476
+ * nested inside it, never a row of sibling Texts.
1477
+ */
1478
+ function gutterRow(Box, Text, key, at, depth, ...content) {
1479
+ return h(
1480
+ Box,
1481
+ { key, flexDirection: 'row', flexShrink: 0 },
1482
+ h(Box, { width: GUTTER_COLUMNS, flexShrink: 0 }, h(Text, DIM, clockText(at))),
1483
+ h(Box, { flexGrow: 1, flexShrink: 1, flexDirection: 'column', paddingLeft: depth * INDENT }, ...content)
1139
1484
  )
1140
1485
  }
1141
1486
 
1487
+ /**
1488
+ * A step's line: its own words, then the agent whose Bash call printed it,
1489
+ * dim, with its served models, its requests and, once its turn ended, how long
1490
+ * it took; the relay's turn word only when the host ended it without an answer.
1491
+ */
1492
+ function stepLine(Text, step) {
1493
+ const spans = [stepText(step)]
1494
+ if (step.relayId) {
1495
+ const a = run.agents.get(step.relayId)
1496
+ const figures = a ? ` · ${a.models.join(', ') || 'model not reported'} · ${requestsText(a)}` : ''
1497
+ spans.push(h(Text, DIM, ` · cli ${id8(step.relayId)}${figures}`))
1498
+ if (a && abnormal(a)) spans.push(' · ', h(Text, turnProps(a.reason), turnText(a)))
1499
+ else if (a && a.durationMs !== null) spans.push(h(Text, DIM, ` · ${durationText(a.durationMs)}`))
1500
+ }
1501
+ return h(Text, { wrap: 'wrap' }, ...spans)
1502
+ }
1503
+
1504
+ /**
1505
+ * The timeline section: `lib/meter-timeline.mjs`'s entries, each a gutter
1506
+ * line. An agent a step spawned and an agent no step names keep the requests
1507
+ * and tokens line beneath their own; a relay's figures ride its step's line or
1508
+ * its own one line, and its token breakdown is the trajectory's.
1509
+ */
1510
+ function drawTimeline($, e) {
1511
+ const { Box, Text } = $.ui.resolve(e)
1512
+ const entries = timeline({
1513
+ steps: run.steps,
1514
+ agents: [...run.agents.values()],
1515
+ launch: { at: run.launchedAt, name: run.workflowName }
1516
+ })
1517
+ const figures = a => h(Text, { dimColor: true, wrap: 'wrap' }, `${requestsText(a)} · ${tokensText(a)}`)
1518
+ const out = []
1519
+ for (const entry of entries) {
1520
+ const row = (...content) => gutterRow(Box, Text, entry.key, entry.at, entry.depth, ...content)
1521
+ if (entry.kind === 'launch') out.push(row(h(Text, { wrap: 'wrap' }, `launch · ${entry.name || 'interlock:ship'}`)))
1522
+ else if (entry.kind === 'step') out.push(row(stepLine(Text, entry.step)))
1523
+ else if (entry.kind === 'spawn' && entry.agent) {
1524
+ out.push(row(agentLine(Text, entry.agent, entry.spawn.title, 'joined'), figures(entry.agent)))
1525
+ } else if (entry.kind === 'spawn') {
1526
+ const waiting = [h(Text, NAME_PROPS, entry.spawn.title), ' · ', h(Text, DIM, 'waiting')]
1527
+ const mark = markSuffix()
1528
+ if (mark) waiting.push(mark)
1529
+ out.push(row(h(Text, { wrap: 'wrap' }, ...waiting)))
1530
+ } else if (entry.kind === 'relay') out.push(row(agentLine(Text, entry.agent, `cli · ${entry.agent.relay}`, 'relay')))
1531
+ else if (entry.kind === 'unmatched-head') out.push(h(Box, { key: entry.key, flexShrink: 0 }, h(Text, DIM, 'unmatched')))
1532
+ else if (entry.kind === 'unmatched') out.push(row(agentLine(Text, entry.agent, UNMATCHED, 'unmatched'), figures(entry.agent)))
1533
+ }
1534
+ if (entries.some(entry => entry.kind === 'unmatched')) {
1535
+ out.push(h(Box, { key: 'agents-unmatched', flexShrink: 0, paddingLeft: INDENT }, h(Text, { dimColor: true, wrap: 'wrap' }, UNMATCHED_LINE)))
1536
+ }
1537
+ if (!run.steps.length) out.push(h(Box, { key: 'timeline-no-step', flexShrink: 0 }, h(Text, DIM, NO_STEP_LINE)))
1538
+ return out
1539
+ }
1540
+
1142
1541
  /** One spawn as a flat row, as the section drew every spawn before the board: its title in the identity colour. */
1143
1542
  function flatRow($, e, r) {
1144
1543
  const { Box, Text } = $.ui.resolve(e)
1145
1544
  const a = agentForSha(r.sha)
1146
1545
  const served = a && a.models.length ? a.models.join(', ') : '?'
1147
1546
  const state = a ? a.reason || 'running' : 'waiting'
1547
+ const live = state === 'running' || state === 'waiting' ? `${state}${markSuffix()}` : state
1148
1548
  return h(
1149
1549
  Box,
1150
- { key: `wave-row-${r.label}` },
1550
+ { key: `wave-row-${r.label}`, flexShrink: 0 },
1151
1551
  h(
1152
1552
  Text,
1153
1553
  null,
1154
1554
  h(Text, NAME_PROPS, r.title),
1155
- ` · ${r.kind || 'agent'} · routed ${r.model || '?'} · served ${served} · effort ${r.effort || '?'} · ${state}`
1555
+ ` · ${r.kind || 'agent'} · routed ${r.model || '?'} · served ${served} · effort ${r.effort || '?'} · ${live}`
1156
1556
  )
1157
1557
  )
1158
1558
  }
@@ -1180,7 +1580,7 @@ function laneNotes() {
1180
1580
  for (const r of run.rows.values()) {
1181
1581
  const a = agentForSha(r.sha)
1182
1582
  const served = a && a.models.length ? a.models.join(', ') : '?'
1183
- notes[r.label] = `served ${served}${a ? ` · ${a.reason || 'running'}` : ''}`
1583
+ notes[r.label] = `served ${served}${a ? ` · ${turnText(a)}` : ''}`
1184
1584
  }
1185
1585
  return notes
1186
1586
  }
@@ -1193,11 +1593,13 @@ function boardKey(key) {
1193
1593
  return `${kind === 'wave' ? 'wave-block' : `wave-${kind}`}-${rest.join('-')}`
1194
1594
  }
1195
1595
 
1196
- /** The width a card and the renderer share: the body, capped at the published default. */
1596
+ /**
1597
+ * The width a card and the renderer share: the section body (the pane's body
1598
+ * less the body's indent beneath its heading), capped at the published default.
1599
+ */
1197
1600
  function boardColumns(e) {
1198
- const body = e.props && isInt(e.props.bodyColumns) && e.props.bodyColumns > 0
1199
- ? e.props.bodyColumns
1200
- : LIMITS.waveBoardDefaultColumns
1601
+ const pane = e.props && isInt(e.props.bodyColumns) && e.props.bodyColumns > 0 ? e.props.bodyColumns : null
1602
+ const body = pane === null ? LIMITS.waveBoardDefaultColumns : Math.max(1, pane - INDENT)
1201
1603
  return body < LIMITS.waveBoardMinColumns ? body : Math.min(body, LIMITS.waveBoardDefaultColumns)
1202
1604
  }
1203
1605
 
@@ -1226,14 +1628,14 @@ function idSpans(Text, parts) {
1226
1628
  }
1227
1629
 
1228
1630
  /**
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.
1631
+ * A lane's label, model, tier and effort cells, space-joined as the board
1632
+ * prints them, the label a span in the identity colour. The batch cell is the
1633
+ * sub-header the lane sits under, so the lane does not repeat it.
1231
1634
  */
1232
1635
  function headSpans(Text, parts) {
1233
- const before = [parts.batch].filter(Boolean).join(' ')
1234
1636
  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 !== '')
1637
+ if (!parts.label) return [after]
1638
+ return [h(Text, NAME_PROPS, parts.label), ...(after ? [` ${after}`] : [])]
1237
1639
  }
1238
1640
 
1239
1641
  /** Whether a lane the board drew holds the cursor: its own state word, or a task's, is `current`. */
@@ -1242,16 +1644,17 @@ const isCurrentLane = parts =>
1242
1644
  (parts.state === 'current' || (Array.isArray(parts.tasks) && parts.tasks.some(task => task && task.word === 'current')))
1243
1645
 
1244
1646
  /**
1245
- * A lane in its card: the fixed cells as one Text, the gist wrapping beneath,
1246
- * then the host's note, dim. The coloured words are spans nested in that one
1247
- * Text, because a row of separate elements shrinks each into its own column
1248
- * and breaks words apart once the line is wider than the card.
1647
+ * A lane in its card, one step in beneath its batch's sub-header with a blank
1648
+ * gutter: the fixed cells as one Text, the gist wrapping beneath, then the
1649
+ * host's note, dim. The coloured words are spans nested in that one Text,
1650
+ * because a row of separate elements shrinks each into its own column and
1651
+ * breaks words apart once the line is wider than the card.
1249
1652
  */
1250
1653
  function drawLane($, e, row) {
1251
1654
  const { Box, Text } = $.ui.resolve(e)
1252
1655
  const key = boardKey(row.key)
1253
1656
  const parts = row.parts
1254
- if (!parts) return h(Box, { key }, h(Text, { wrap: 'truncate-end' }, row.text))
1657
+ if (!parts) return h(Box, { key, flexShrink: 0 }, h(Text, { wrap: 'truncate-end' }, row.text))
1255
1658
  const line = [
1256
1659
  ...headSpans(Text, parts),
1257
1660
  ...(parts.state ? [' ', h(Text, stateProps(parts.state), parts.state)] : []),
@@ -1260,28 +1663,57 @@ function drawLane($, e, row) {
1260
1663
  ]
1261
1664
  const kids = [
1262
1665
  h(Text, { wrap: 'wrap' }, ...line),
1263
- parts.gist ? h(Box, { key: `${key}-gist` }, h(Text, { wrap: 'wrap' }, parts.gist)) : null,
1264
- parts.note ? h(Box, { key: `${key}-note` }, h(Text, { dimColor: true }, parts.note)) : null
1666
+ parts.gist ? h(Box, { key: `${key}-gist`, flexShrink: 0 }, h(Text, { wrap: 'wrap' }, parts.gist)) : null,
1667
+ parts.note ? h(Box, { key: `${key}-note`, flexShrink: 0 }, h(Text, { dimColor: true, wrap: 'wrap' }, noted(parts.note))) : null
1265
1668
  ].filter(Boolean)
1266
- return h(Box, { key, flexDirection: 'column' }, ...kids)
1669
+ return gutterRow(Box, Text, key, null, 1, ...kids)
1670
+ }
1671
+
1672
+ /**
1673
+ * A card's lanes, grouped by the batch the board printed for each: before each
1674
+ * group a gutter line with the time that batch was first dispatched and how
1675
+ * many lanes it runs side by side. It carries no state word: the cursor card's
1676
+ * border already marks where the run is.
1677
+ */
1678
+ function drawBatches($, e, index, lanes) {
1679
+ const { Box, Text } = $.ui.resolve(e)
1680
+ const groups = new Map()
1681
+ for (const row of lanes) {
1682
+ const batch = row.parts && typeof row.parts.batch === 'string' ? row.parts.batch : null
1683
+ if (!groups.has(batch)) groups.set(batch, [])
1684
+ groups.get(batch).push(row)
1685
+ }
1686
+ const out = []
1687
+ for (const [batch, rows] of groups) {
1688
+ const k = batch && /^b\d+$/.test(batch) ? batch.slice(1) : null
1689
+ if (k !== null) {
1690
+ const at = run.batchAt.has(`${index}:${k}`) ? run.batchAt.get(`${index}:${k}`) : null
1691
+ out.push(gutterRow(Box, Text, `wave-batch-${index}-${k}`, at, 0, h(Text, { wrap: 'wrap' }, `${batch} · ${laneCountText(rows.length)}`)))
1692
+ }
1693
+ out.push(...rows.map(row => drawLane($, e, row)))
1694
+ }
1695
+ return out
1267
1696
  }
1268
1697
 
1269
1698
  function drawBoardCards($, e, board, width) {
1270
1699
  const { Box, Text } = $.ui.resolve(e)
1271
- const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
1700
+ const keyed = (key, text, props) => h(Box, { key, flexShrink: 0 }, h(Text, props || null, text))
1272
1701
  const out = []
1273
1702
  let index = null
1274
1703
  let kids = null
1704
+ let lanes = null
1275
1705
  // Whether the card being filled holds a lane at the cursor: its border is then the current colour.
1276
1706
  let current = false
1277
1707
  const flush = () => {
1278
1708
  if (index === null) return
1709
+ kids.push(...drawBatches($, e, index, lanes))
1279
1710
  out.push(
1280
1711
  h(
1281
1712
  Box,
1282
1713
  {
1283
1714
  key: `wave-card-${index}`,
1284
1715
  flexDirection: 'column',
1716
+ flexShrink: 0,
1285
1717
  borderStyle: 'single',
1286
1718
  ...(current ? { borderColor: PALETTE.current } : { borderDimColor: true }),
1287
1719
  width,
@@ -1294,6 +1726,7 @@ function drawBoardCards($, e, board, width) {
1294
1726
  )
1295
1727
  index = null
1296
1728
  kids = null
1729
+ lanes = null
1297
1730
  current = false
1298
1731
  }
1299
1732
  for (const row of board) {
@@ -1301,15 +1734,15 @@ function drawBoardCards($, e, board, width) {
1301
1734
  if (row.key.startsWith('wave:')) {
1302
1735
  flush()
1303
1736
  index = row.key.slice('wave:'.length)
1304
- kids = [h(Box, { key: boardKey(row.key) }, h(Text, { bold: true }, waveTitleOf(row)))]
1737
+ kids = [h(Box, { key: boardKey(row.key), flexShrink: 0 }, h(Text, { bold: true }, waveTitleOf(row)))]
1738
+ lanes = []
1305
1739
  continue
1306
1740
  }
1307
1741
  if (row.key.startsWith('lane:')) {
1308
- const lane = drawLane($, e, row)
1309
- if (kids) {
1310
- kids.push(lane)
1742
+ if (lanes) {
1743
+ lanes.push(row)
1311
1744
  if (isCurrentLane(row.parts)) current = true
1312
- } else out.push(lane)
1745
+ } else out.push(drawLane($, e, row))
1313
1746
  continue
1314
1747
  }
1315
1748
  flush()
@@ -1330,11 +1763,11 @@ function drawBoardCards($, e, board, width) {
1330
1763
  */
1331
1764
  function drawWaves($, e) {
1332
1765
  const { Box, Text } = $.ui.resolve(e)
1333
- const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
1766
+ const keyed = (key, text, props) => h(Box, { key, flexShrink: 0 }, h(Text, props || null, text))
1334
1767
  const line = (key, text) => keyed(key, text, { wrap: 'truncate-end' })
1335
1768
  const rows = [...run.rows.values()]
1336
1769
  if (!run.plan) {
1337
- if (!rows.length) return [h(Text, { dimColor: true }, NO_LANES_LINE)]
1770
+ if (!rows.length) return [h(Box, { flexShrink: 0 }, h(Text, { dimColor: true }, `${NO_LANES_LINE}${markSuffix()}`))]
1338
1771
  const dispatched = run.dispatched !== null || rows.some(r => r.batch)
1339
1772
  return [...(dispatched ? [keyed('wave-board-unrelayed', UNRELAYED_LINE)] : []), ...rows.map(r => flatRow($, e, r))]
1340
1773
  }
@@ -1356,8 +1789,12 @@ function drawWaves($, e) {
1356
1789
 
1357
1790
  async function drawPane($, e) {
1358
1791
  const { Box, Text } = $.ui.resolve(e)
1359
- const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
1360
- const heading = text => h(Box, { marginTop: 1 }, h(Text, HEADING_PROPS, text))
1792
+ const keyed = (key, text, props) => h(Box, { key, flexShrink: 0 }, h(Text, props || null, text))
1793
+ const heading = text => h(Box, { flexShrink: 0, marginTop: 1 }, h(Text, HEADING_PROPS, text))
1794
+ // Each section's body sits one indent in beneath its heading. The pane
1795
+ // scrolls by the tree's own height; a column that can shrink collapses into
1796
+ // the viewport, and the latest lines then sit past the last offset.
1797
+ const body = (key, ...kids) => h(Box, { key, flexDirection: 'column', flexShrink: 0, paddingLeft: INDENT }, ...kids)
1361
1798
  // The launch-guard line, in the alarm colour only while its ruling refuses the next launch.
1362
1799
  const guardLine = async () => {
1363
1800
  const guard = await launchGuardText($)
@@ -1365,40 +1802,27 @@ async function drawPane($, e) {
1365
1802
  }
1366
1803
  if (run.phase === 'idle' && !run.summary) {
1367
1804
  // The guard runs in every session, so its line stands here too (design D5).
1368
- return h(Box, { flexDirection: 'column' }, h(Text, { dimColor: true }, NO_RUN_LINE), await guardLine())
1805
+ return h(Box, { key: 'meter-root', flexDirection: 'column', flexShrink: 0 }, h(Text, { dimColor: true }, NO_RUN_LINE), await guardLine())
1369
1806
  }
1370
1807
 
1371
1808
  const out = []
1372
- out.push(h(Text, HEADING_PROPS, `${run.change || 'ship run'}${run.runId ? ` · run ${run.runId}` : ''}`))
1373
- out.push(keyed('action', `action: ${run.action || 'starting'}${run.phase === 'closed' ? ' (closed)' : ''}`))
1809
+ out.push(h(Box, { flexShrink: 0 }, h(Text, HEADING_PROPS, `${run.change || 'ship run'}${run.runId ? ` · run ${run.runId}` : ''}`)))
1810
+ out.push(keyed('action', `action: ${run.action || 'starting'}${run.phase === 'closed' ? ' (closed)' : ''}${markSuffix()}`))
1374
1811
  const activity = await lastActivityText($)
1375
1812
  out.push(
1376
1813
  h(
1377
1814
  Box,
1378
- { key: 'last-activity' },
1815
+ { key: 'last-activity', flexShrink: 0 },
1379
1816
  h(Text, null, activity.line, ...(activity.word ? [' · ', h(Text, { color: PALETTE.warn }, activity.word)] : []))
1380
1817
  )
1381
1818
  )
1382
- out.push(h(Text, { dimColor: true }, RECORD_LINE))
1819
+ out.push(h(Box, { flexShrink: 0 }, h(Text, { dimColor: true }, RECORD_LINE)))
1383
1820
 
1384
1821
  out.push(heading('waves'))
1385
- out.push(...drawWaves($, e))
1822
+ out.push(body('waves-body', ...drawWaves($, e)))
1386
1823
 
1387
- out.push(heading('agents'))
1388
- if (!run.agents.size) out.push(h(Text, { dimColor: true }, 'no agent has reported a request yet'))
1389
- for (const a of run.agents.values()) {
1390
- const n = a.requests === 1 ? '1 request' : `${a.requests} requests`
1391
- const done = a.reason ? `${a.reason}${a.durationMs !== null ? ` ${(a.durationMs / 1000).toFixed(1)}s` : ''}` : 'running'
1392
- out.push(
1393
- h(
1394
- Box,
1395
- { key: `agent-${a.id}`, flexDirection: 'column' },
1396
- h(Box, null, agentLine(Text, a, done)),
1397
- h(Box, null, h(Text, { dimColor: true }, `${n} · ${tokensText(a)}`))
1398
- )
1399
- )
1400
- }
1401
- if ([...run.agents.values()].some(a => !agentName(a))) out.push(keyed('agents-unmatched', UNMATCHED_LINE, { dimColor: true }))
1824
+ out.push(heading('timeline'))
1825
+ out.push(body('timeline-body', ...drawTimeline($, e)))
1402
1826
 
1403
1827
  // One plain read feeds the session lines and the plan windows; a breakdown
1404
1828
  // is never asked for, because only the plain call is free (design D4).
@@ -1412,38 +1836,37 @@ async function drawPane($, e) {
1412
1836
  }
1413
1837
  const [contextLine, costLine] = sessionLines(usage, failure)
1414
1838
  out.push(heading('session'))
1415
- out.push(keyed('session-context', contextLine))
1416
- out.push(keyed('session-cost', costLine))
1839
+ out.push(body('session-body', keyed('session-context', contextLine), keyed('session-cost', costLine)))
1417
1840
 
1418
1841
  out.push(heading('plan windows'))
1419
1842
  const windows = usage && Array.isArray(usage.rateLimits) ? usage.rateLimits : []
1420
- if (failure !== null) out.push(h(Text, { dimColor: true }, `plan windows unavailable: ${failure}`))
1421
- else if (!windows.length) out.push(h(Text, { dimColor: true }, 'none reported (off a subscription the session has none)'))
1843
+ const planLines = []
1844
+ if (failure !== null) planLines.push(h(Text, { dimColor: true }, `plan windows unavailable: ${failure}`))
1845
+ else if (!windows.length) planLines.push(h(Text, { dimColor: true }, 'none reported (off a subscription the session has none)'))
1422
1846
  for (const w of windows) {
1423
1847
  if (!w || typeof w !== 'object') continue
1424
1848
  const kind = String(w.kind)
1425
- out.push(keyed(`plan-window-${kind}`, `${kind} ${w.percentUsed}% used${w.resetsAt ? ` · resets ${w.resetsAt}` : ''}`))
1849
+ planLines.push(keyed(`plan-window-${kind}`, `${kind} ${w.percentUsed}% used${w.resetsAt ? ` · resets ${w.resetsAt}` : ''}`))
1426
1850
  }
1851
+ out.push(body('plan-windows-body', ...planLines))
1427
1852
 
1428
1853
  out.push(heading('banners'))
1429
1854
  out.push(
1430
- h(
1431
- Box,
1432
- { key: 'banners', flexDirection: 'column' },
1855
+ body(
1856
+ 'banners',
1433
1857
  ...(run.banners.length ? run.banners.map(b => h(Text, { color: PALETTE.warn }, b)) : [h(Text, { dimColor: true }, 'none so far')])
1434
1858
  )
1435
1859
  )
1436
1860
 
1437
1861
  out.push(heading('refusals'))
1438
1862
  const denials = guardDenialLine(run.guardDenials)
1439
- if (denials) out.push(keyed('guard-denials', denials, { color: PALETTE.alarm }))
1440
- out.push(await guardLine())
1863
+ out.push(body('refusals-body', ...(denials ? [keyed('guard-denials', denials, { color: PALETTE.alarm })] : []), await guardLine()))
1441
1864
 
1442
1865
  if (run.summary) {
1443
1866
  out.push(heading('close summary'))
1444
- out.push(h(Box, { key: 'summary', flexDirection: 'column' }, h(Text, null, run.summary)))
1867
+ out.push(body('summary', h(Text, null, run.summary)))
1445
1868
  }
1446
- return h(Box, { flexDirection: 'column' }, ...out)
1869
+ return h(Box, { key: 'meter-root', flexDirection: 'column', flexShrink: 0 }, ...out)
1447
1870
  }
1448
1871
 
1449
1872
  export function register(on) {
@@ -1456,7 +1879,7 @@ export function register(on) {
1456
1879
  try {
1457
1880
  await $.command.register({
1458
1881
  name: PANE,
1459
- description: 'Open the Interlock ship meter: the live run, its waves, agents, plan windows and banners',
1882
+ description: 'Open the Interlock ship meter: the live run, its waves, step timeline, plan windows and banners',
1460
1883
  immediate: true
1461
1884
  })
1462
1885
  } catch (err) {
@@ -1573,17 +1996,20 @@ export function register(on) {
1573
1996
  run.workflowName = str(r.result.workflowName)
1574
1997
  run.transcriptDir = str(r.result.transcriptDir)
1575
1998
  run.lastActivityAt = now
1999
+ run.launchedAt = now
1576
2000
  $.ui.invalidate('ui.render')
1577
2001
  const placed = await openPane($)
1578
2002
  if (!placed || placed.isPlaced !== true) $.ui.toast(UNPLACED_TOAST)
1579
2003
  if (run === launched) startTick($)
2004
+ if (run === launched) await followBottom($)
1580
2005
  return r
1581
2006
  })
1582
2007
 
1583
2008
  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
1584
2009
  if (!session.interactive) return next(e)
1585
2010
  const command = typeof e.command === 'string' ? e.command : ''
1586
- const driver = DRIVER_LINE.test(command)
2011
+ const invocation = driverInvocation(command)
2012
+ const driver = invocation !== null
1587
2013
  // The spec flow's keyed lines, read only while a spec run is live (observe-the-spec-run-live design D3).
1588
2014
  const lines = specLines(command)
1589
2015
  const reader = lines.length ? specReader($, lines) : null
@@ -1599,7 +2025,7 @@ export function register(on) {
1599
2025
  // briefing it reads joins it to its lane, and a driver line names a relay.
1600
2026
  if (run.phase === 'live' && str(e.agentId)) {
1601
2027
  noteBriefing($, e.agentId, command)
1602
- if (driver) noteRelay($, e.agentId, command)
2028
+ if (driver) noteRelay($, e.agentId, invocation)
1603
2029
  }
1604
2030
  const r = await next(e)
1605
2031
  // Any Bash call during the run is read for a guard's refusal (guard-commit); only a driver line for a step.
@@ -1607,8 +2033,8 @@ export function register(on) {
1607
2033
  if (driver) {
1608
2034
  try {
1609
2035
  const { record, problem } = readRecord(r.text)
1610
- if (!record) $.ui.log(`interlock meter: ${command.slice(0, 80)}: not a step record (${problem})`, { to: 'debug' })
1611
- else if (held === run) await applyRecord($, record)
2036
+ if (!record) $.ui.log(`interlock meter: ${invocation.slice(0, 80)}: not a step record (${problem})`, { to: 'debug' })
2037
+ else if (held === run) await applyRecord($, record, str(e.agentId))
1612
2038
  else if (run.phase === 'idle' && !run.idleStepNamed) nameIdleStep($)
1613
2039
  } catch (err) {
1614
2040
  $.ui.log(`interlock meter: step read failed: ${(err && err.message) || err}`, { to: 'debug' })
@@ -1642,7 +2068,9 @@ export function register(on) {
1642
2068
  on('turn.step', async function* ($, e, next) {
1643
2069
  if (!session.interactive || run.phase !== 'live' || !e.agentId) return yield* next(e)
1644
2070
  // A run agent's request starting, and its answer arriving, are each activity (design D1).
2071
+ const held = run
1645
2072
  if (await stamp($)) redrawWord($)
2073
+ if (held === run) firstRequest(agentOf(e.agentId))
1646
2074
  const result = yield* next(e)
1647
2075
  if (await stamp($)) redrawWord($)
1648
2076
  try {
@@ -1670,7 +2098,9 @@ export function register(on) {
1670
2098
  $.ui.log(`interlock meter: the turn-end toast for ${a.id} was refused: ${messageOf(err)}`, { to: 'debug' })
1671
2099
  }
1672
2100
  }
2101
+ const held = run
1673
2102
  if (await stamp($)) redrawWord($)
2103
+ if (held === run) a.endAt = run.stampedAt
1674
2104
  $.ui.invalidate('ui.render')
1675
2105
  }
1676
2106
  return next(e)
package/lib/doctor.mjs CHANGED
@@ -535,9 +535,11 @@ function checkPlugin(pluginRoot) {
535
535
 
536
536
  /**
537
537
  * `interlock` and `interlock-graph` are on PATH inside a Claude Code session
538
- * because the host injects the plugin's `bin/`. Outside one they are not, and
539
- * that is not a fault — so an absence resolves to `warn` with the reason, never
540
- * to a failure that would make this command unusable from a plain shell.
538
+ * because the host injects the plugin's `bin/` — but not in the SessionStart
539
+ * hook that runs this doctor, nor in a plain shell. A copy in the plugin's
540
+ * `bin/` is a complete install, so it is `ok`: a `warn` here would be drawn
541
+ * above the prompt at every session start for a state nobody has to change.
542
+ * Only a binary found in neither place is a failure.
541
543
  */
542
544
  function checkBinaries(pluginRoot, env) {
543
545
  const found = []
@@ -552,9 +554,8 @@ function checkBinaries(pluginRoot, env) {
552
554
  }
553
555
  const bundled = absent.filter(name => existsSync(join(pluginRoot, 'bin', name)))
554
556
  if (bundled.length === absent.length) {
555
- return check('binaries', 'warn', `${absent.join(', ')} not on PATH — present in the plugin's bin/, which Claude Code injects inside a session`, {
556
- evidence: [...found, ...bundled.map(n => `bundled: ${join(pluginRoot, 'bin', n)}`)],
557
- fix: `Nothing to do for a run inside Claude Code. For a plain shell: export PATH="${join(pluginRoot, 'bin')}:$PATH"`
557
+ return check('binaries', 'ok', `${absent.join(', ')} run from the plugin's bin/ (${join(pluginRoot, 'bin')}), which Claude Code puts on PATH inside a session`, {
558
+ evidence: [...found, ...bundled.map(n => `bundled: ${join(pluginRoot, 'bin', n)}`)]
558
559
  })
559
560
  }
560
561
  return check('binaries', 'fail', `${absent.join(', ')} resolve neither on PATH nor in the plugin's bin/`, {
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@renzrollon/interlock",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec",
5
5
  "type": "module",
6
6
  "engines": {