@renzrollon/interlock 1.4.1 → 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.1",
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,12 @@ 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
+
8
14
  ## 1.4.1 — 2026-10-09
9
15
 
10
16
  ### Changed
package/hooks/mod.mjs CHANGED
@@ -75,9 +75,12 @@
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.
81
84
  //
82
85
  // THE STEP TIMELINE. The agents list's place is a timeline: one line per step
83
86
  // as it crossed, the agent whose Bash call printed it folded into that line,
@@ -184,8 +187,8 @@ const LEDGER = { plugin: 'interlock', key: 'ledger' }
184
187
  // a missing origin count nothing.
185
188
  const HUMAN_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
186
189
  const GUARD = 'interlock guard'
187
- // The line `cli()` in workflows/ship.js hands the ping (design D2).
188
- 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.
189
192
  // The bootstrap line a lane's spawn prompt carries (as `lib/agent-usage.mjs` reads it).
190
193
  const BRIEFING_SHA = /Expected sha256: ([0-9a-f]{64})/g
191
194
  // What an agent row and a turn-end toast read for an agent no row and no driver line names.
@@ -202,6 +205,9 @@ const DIM = { dimColor: true }
202
205
  // and a child line sit in from what holds them (layout probe P3, Claude Code 2.1.295).
203
206
  const GUTTER_COLUMNS = 10
204
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 = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
205
211
  const NO_STEP_LINE = 'no step has crossed yet'
206
212
  /** The props the run header and each section heading are drawn with. */
207
213
  const HEADING_PROPS = { bold: true, color: PALETTE.accent }
@@ -275,7 +281,11 @@ function freshRun() {
275
281
  dispatched: null,
276
282
  halt: null,
277
283
  planNamed: false,
278
- 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
279
289
  }
280
290
  }
281
291
 
@@ -408,10 +418,10 @@ async function stamp($) {
408
418
 
409
419
  /**
410
420
  * Starts the run's one interval (design D2), the host's own timer: each period
411
- * reads the clock and recomputes the word, and draws only when the word
412
- * changed. A period that finds another run or another interval in its place
413
- * returns at once. A host that will not start one leaves the pane, which
414
- * recomputes the word whenever it is drawn.
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.
415
425
  */
416
426
  function startTick($) {
417
427
  const generation = ++generations
@@ -422,7 +432,14 @@ function startTick($) {
422
432
  if (!isMine(tick)) return
423
433
  try {
424
434
  const now = await clockNow($)
425
- 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($)
426
443
  } catch (err) {
427
444
  $.ui.log(`interlock meter: a quiet-figure period failed: ${messageOf(err)}`, { to: 'debug' })
428
445
  }
@@ -435,6 +452,28 @@ function startTick($) {
435
452
  }
436
453
  }
437
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
+
438
477
  /** Ends the run's interval, if it has one: a cancelled timer never fires again. */
439
478
  function stopTick() {
440
479
  if (run.tick) run.tick.cancel()
@@ -697,6 +736,126 @@ function noteBriefing($, agentId, text) {
697
736
  }
698
737
  }
699
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
+
700
859
  /**
701
860
  * `run next` for `interlock run next --results … --json`: the subcommand a
702
861
  * driver line relays, as `workflows/ship.js` names its relay (`argv.slice(0, 2)`),
@@ -809,18 +968,75 @@ function noteIdleStep($, r) {
809
968
  }
810
969
  }
811
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
+
812
1027
  /** The step record in a Bash result's text, guarded as the driver guards it: an object with a string `action`. */
813
1028
  function readRecord(text) {
814
1029
  if (typeof text !== 'string' || !text.trim()) return { problem: 'no result text' }
815
- let value
1030
+ const trimmed = text.trim()
816
1031
  try {
817
- 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' }
818
1036
  } catch {
819
- return { problem: 'not JSON' }
1037
+ const found = findStep(trimmed)
1038
+ return found ? { record: found } : { problem: 'not JSON' }
820
1039
  }
821
- if (!value || typeof value !== 'object' || Array.isArray(value)) return { problem: 'not an object' }
822
- if (typeof value.action !== 'string') return { problem: 'no action' }
823
- return { record: value }
824
1040
  }
825
1041
 
826
1042
  function agentOf(id) {
@@ -1017,6 +1233,7 @@ async function applyRecord($, record, relayId) {
1017
1233
  }
1018
1234
  setStatus($)
1019
1235
  $.ui.invalidate('ui.render')
1236
+ await followBottom($)
1020
1237
  }
1021
1238
 
1022
1239
  async function openPane($, id = PANE, title = TITLE) {
@@ -1199,6 +1416,36 @@ const turnText = a =>
1199
1416
  /** Whether the host ended the agent's turn without an answer. */
1200
1417
  const abnormal = a => Boolean(a.reason) && a.reason !== 'answer'
1201
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()}`
1447
+ }
1448
+
1202
1449
  /**
1203
1450
  * An agent's first line, one wrapping text: its name, its id and models dim,
1204
1451
  * then its turn word. A joined agent is named in the identity colour, a relay
@@ -1209,14 +1456,17 @@ const abnormal = a => Boolean(a.reason) && a.reason !== 'answer'
1209
1456
  function agentLine(Text, a, name, kind) {
1210
1457
  const nameProps = kind === 'joined' ? NAME_PROPS : kind === 'relay' ? DIM : { dimColor: true, italic: true }
1211
1458
  const quiet = kind === 'relay' && !abnormal(a)
1212
- return h(
1213
- Text,
1214
- { wrap: 'wrap' },
1459
+ const spans = [
1215
1460
  h(Text, nameProps, name),
1216
1461
  h(Text, DIM, ` · ${id8(a.id)} · ${a.models.join(', ') || 'model not reported'}`),
1217
1462
  ' · ',
1218
1463
  h(Text, quiet ? DIM : turnProps(a.reason), turnText(a))
1219
- )
1464
+ ]
1465
+ if (!a.reason) {
1466
+ const mark = markSuffix()
1467
+ if (mark) spans.push(mark)
1468
+ }
1469
+ return h(Text, { wrap: 'wrap' }, ...spans)
1220
1470
  }
1221
1471
 
1222
1472
  /**
@@ -1228,7 +1478,7 @@ function agentLine(Text, a, name, kind) {
1228
1478
  function gutterRow(Box, Text, key, at, depth, ...content) {
1229
1479
  return h(
1230
1480
  Box,
1231
- { key, flexDirection: 'row' },
1481
+ { key, flexDirection: 'row', flexShrink: 0 },
1232
1482
  h(Box, { width: GUTTER_COLUMNS, flexShrink: 0 }, h(Text, DIM, clockText(at))),
1233
1483
  h(Box, { flexGrow: 1, flexShrink: 1, flexDirection: 'column', paddingLeft: depth * INDENT }, ...content)
1234
1484
  )
@@ -1273,15 +1523,18 @@ function drawTimeline($, e) {
1273
1523
  else if (entry.kind === 'spawn' && entry.agent) {
1274
1524
  out.push(row(agentLine(Text, entry.agent, entry.spawn.title, 'joined'), figures(entry.agent)))
1275
1525
  } else if (entry.kind === 'spawn') {
1276
- out.push(row(h(Text, { wrap: 'wrap' }, h(Text, NAME_PROPS, entry.spawn.title), ' · ', h(Text, DIM, 'waiting'))))
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)))
1277
1530
  } 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')))
1531
+ else if (entry.kind === 'unmatched-head') out.push(h(Box, { key: entry.key, flexShrink: 0 }, h(Text, DIM, 'unmatched')))
1279
1532
  else if (entry.kind === 'unmatched') out.push(row(agentLine(Text, entry.agent, UNMATCHED, 'unmatched'), figures(entry.agent)))
1280
1533
  }
1281
1534
  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)))
1535
+ out.push(h(Box, { key: 'agents-unmatched', flexShrink: 0, paddingLeft: INDENT }, h(Text, { dimColor: true, wrap: 'wrap' }, UNMATCHED_LINE)))
1283
1536
  }
1284
- if (!run.steps.length) out.push(h(Box, { key: 'timeline-no-step' }, h(Text, DIM, NO_STEP_LINE)))
1537
+ if (!run.steps.length) out.push(h(Box, { key: 'timeline-no-step', flexShrink: 0 }, h(Text, DIM, NO_STEP_LINE)))
1285
1538
  return out
1286
1539
  }
1287
1540
 
@@ -1291,14 +1544,15 @@ function flatRow($, e, r) {
1291
1544
  const a = agentForSha(r.sha)
1292
1545
  const served = a && a.models.length ? a.models.join(', ') : '?'
1293
1546
  const state = a ? a.reason || 'running' : 'waiting'
1547
+ const live = state === 'running' || state === 'waiting' ? `${state}${markSuffix()}` : state
1294
1548
  return h(
1295
1549
  Box,
1296
- { key: `wave-row-${r.label}` },
1550
+ { key: `wave-row-${r.label}`, flexShrink: 0 },
1297
1551
  h(
1298
1552
  Text,
1299
1553
  null,
1300
1554
  h(Text, NAME_PROPS, r.title),
1301
- ` · ${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}`
1302
1556
  )
1303
1557
  )
1304
1558
  }
@@ -1400,7 +1654,7 @@ function drawLane($, e, row) {
1400
1654
  const { Box, Text } = $.ui.resolve(e)
1401
1655
  const key = boardKey(row.key)
1402
1656
  const parts = row.parts
1403
- 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))
1404
1658
  const line = [
1405
1659
  ...headSpans(Text, parts),
1406
1660
  ...(parts.state ? [' ', h(Text, stateProps(parts.state), parts.state)] : []),
@@ -1409,8 +1663,8 @@ function drawLane($, e, row) {
1409
1663
  ]
1410
1664
  const kids = [
1411
1665
  h(Text, { wrap: 'wrap' }, ...line),
1412
- parts.gist ? h(Box, { key: `${key}-gist` }, h(Text, { wrap: 'wrap' }, parts.gist)) : null,
1413
- parts.note ? h(Box, { key: `${key}-note` }, h(Text, { dimColor: true, wrap: 'wrap' }, 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
1414
1668
  ].filter(Boolean)
1415
1669
  return gutterRow(Box, Text, key, null, 1, ...kids)
1416
1670
  }
@@ -1443,7 +1697,7 @@ function drawBatches($, e, index, lanes) {
1443
1697
 
1444
1698
  function drawBoardCards($, e, board, width) {
1445
1699
  const { Box, Text } = $.ui.resolve(e)
1446
- 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))
1447
1701
  const out = []
1448
1702
  let index = null
1449
1703
  let kids = null
@@ -1459,6 +1713,7 @@ function drawBoardCards($, e, board, width) {
1459
1713
  {
1460
1714
  key: `wave-card-${index}`,
1461
1715
  flexDirection: 'column',
1716
+ flexShrink: 0,
1462
1717
  borderStyle: 'single',
1463
1718
  ...(current ? { borderColor: PALETTE.current } : { borderDimColor: true }),
1464
1719
  width,
@@ -1479,7 +1734,7 @@ function drawBoardCards($, e, board, width) {
1479
1734
  if (row.key.startsWith('wave:')) {
1480
1735
  flush()
1481
1736
  index = row.key.slice('wave:'.length)
1482
- 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)))]
1483
1738
  lanes = []
1484
1739
  continue
1485
1740
  }
@@ -1508,11 +1763,11 @@ function drawBoardCards($, e, board, width) {
1508
1763
  */
1509
1764
  function drawWaves($, e) {
1510
1765
  const { Box, Text } = $.ui.resolve(e)
1511
- 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))
1512
1767
  const line = (key, text) => keyed(key, text, { wrap: 'truncate-end' })
1513
1768
  const rows = [...run.rows.values()]
1514
1769
  if (!run.plan) {
1515
- 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()}`))]
1516
1771
  const dispatched = run.dispatched !== null || rows.some(r => r.batch)
1517
1772
  return [...(dispatched ? [keyed('wave-board-unrelayed', UNRELAYED_LINE)] : []), ...rows.map(r => flatRow($, e, r))]
1518
1773
  }
@@ -1534,10 +1789,12 @@ function drawWaves($, e) {
1534
1789
 
1535
1790
  async function drawPane($, e) {
1536
1791
  const { Box, Text } = $.ui.resolve(e)
1537
- const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, 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)
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)
1541
1798
  // The launch-guard line, in the alarm colour only while its ruling refuses the next launch.
1542
1799
  const guardLine = async () => {
1543
1800
  const guard = await launchGuardText($)
@@ -1545,21 +1802,21 @@ async function drawPane($, e) {
1545
1802
  }
1546
1803
  if (run.phase === 'idle' && !run.summary) {
1547
1804
  // The guard runs in every session, so its line stands here too (design D5).
1548
- 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())
1549
1806
  }
1550
1807
 
1551
1808
  const out = []
1552
- out.push(h(Text, HEADING_PROPS, `${run.change || 'ship run'}${run.runId ? ` · run ${run.runId}` : ''}`))
1553
- 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()}`))
1554
1811
  const activity = await lastActivityText($)
1555
1812
  out.push(
1556
1813
  h(
1557
1814
  Box,
1558
- { key: 'last-activity' },
1815
+ { key: 'last-activity', flexShrink: 0 },
1559
1816
  h(Text, null, activity.line, ...(activity.word ? [' · ', h(Text, { color: PALETTE.warn }, activity.word)] : []))
1560
1817
  )
1561
1818
  )
1562
- out.push(h(Text, { dimColor: true }, RECORD_LINE))
1819
+ out.push(h(Box, { flexShrink: 0 }, h(Text, { dimColor: true }, RECORD_LINE)))
1563
1820
 
1564
1821
  out.push(heading('waves'))
1565
1822
  out.push(body('waves-body', ...drawWaves($, e)))
@@ -1609,7 +1866,7 @@ async function drawPane($, e) {
1609
1866
  out.push(heading('close summary'))
1610
1867
  out.push(body('summary', h(Text, null, run.summary)))
1611
1868
  }
1612
- return h(Box, { flexDirection: 'column' }, ...out)
1869
+ return h(Box, { key: 'meter-root', flexDirection: 'column', flexShrink: 0 }, ...out)
1613
1870
  }
1614
1871
 
1615
1872
  export function register(on) {
@@ -1744,13 +2001,15 @@ export function register(on) {
1744
2001
  const placed = await openPane($)
1745
2002
  if (!placed || placed.isPlaced !== true) $.ui.toast(UNPLACED_TOAST)
1746
2003
  if (run === launched) startTick($)
2004
+ if (run === launched) await followBottom($)
1747
2005
  return r
1748
2006
  })
1749
2007
 
1750
2008
  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
1751
2009
  if (!session.interactive) return next(e)
1752
2010
  const command = typeof e.command === 'string' ? e.command : ''
1753
- const driver = DRIVER_LINE.test(command)
2011
+ const invocation = driverInvocation(command)
2012
+ const driver = invocation !== null
1754
2013
  // The spec flow's keyed lines, read only while a spec run is live (observe-the-spec-run-live design D3).
1755
2014
  const lines = specLines(command)
1756
2015
  const reader = lines.length ? specReader($, lines) : null
@@ -1766,7 +2025,7 @@ export function register(on) {
1766
2025
  // briefing it reads joins it to its lane, and a driver line names a relay.
1767
2026
  if (run.phase === 'live' && str(e.agentId)) {
1768
2027
  noteBriefing($, e.agentId, command)
1769
- if (driver) noteRelay($, e.agentId, command)
2028
+ if (driver) noteRelay($, e.agentId, invocation)
1770
2029
  }
1771
2030
  const r = await next(e)
1772
2031
  // Any Bash call during the run is read for a guard's refusal (guard-commit); only a driver line for a step.
@@ -1774,7 +2033,7 @@ export function register(on) {
1774
2033
  if (driver) {
1775
2034
  try {
1776
2035
  const { record, problem } = readRecord(r.text)
1777
- if (!record) $.ui.log(`interlock meter: ${command.slice(0, 80)}: not a step record (${problem})`, { to: 'debug' })
2036
+ if (!record) $.ui.log(`interlock meter: ${invocation.slice(0, 80)}: not a step record (${problem})`, { to: 'debug' })
1778
2037
  else if (held === run) await applyRecord($, record, str(e.agentId))
1779
2038
  else if (run.phase === 'idle' && !run.idleStepNamed) nameIdleStep($)
1780
2039
  } catch (err) {
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/`, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@renzrollon/interlock",
3
- "version": "1.4.1",
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": {