@renzrollon/interlock 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/hooks/mod.mjs CHANGED
@@ -47,15 +47,111 @@
47
47
  // status line, the spinner and the pane. The word names no cause, and the
48
48
  // threshold and the period are `lib/meter-quiet.mjs`'s, read from
49
49
  // `interlock limits`; this file holds neither number.
50
+ //
51
+ // THE GUARDS' REFUSALS (speak-permission-prompts-and-guard-denials design D4,
52
+ // D5). A settings guard that refuses a tool call ends it with an errored result
53
+ // whose text carries the guard's own sentence behind an engine prefix. The
54
+ // tool hooks on Bash, Edit, Write and Workflow read that result, and when it
55
+ // names one of the four guards the sentence is toasted once and counted on the
56
+ // pane. The in-process launch guard's own refusal is toasted in the rule's
57
+ // words, and the pane shows the rule's ruling over the session's record. The
58
+ // names and the words are `lib/meter-refusals.mjs`'s and the guards'; the
59
+ // permission prompts are not spoken, because no classic event reaches a hooks
60
+ // module on the engines probed (design D1, D9).
61
+ //
62
+ // THE WAVE BOARD (draw-the-wave-board-in-the-meter-pane design D5-D8). The
63
+ // pane's waves section is the board `lib/draw-plan.mjs` draws for the CLI,
64
+ // drawn from one source: the plan summary the adoption and replan steps carry,
65
+ // with an overlay built from the steps alone (the cursor, the ids each recorded
66
+ // batch names, the skipped verifications, the halt). No file is read for it.
67
+ // The module lays those rows as one bordered card per wave, wraps the gist,
68
+ // and colours only the state word the renderer chose. It keys the lanes,
69
+ // appends what the host observed of a lane, and speaks every fallback: a CLI
70
+ // that relays no plan, a pane narrower than the board, a renderer that throws.
71
+ //
72
+ // THE SESSION START (show-preflight-and-interrupted-runs-at-session-start
73
+ // design D5-D8). Outside a run the module draws two things, both from the
74
+ // report `hooks/preflight.mjs` leaves at `.claude/ship/preflight.json`: the
75
+ // `AbovePrompt` band with `/interlock-preflight`, and `/interlock-handoff`,
76
+ // which renders a halt resume card the report names. That file and a card it
77
+ // names are the module's one file read, `$.fs.read`, and nothing else: never a
78
+ // run's trajectory, its manifest or its wave state, and no listing or
79
+ // existence check (the hook lists, in Node). It reads at each prompt a person
80
+ // submits, the one event that reaches a module after the preflight wrote, and
81
+ // after the classic session-start event where an engine raises one. A report
82
+ // is known by its own written time, so a re-read changes nothing and the band
83
+ // stays hidden once hidden; the lines, the hide rule and the card's cap are
84
+ // `lib/preflight-file.mjs`'s.
85
+ //
86
+ // THE SPEC METER (observe-the-spec-run-live design D1-D7). The same module
87
+ // draws an `/interlock:spec` run, which has no CLI spine of its own, from what
88
+ // already crossed it: the spec skill's load, read off a submitted prompt's
89
+ // first word or a Skill tool call (the engine raised no `skill.prompt` for a
90
+ // plugin skill on 2.1.291, so that event is not hooked), the Bash results of
91
+ // the flow's keyed `openspec` and `interlock` lines, the paths of Edit and
92
+ // Write calls, and the Explore spawns. A prompt, a Skill call and every result
93
+ // are passed on as they came. It writes nothing and reads no file: the
94
+ // artifacts on disk, the findings file and the gate's exit are the record,
95
+ // and the pane says so. Its record is module state, ended at the checkpoint,
96
+ // at an accepted ship launch and at the session boundary. The module owns one
97
+ // status line, written by `setStatus` alone: the ship position while a ship
98
+ // run is live, else the spec line. Every rule and word is
99
+ // `lib/spec-meter.mjs`'s.
50
100
 
101
+ import { drawPlanBoardRows } from '../lib/draw-plan.mjs'
51
102
  import { decideLaunch, emptyRecord, isAcceptedLaunch, isShipLaunch, withLaunch, withPrompt } from '../lib/launch-rule.mjs'
103
+ import { LIMITS } from '../lib/limits.mjs'
52
104
  import { TICK_MS, quietMs, quietWord } from '../lib/meter-quiet.mjs'
105
+ import { guardDenialLine, guardOf, guardReason, launchGuardLine } from '../lib/meter-refusals.mjs'
106
+ import {
107
+ adoptPreflight,
108
+ bandLines,
109
+ cardLine,
110
+ clampCard,
111
+ freshStart,
112
+ noCardText,
113
+ noReportText,
114
+ orderCards,
115
+ preflightFilePath,
116
+ preflightPaneLines,
117
+ readPreflightReport
118
+ } from '../lib/preflight-file.mjs'
119
+ import {
120
+ SPEC_REPLACED_LINE,
121
+ SPEC_TITLE,
122
+ SPEC_UNPLACED_TOAST,
123
+ TEXT_KINDS,
124
+ changeOf,
125
+ freshSpec,
126
+ lateLineText,
127
+ promptSkill,
128
+ readResult,
129
+ skillRole,
130
+ specLines,
131
+ specPaneLines,
132
+ specStatusText,
133
+ unparsedEntry,
134
+ unreadLineText,
135
+ writeOf
136
+ } from '../lib/spec-meter.mjs'
53
137
 
54
138
  const PANE = 'interlock-meter'
55
139
  const TITLE = 'Interlock'
56
140
  const RECORD_LINE = 'live figures; the close summary and the receipt are the record'
57
141
  const NO_RUN_LINE = 'no ship run is live in this session'
58
142
  const UNPLACED_TOAST = '/interlock-meter opens the ship meter'
143
+ // The wave section's spoken lines (draw-the-wave-board-in-the-meter-pane design D8).
144
+ const NO_LANES_LINE = 'no lanes dispatched yet'
145
+ const UNRELAYED_LINE = 'plan structure not relayed by this CLI'
146
+ const BOARD_FAILED_LINE = 'wave board could not be drawn'
147
+ // The actions that dispatch a batch of lanes, and so place the board's cursor on one.
148
+ const BATCH_ACTIONS = new Set(['run-batch', 'test-wave'])
149
+ // The session start's two panes, each opened by the command of its name (design D6).
150
+ const PREFLIGHT_PANE = 'interlock-preflight'
151
+ const HANDOFF_PANE = 'interlock-handoff'
152
+ // The spec meter's pane and command (observe-the-spec-run-live design D6), spelled
153
+ // here so the validator records it; pinned equal to lib/spec-meter.mjs's.
154
+ const SPEC_PANE = 'interlock-spec'
59
155
 
60
156
  // The launch guard's record in the session's state: the one key the plugin's
61
157
  // type contract declares (types/index.d.ts; guard-ship-relaunch-in-process D2).
@@ -79,10 +175,16 @@ const BOUNDARIES = new Map([
79
175
  ['fork', '/branch']
80
176
  ])
81
177
 
82
- let session = { interactive: false, surface: null }
178
+ let session = { interactive: false, surface: null, cwd: null }
83
179
  let run = freshRun()
180
+ // The session start's report, as last read (design D8): module state, reset
181
+ // with the run on a reload and on the session boundary.
182
+ let start = freshStart()
84
183
  // Counts every interval this environment started, so a period can tell its own (design D2).
85
184
  let generations = 0
185
+ // The spec run, as module state (observe-the-spec-run-live design D7): reset
186
+ // on a reload and at the session boundary, like the ship run.
187
+ let spec = freshSpec()
86
188
 
87
189
  function freshRun() {
88
190
  return {
@@ -111,11 +213,28 @@ function freshRun() {
111
213
  generation: 0,
112
214
  clockFailed: false,
113
215
  // Whether a step that crossed with no live run has been named on the debug log.
114
- idleStepNamed: false
216
+ idleStepNamed: false,
217
+ // Each settings guard's refusals this run, by guard, in first-seen order (design D4).
218
+ guardDenials: new Map(),
219
+ // The wave board (draw-the-wave-board-in-the-meter-pane design D6), every
220
+ // field taken off a step: the latest plan summary, the ids each recorded
221
+ // batch named (and, for a failed id, the wave it was recorded at), the
222
+ // skipped verifications by position, where the run is, the last batch
223
+ // dispatched, the halt, and whether a malformed field was named yet.
224
+ plan: null,
225
+ recorded: { ok: [], failed: [], notAttempted: [] },
226
+ failedAt: new Map(),
227
+ skips: [],
228
+ cursor: null,
229
+ dispatched: null,
230
+ halt: null,
231
+ planNamed: false,
232
+ recordedNamed: false
115
233
  }
116
234
  }
117
235
 
118
236
  const watching = () => session.interactive && (run.phase === 'live' || run.phase === 'closing')
237
+ const specWatching = () => session.interactive && spec.phase === 'live'
119
238
  const isInt = v => Number.isInteger(v)
120
239
  const str = v => (typeof v === 'string' && v ? v : null)
121
240
 
@@ -179,6 +298,18 @@ function statusText() {
179
298
  return text ? `interlock: ${text}` : undefined
180
299
  }
181
300
 
301
+ /**
302
+ * The module's one status line, composed in one place (observe-the-spec-run-live
303
+ * design D7): the ship position while a ship run is live, else the spec line
304
+ * while a spec run is live or stopped at its checkpoint, else nothing. The
305
+ * engine keeps one line per plugin and each call replaces it (probe 4), so
306
+ * this is the only writer.
307
+ */
308
+ function setStatus($) {
309
+ const shownSpec = spec.phase === 'live' || spec.phase === 'checkpoint'
310
+ $.ui.status(run.phase === 'live' ? statusText() : shownSpec ? specStatusText(spec) : undefined)
311
+ }
312
+
182
313
  /**
183
314
  * The engine's time now, or `null` when the clock cannot be read. A read that
184
315
  * fails is named once per run on the debug log and never thrown (design D1).
@@ -210,7 +341,7 @@ function setWord(word) {
210
341
 
211
342
  /** Draws the word where it shows: the status line while the run is live, the pane and the spinner. */
212
343
  function redrawWord($) {
213
- if (run.phase === 'live') $.ui.status(statusText())
344
+ if (run.phase === 'live') setStatus($)
214
345
  $.ui.invalidate('ui.render')
215
346
  }
216
347
 
@@ -263,6 +394,277 @@ function stopTick() {
263
394
  run.quietWord = null
264
395
  }
265
396
 
397
+ /**
398
+ * A settings guard's refusal, read off the result the engine handed a tool hook
399
+ * (design D4): the text from the guard's name on is toasted once per distinct
400
+ * text per run and counted by guard. A result that is not errored, or names no
401
+ * guard, is not this module's to speak: nothing is drawn and nothing logged.
402
+ */
403
+ function speakRefusal($, r) {
404
+ try {
405
+ const reason = r && r.isError === true ? guardReason(r.text) : null
406
+ if (!reason) return
407
+ const guard = guardOf(reason)
408
+ run.guardDenials.set(guard, (run.guardDenials.get(guard) || 0) + 1)
409
+ if (!run.toasted.has(reason)) {
410
+ run.toasted.add(reason)
411
+ $.ui.toast(reason)
412
+ }
413
+ $.ui.invalidate('ui.render')
414
+ } catch (err) {
415
+ $.ui.log(`interlock meter: a guard's refusal was not drawn: ${messageOf(err)}`, { to: 'debug' })
416
+ }
417
+ }
418
+
419
+ /**
420
+ * An edit during a live ship run, its result read for a guard's refusal
421
+ * (guard-tests, guard-tasks); during a live spec run, its path filed as a
422
+ * write when the engine did not mark it errored (observe-the-spec-run-live
423
+ * design D4). Returned as it came.
424
+ */
425
+ async function editCall($, e, next) {
426
+ const ship = watching()
427
+ if (!ship && !specWatching()) return next(e)
428
+ const held = run
429
+ const heldSpec = spec
430
+ const r = await next(e)
431
+ if (ship && held === run) speakRefusal($, r)
432
+ if (heldSpec === spec && specWatching() && !(r && r.isError === true)) await fileWrite($, e.file_path)
433
+ return r
434
+ }
435
+
436
+ /**
437
+ * The engine's time now for the spec record, or `null` when the clock cannot
438
+ * be read: the ship meter's rule (design D6), named once per spec run.
439
+ */
440
+ async function specNow($) {
441
+ const held = spec
442
+ try {
443
+ const now = await $.clock.now()
444
+ if (Number.isFinite(now)) return now
445
+ throw new Error(`the clock answered ${String(now)}`)
446
+ } catch (err) {
447
+ if (!held.clockFailed) {
448
+ held.clockFailed = true
449
+ $.ui.log(`interlock spec: the clock cannot be read; the last activity time stands: ${messageOf(err)}`, { to: 'debug' })
450
+ }
451
+ return null
452
+ }
453
+ }
454
+
455
+ /** Activity on the spec record: its last activity is now, when the clock answers. */
456
+ async function specStamp($) {
457
+ const held = spec
458
+ const now = await specNow($)
459
+ if (held === spec && now !== null) spec.lastActivityAt = now
460
+ return now
461
+ }
462
+
463
+ /** Redraws what the spec run shows: the status line through the composer, and the panes. */
464
+ function redrawSpec($) {
465
+ setStatus($)
466
+ $.ui.invalidate('ui.render')
467
+ }
468
+
469
+ /**
470
+ * The spec skill's load (design D1): `spec` starts a run over whatever was
471
+ * held, `explore` and `review-artifacts` set the stage of a live one, and
472
+ * anything else is not this meter's. Never throws.
473
+ */
474
+ async function observeLoad($, name) {
475
+ try {
476
+ const role = skillRole(name)
477
+ if (role === 'spec') await startSpec($)
478
+ else if (role && specWatching()) {
479
+ spec.stage = { kind: role }
480
+ await specStamp($)
481
+ redrawSpec($)
482
+ }
483
+ } catch (err) {
484
+ $.ui.log(`interlock spec: a skill load was not drawn: ${messageOf(err)}`, { to: 'debug' })
485
+ }
486
+ }
487
+
488
+ /** A fresh spec run, live, its pane opened unasked; one toast when the engine will not seat it. */
489
+ async function startSpec($) {
490
+ if (spec.phase !== 'idle') $.ui.log(SPEC_REPLACED_LINE, { to: 'debug' })
491
+ spec = freshSpec()
492
+ const started = spec
493
+ spec.phase = 'live'
494
+ spec.startedAt = await specStamp($)
495
+ if (started !== spec) return
496
+ redrawSpec($)
497
+ const placed = await openPane($, SPEC_PANE, SPEC_TITLE)
498
+ if (!placed || placed.isPlaced !== true) $.ui.toast(SPEC_UNPLACED_TOAST)
499
+ }
500
+
501
+ /**
502
+ * The spec record that will read a keyed command, or `null` (design D3). With
503
+ * no live spec run the first keyed line after the checkpoint or a session
504
+ * boundary is named once on the debug log, and none is read.
505
+ */
506
+ function specReader($, lines) {
507
+ if (specWatching()) return spec
508
+ const keyed = lines.find(l => l.kind !== 'naming-only')
509
+ if (keyed && !spec.lateNamed && (spec.phase === 'checkpoint' || spec.afterBoundary)) {
510
+ spec.lateNamed = true
511
+ $.ui.log(lateLineText(keyed.kind, spec.phase), { to: 'debug' })
512
+ }
513
+ return null
514
+ }
515
+
516
+ /** The results kept for a change, `null` being the unnamed slot the pane never draws (design D2). */
517
+ function changeSlot(name) {
518
+ if (!spec.changes.has(name)) spec.changes.set(name, {})
519
+ return spec.changes.get(name)
520
+ }
521
+
522
+ /**
523
+ * Files one keyed segment's entry (design D2, D3, D7): the change it names
524
+ * becomes the one drawn, its result is kept under that change (or the current
525
+ * one), and the stage is the entry. A checkpoint ends the run at its checkpoint.
526
+ */
527
+ function fileEntry(entry, now) {
528
+ if (entry.name) {
529
+ spec.current = entry.name
530
+ if (!spec.order.includes(entry.name)) spec.order.push(entry.name)
531
+ }
532
+ if (entry.kind === 'naming-only') return
533
+ const slot = changeSlot(entry.name || spec.current)
534
+ if (entry.kind === 'drift') spec.drift = entry
535
+ else if (entry.kind === 'autonomy') spec.autonomy = entry.command
536
+ else if (TEXT_KINDS.includes(entry.kind)) slot[entry.kind] = entry
537
+ if (entry.kind === 'checkpoint') {
538
+ spec.phase = 'checkpoint'
539
+ spec.checkpointCrossed = true
540
+ spec.checkpointAt = now
541
+ }
542
+ spec.stage = entry
543
+ }
544
+
545
+ /**
546
+ * Reads a command's keyed segments into the spec record (design D3). Every
547
+ * entry is read before any is filed, so a reader that throws leaves the record
548
+ * as it was. Two segments whose results are read share one output, so each is
549
+ * kept unparsed. An unparsed result leaves one debug line. Never throws.
550
+ */
551
+ async function applySpecLines($, held, lines, r) {
552
+ try {
553
+ const texts = lines.filter(l => TEXT_KINDS.includes(l.kind)).length
554
+ const text = r && typeof r.text === 'string' ? r.text : ''
555
+ const entries = lines.map(({ kind, segment }) => {
556
+ if (kind === 'naming-only') return { entry: { kind, name: changeOf(kind, segment, null) }, segment }
557
+ if (texts > 1 && TEXT_KINDS.includes(kind)) {
558
+ return { entry: { ...unparsedEntry(kind, text, 'compound command'), name: changeOf(kind, segment, null) }, segment }
559
+ }
560
+ return { entry: readResult(kind, segment, r), segment }
561
+ })
562
+ const now = await specNow($)
563
+ if (held !== spec || !specWatching()) return
564
+ for (const { entry, segment } of entries) {
565
+ if (!entry) continue
566
+ fileEntry(entry, now)
567
+ if (typeof entry.unparsed === 'string') $.ui.log(unreadLineText(entry, segment), { to: 'debug' })
568
+ }
569
+ if (now !== null) spec.lastActivityAt = now
570
+ redrawSpec($)
571
+ } catch (err) {
572
+ $.ui.log(`interlock spec: a keyed line was not read: ${messageOf(err)}`, { to: 'debug' })
573
+ }
574
+ }
575
+
576
+ /** A write during a live spec run, filed by its path (design D4). A path never names the change. Never throws. */
577
+ async function fileWrite($, path) {
578
+ try {
579
+ const w = writeOf(path)
580
+ if (!w) return
581
+ const held = spec
582
+ const now = await specNow($)
583
+ if (held !== spec) return
584
+ if (w.kind === 'artifact') {
585
+ if (!spec.writes.has(w.change)) spec.writes.set(w.change, new Map())
586
+ const files = spec.writes.get(w.change)
587
+ const before = files.get(w.file)
588
+ files.set(w.file, { count: (before ? before.count : 0) + 1, at: now === null && before ? before.at : now })
589
+ } else if (w.kind === 'brief') spec.explore.brief = w.path
590
+ else if (w.kind === 'findings') spec.findings.set(w.change, w.path)
591
+ if (now !== null) spec.lastActivityAt = now
592
+ $.ui.invalidate('ui.render')
593
+ } catch (err) {
594
+ $.ui.log(`interlock spec: a write was not filed: ${messageOf(err)}`, { to: 'debug' })
595
+ }
596
+ }
597
+
598
+ /** What an `agent.spawn` chain returned, read for the meter and handed back unchanged. */
599
+ async function noteSpawn($, input, spawned) {
600
+ if (session.interactive && run.phase === 'live') {
601
+ if (spawned && str(spawned.agentId) && input && typeof input.prompt === 'string') {
602
+ const found = [...input.prompt.matchAll(BRIEFING_SHA)]
603
+ if (found.length) agentOf(spawned.agentId).sha = found[found.length - 1][1]
604
+ }
605
+ if (await stamp($)) redrawWord($)
606
+ }
607
+ // An Explore investigator the chain let through (observe-the-spec-run-live design D5).
608
+ if (specWatching() && input && input.subagentType === 'Explore' && spawned && typeof spawned.model === 'string') {
609
+ await countInvestigator($)
610
+ }
611
+ return spawned
612
+ }
613
+
614
+ /**
615
+ * A clear, resume or fork ends the run the meter holds, before the chain
616
+ * beneath runs. Any other source, and a non-interactive session, changes
617
+ * nothing. Never throws.
618
+ */
619
+ function endRunAtBoundary($, input) {
620
+ if (!session.interactive) return
621
+ try {
622
+ const boundary = BOUNDARIES.get(input.source)
623
+ if (!boundary) return
624
+ const ended = run
625
+ stopTick()
626
+ run = freshRun()
627
+ start = freshStart()
628
+ spec = freshSpec({ afterBoundary: true })
629
+ setStatus($)
630
+ $.ui.invalidate('ui.render')
631
+ const what = ended.phase === 'idle' ? 'no run was held' : `run ${ended.runId || '(no id)'} is no longer drawn`
632
+ $.ui.log(`interlock meter: ${boundary} started the session over; ${what}`, { to: 'debug' })
633
+ } catch (err) {
634
+ $.ui.log(`interlock meter: the session boundary was not applied: ${messageOf(err)}`, { to: 'debug' })
635
+ }
636
+ }
637
+
638
+ /** Once the chain beneath has run: the preflight's report, read for every source. */
639
+ async function readPreflightAfterStart($, input) {
640
+ if (session.interactive) await readPreflight($, str(input.cwd) || session.cwd)
641
+ }
642
+
643
+ /** One Explore investigator the chain let through (design D5). Never throws. */
644
+ async function countInvestigator($) {
645
+ try {
646
+ spec.explore.spawned++
647
+ await specStamp($)
648
+ redrawSpec($)
649
+ } catch (err) {
650
+ $.ui.log(`interlock spec: an investigator was not counted: ${messageOf(err)}`, { to: 'debug' })
651
+ }
652
+ }
653
+
654
+ /** `/interlock-spec` (design D6): the pure module's keyed lines on `Box` and `Text`, and nothing else. */
655
+ function drawSpecPane($, e) {
656
+ const { Box, Text } = $.ui.resolve(e)
657
+ const props = l => (l.bold ? { bold: true } : l.dim ? { dimColor: true } : null)
658
+ return h(Box, { flexDirection: 'column' }, ...specPaneLines(spec).map(l => h(Box, { key: l.key }, h(Text, props(l), l.text))))
659
+ }
660
+
661
+ /** The launch guard's ruling now, over the record its branch would read, in the rule's words (design D5). */
662
+ async function launchGuardText($) {
663
+ const { record, now } = await guardFacts($)
664
+ const ruling = decideLaunch(record, now)
665
+ return launchGuardLine(ruling, now)
666
+ }
667
+
266
668
  /** Names, once per run record, a step that crossed with no live run (design D4). */
267
669
  function nameIdleStep($) {
268
670
  run.idleStepNamed = true
@@ -277,12 +679,17 @@ function nameIdleStep($) {
277
679
  async function idleStep($, e, next) {
278
680
  if (run.phase !== 'idle' || run.idleStepNamed) return next(e)
279
681
  const r = await next(e)
682
+ noteIdleStep($, r)
683
+ return r
684
+ }
685
+
686
+ /** Names the first step record a result carries while no run is live; reads nothing else. */
687
+ function noteIdleStep($, r) {
280
688
  try {
281
- if (readRecord(r.text).record && run.phase === 'idle' && !run.idleStepNamed) nameIdleStep($)
689
+ if (run.phase === 'idle' && !run.idleStepNamed && readRecord(r.text).record) nameIdleStep($)
282
690
  } catch {
283
691
  // Reading the result is all this does; the call and its result are the engine's.
284
692
  }
285
- return r
286
693
  }
287
694
 
288
695
  /** The step record in a Bash result's text, guarded as the driver guards it: an object with a string `action`. */
@@ -302,7 +709,19 @@ function readRecord(text) {
302
709
  function agentOf(id) {
303
710
  let a = run.agents.get(id)
304
711
  if (!a) {
305
- a = { id, requests: 0, known: 0, partial: false, sums: null, models: [], reason: null, durationMs: null, sha: null }
712
+ a = {
713
+ id,
714
+ requests: 0,
715
+ known: 0,
716
+ partial: false,
717
+ sums: null,
718
+ models: [],
719
+ reason: null,
720
+ durationMs: null,
721
+ sha: null,
722
+ // Whether its turn end has been spoken: once per agent per run (speak-lane-turn-ends design D1).
723
+ endToasted: false
724
+ }
306
725
  run.agents.set(id, a)
307
726
  }
308
727
  return a
@@ -323,6 +742,76 @@ function addUsage(a, usage) {
323
742
  if (str(usage.model) && !a.models.includes(usage.model)) a.models.push(usage.model)
324
743
  }
325
744
 
745
+ /** What a malformed field is, for its debug line: never its content. */
746
+ const shapeOf = value => (value === null ? 'null' : Array.isArray(value) ? 'an array' : `a ${typeof value}`)
747
+
748
+ /** A plan summary as the run program states it: an object with an array of waves. */
749
+ const isPlanSummary = value => value !== null && typeof value === 'object' && Array.isArray(value.waves)
750
+
751
+ /** A `recorded` block as the run program states it: three lists of ids. */
752
+ const isRecordedIds = value =>
753
+ value !== null &&
754
+ typeof value === 'object' &&
755
+ ['ok', 'failed', 'notAttempted'].every(list => Array.isArray(value[list]))
756
+
757
+ /** The summary's wave at a position, as a failure record names it: its group and its kind. */
758
+ function waveAt(plan, index) {
759
+ if (!plan || !isInt(index)) return { wave: null, waveKind: null }
760
+ const wave = plan.waves[index]
761
+ if (wave && typeof wave === 'object') return { wave: wave.group ?? null, waveKind: str(wave.kind) }
762
+ return plan.testWave && index === plan.waves.length ? { wave: null, waveKind: 'test' } : { wave: null, waveKind: null }
763
+ }
764
+
765
+ /**
766
+ * What the wave board keeps from a step (draw-the-wave-board-in-the-meter-pane
767
+ * design D6). A `plan` or `recorded` without the shape the run program states
768
+ * is named once per run on the debug log and ignored; the rest of the step
769
+ * applies. The recorded ids are taken before the step's own position, because
770
+ * the step record-batch returns already names the next batch.
771
+ */
772
+ function takeBoard($, record) {
773
+ if (record.plan !== undefined) {
774
+ if (isPlanSummary(record.plan)) run.plan = record.plan
775
+ else if (!run.planNamed) {
776
+ run.planNamed = true
777
+ $.ui.log(`interlock meter: a step's plan is ${shapeOf(record.plan)}, not a plan summary; ignored, the board keeps what it held`, {
778
+ to: 'debug'
779
+ })
780
+ }
781
+ }
782
+ if (record.recorded !== undefined) {
783
+ if (isRecordedIds(record.recorded)) {
784
+ for (const list of ['ok', 'failed', 'notAttempted']) {
785
+ for (const id of record.recorded[list]) {
786
+ if (!str(id) || run.recorded[list].includes(id)) continue
787
+ run.recorded[list].push(id)
788
+ if (list === 'failed') run.failedAt.set(id, waveAt(run.plan, run.dispatched && run.dispatched.waveIndex))
789
+ }
790
+ }
791
+ } else if (!run.recordedNamed) {
792
+ run.recordedNamed = true
793
+ $.ui.log(
794
+ `interlock meter: a step's recorded is ${shapeOf(record.recorded)}, not three id lists; ignored, the board keeps the ids it held`,
795
+ { to: 'debug' }
796
+ )
797
+ }
798
+ }
799
+ if (isInt(record.waveIndex)) {
800
+ const at = { waveIndex: record.waveIndex, batchIndex: isInt(record.batchIndex) ? record.batchIndex : null }
801
+ if (BATCH_ACTIONS.has(record.action)) {
802
+ run.cursor = { ...at, phase: 'batch' }
803
+ run.dispatched = at
804
+ } else if (record.action === 'verify') {
805
+ run.cursor = { ...at, phase: 'verify' }
806
+ const reason = str(record.reason)
807
+ const seen = run.skips.some(skip => skip.waveIndex === at.waveIndex && skip.reason === reason)
808
+ if (record.skipped === true && !seen) run.skips.push({ waveIndex: at.waveIndex, wave: record.wave ?? null, reason })
809
+ }
810
+ }
811
+ // The close record repeats the action without the reason; the halt step's stands.
812
+ if (record.action === 'halt') run.halt = { reason: str(record.reason) || (run.halt ? run.halt.reason : null) }
813
+ }
814
+
326
815
  async function applyRecord($, record) {
327
816
  const held = run
328
817
  await stamp($)
@@ -341,9 +830,12 @@ async function applyRecord($, record) {
341
830
  kind: str(s.kind),
342
831
  model: str(s.model),
343
832
  effort: str(s.effort),
344
- sha: str(s.promptSha256)
833
+ sha: str(s.promptSha256),
834
+ // Spawned by a step that dispatches a batch: a lane, planned or not.
835
+ batch: BATCH_ACTIONS.has(record.action)
345
836
  })
346
837
  }
838
+ takeBoard($, record)
347
839
  for (const banner of Array.isArray(record.banners) ? record.banners : []) {
348
840
  if (typeof banner !== 'string' || !banner) continue
349
841
  if (!run.banners.includes(banner)) run.banners.push(banner)
@@ -357,19 +849,112 @@ async function applyRecord($, record) {
357
849
  run.exitCode = record.exitCode
358
850
  run.phase = 'closed'
359
851
  stopTick()
360
- $.ui.status(undefined)
361
852
  } else if (record.action === 'close' || record.action === 'halt') {
362
853
  run.phase = 'closing'
363
854
  stopTick()
364
- $.ui.status(undefined)
365
- } else {
366
- $.ui.status(statusText())
367
855
  }
856
+ setStatus($)
368
857
  $.ui.invalidate('ui.render')
369
858
  }
370
859
 
371
- async function openPane($) {
372
- return $.ui.open({ id: PANE, title: TITLE, closeOnEscape: true })
860
+ async function openPane($, id = PANE, title = TITLE) {
861
+ return $.ui.open({ id, title, closeOnEscape: true })
862
+ }
863
+
864
+ /**
865
+ * Reads the session start's report (design D5) and holds what `adoptPreflight`
866
+ * keeps: the same report changes nothing, a new one is drawn, and a file that
867
+ * cannot be read or is not a report is named once on the debug log. Never
868
+ * throws: a read that rejects is the event's to carry on past.
869
+ */
870
+ async function readPreflight($, cwd) {
871
+ const path = preflightFilePath(cwd)
872
+ let outcome
873
+ try {
874
+ outcome = readPreflightReport(await $.fs.read(path))
875
+ } catch (err) {
876
+ outcome = { problem: messageOf(err) }
877
+ }
878
+ try {
879
+ const { start: next, changed, log } = adoptPreflight(start, outcome, path)
880
+ start = next
881
+ if (log) $.ui.log(log, { to: 'debug' })
882
+ if (changed) $.ui.invalidate('ui.render')
883
+ } catch (err) {
884
+ $.ui.log(`interlock meter: the preflight report was not held: ${messageOf(err)}`, { to: 'debug' })
885
+ }
886
+ }
887
+
888
+ /** One keyed row per line, so the kit and the person both find each line on its own. */
889
+ function rowsOf($, e, prefix, lines) {
890
+ const { Box, Text } = $.ui.resolve(e)
891
+ return lines.map((line, n) => h(Box, { key: `${prefix}-${n}` }, h(Text, null, line)))
892
+ }
893
+
894
+ /**
895
+ * The band (design D6): the report's lines above whatever the other plugins
896
+ * drew, and a Hide control that collapses it until a new session start's
897
+ * report. It yields to a survey, and draws nothing of its own with no report,
898
+ * a hidden band, or a report with nothing to say.
899
+ */
900
+ async function drawBand($, e, next) {
901
+ if (!session.interactive || (e.props && e.props.hasSurvey) || start.hidden || !start.report) return next(e)
902
+ const lines = bandLines(start.report)
903
+ if (!lines.length) return next(e)
904
+ const beneath = await next(e)
905
+ try {
906
+ const { Box, Button } = $.ui.resolve(e)
907
+ const hide = () => {
908
+ start.hidden = true
909
+ $.ui.invalidate('ui.render')
910
+ }
911
+ return h(
912
+ Box,
913
+ { flexDirection: 'column' },
914
+ ...rowsOf($, e, 'preflight', lines),
915
+ h(Button, { key: 'hide', label: 'Hide', onPress: hide }),
916
+ ...(beneath ? [beneath] : [])
917
+ )
918
+ } catch (err) {
919
+ $.ui.log(`interlock meter: the session-start band was not drawn: ${messageOf(err)}`, { to: 'debug' })
920
+ return beneath
921
+ }
922
+ }
923
+
924
+ /** `/interlock-preflight`: the report's every line, or why there is none (design D6). */
925
+ function drawPreflightPane($, e) {
926
+ const { Box } = $.ui.resolve(e)
927
+ const lines = start.report
928
+ ? preflightPaneLines(start.report)
929
+ : [noReportText(start.path || preflightFilePath(session.cwd), start.problem)]
930
+ return h(Box, { flexDirection: 'column' }, ...rowsOf($, e, 'preflight-pane', lines))
931
+ }
932
+
933
+ /**
934
+ * `/interlock-handoff` (design D6): each card the report lists, newest first,
935
+ * read from its path now and rendered verbatim through `Markdown`, cut to the
936
+ * published cap. A card that cannot be read is named with the reason, and the
937
+ * next is drawn as before. Nothing here acts on a card.
938
+ */
939
+ async function drawHandoffPane($, e) {
940
+ const { Box, Text, Markdown } = $.ui.resolve(e)
941
+ const column = rows => h(Box, { flexDirection: 'column' }, ...rows)
942
+ if (!start.report) {
943
+ return column(rowsOf($, e, 'handoff', [noReportText(start.path || preflightFilePath(session.cwd), start.problem)]))
944
+ }
945
+ const cards = orderCards(start.report)
946
+ if (!cards.length) return column(rowsOf($, e, 'handoff', noCardText(start.report)))
947
+ const out = []
948
+ for (const [n, card] of cards.entries()) {
949
+ out.push(h(Box, { key: `card-${n}` }, h(Text, null, cardLine(card))))
950
+ try {
951
+ const text = await $.fs.read(card.path)
952
+ out.push(h(Box, { key: `card-${n}-body` }, h(Markdown, { text: clampCard(text, card.path).text })))
953
+ } catch (err) {
954
+ out.push(h(Box, { key: `card-${n}-unread` }, h(Text, null, `${card.path}: cannot be read: ${messageOf(err)}`)))
955
+ }
956
+ }
957
+ return column(out)
373
958
  }
374
959
 
375
960
  function tokensText(a) {
@@ -388,12 +973,38 @@ function agentForSha(sha) {
388
973
  return null
389
974
  }
390
975
 
391
- function laneForAgent(a) {
976
+ function rowForAgent(a) {
392
977
  if (!a.sha) return null
393
- for (const r of run.rows.values()) if (r.sha === a.sha) return r.label
978
+ for (const r of run.rows.values()) if (r.sha === a.sha) return r
394
979
  return null
395
980
  }
396
981
 
982
+ function laneForAgent(a) {
983
+ const r = rowForAgent(a)
984
+ return r ? r.label : null
985
+ }
986
+
987
+ /**
988
+ * The session lines (speak-lane-turn-ends-and-session-cost design D4, D6): the
989
+ * engine's own figures as it answered them, each said to be unreported where
990
+ * it is absent. Nothing here is computed: no percent from the tokens, no price.
991
+ */
992
+ function sessionLines(usage, failure) {
993
+ if (failure !== null) return [`context unavailable: ${failure}`, `session cost unavailable: ${failure}`]
994
+ const num = v => (typeof v === 'number' && Number.isFinite(v) ? v : null)
995
+ const context = usage.context && typeof usage.context === 'object' ? usage.context : {}
996
+ const [tokens, size, percent] = [num(context.tokens), num(context.window), num(context.percent)]
997
+ let fill = 'context not reported'
998
+ if (size !== null && tokens === null) fill = `context: window ${size} tokens · fill not yet reported`
999
+ else if (size !== null) fill = `context ${tokens} of ${size} tokens${percent !== null ? ` · ${percent}% used` : ''}`
1000
+ const usd = usage.cost && typeof usage.cost === 'object' ? num(usage.cost.usd) : null
1001
+ const cost =
1002
+ usd === null
1003
+ ? 'session cost not reported by this host'
1004
+ : `session cost $${usd.toFixed(4)} · the engine's total for this session, not the run's`
1005
+ return [fill, cost]
1006
+ }
1007
+
397
1008
  /**
398
1009
  * `last activity <ISO>` or `last activity unknown`, then the quiet word while a
399
1010
  * live run is quiet. The word is recomputed from the clock at each draw, so a
@@ -406,11 +1017,211 @@ async function lastActivityText($) {
406
1017
  return `last activity ${at}${word ? ` · ${word}` : ''}`
407
1018
  }
408
1019
 
1020
+ /** One spawn as a flat row, as the section drew every spawn before the board. */
1021
+ function flatRow(keyed, r) {
1022
+ const a = agentForSha(r.sha)
1023
+ const served = a && a.models.length ? a.models.join(', ') : '?'
1024
+ const state = a ? a.reason || 'running' : 'waiting'
1025
+ return keyed(
1026
+ `wave-row-${r.label}`,
1027
+ `${r.title} · ${r.kind || 'agent'} · routed ${r.model || '?'} · served ${served} · effort ${r.effort || '?'} · ${state}`
1028
+ )
1029
+ }
1030
+
1031
+ /**
1032
+ * The overlay the renderer reads in place of a wave state, built from the
1033
+ * steps alone (design D6). The wave list is the summary's own, so the renderer
1034
+ * never falls back to ids for a count the module made disagree.
1035
+ */
1036
+ function boardOverlay(plan) {
1037
+ return {
1038
+ waves: [...plan.waves, ...(plan.testWave ? [plan.testWave] : [])],
1039
+ cursor: run.cursor,
1040
+ completed: run.recorded.ok,
1041
+ failures: run.recorded.failed.map(id => ({ id, ...(run.failedAt.get(id) || waveAt(null, null)), error: null })),
1042
+ skippedVerifications: run.skips,
1043
+ unresolved: [],
1044
+ halt: run.halt
1045
+ }
1046
+ }
1047
+
1048
+ /** What the host observed of each spawned lane: its served models and, once joined to its agent, its turn. */
1049
+ function laneNotes() {
1050
+ const notes = {}
1051
+ for (const r of run.rows.values()) {
1052
+ const a = agentForSha(r.sha)
1053
+ const served = a && a.models.length ? a.models.join(', ') : '?'
1054
+ notes[r.label] = `served ${served}${a ? ` · ${a.reason || 'running'}` : ''}`
1055
+ }
1056
+ return notes
1057
+ }
1058
+
1059
+ /** The pane key a board row is drawn under: a lane keeps the flat row's key (design D7). */
1060
+ function boardKey(key) {
1061
+ if (key.startsWith('lane:')) return `wave-row-${key.slice('lane:'.length)}`
1062
+ if (key === 'header' || key === 'tail') return `wave-board-${key}`
1063
+ const [kind, ...rest] = key.split(':')
1064
+ return `${kind === 'wave' ? 'wave-block' : `wave-${kind}`}-${rest.join('-')}`
1065
+ }
1066
+
1067
+ /** Colour only a state word the board printed: hue for ok/failed/current, dim for the rest. */
1068
+ function stateProps(word) {
1069
+ if (word === 'ok') return { color: 'green' }
1070
+ if (word === 'failed') return { color: 'red' }
1071
+ if (word === 'current') return { color: 'yellow' }
1072
+ if (word === 'pending' || word === 'not reached' || word === 'not recorded' || word === 'per task') {
1073
+ return { dimColor: true }
1074
+ }
1075
+ return null
1076
+ }
1077
+
1078
+ /** The width a card and the renderer share: the body, capped at the published default. */
1079
+ function boardColumns(e) {
1080
+ const body = e.props && isInt(e.props.bodyColumns) && e.props.bodyColumns > 0
1081
+ ? e.props.bodyColumns
1082
+ : LIMITS.waveBoardDefaultColumns
1083
+ return body < LIMITS.waveBoardMinColumns ? body : Math.min(body, LIMITS.waveBoardDefaultColumns)
1084
+ }
1085
+
1086
+ function waveTitleOf(row) {
1087
+ if (row.parts && typeof row.parts.title === 'string' && row.parts.title) return row.parts.title
1088
+ const text = row.text || ''
1089
+ if (!text.startsWith('┌─ ')) return text
1090
+ const rest = text.slice(3)
1091
+ const cut = rest.search(/ ─/)
1092
+ return (cut === -1 ? rest : rest.slice(0, cut)).trimEnd()
1093
+ }
1094
+
1095
+ /** A lane's ids cell, as spans: each task's own state word coloured when its tasks disagree. */
1096
+ function idSpans(Text, parts) {
1097
+ if (parts.state === 'per task' && Array.isArray(parts.tasks) && parts.tasks.length) {
1098
+ const spans = [' [']
1099
+ parts.tasks.forEach((task, i) => {
1100
+ if (i) spans.push(', ')
1101
+ if (!task.word) return spans.push(task.id)
1102
+ spans.push(`${task.id} `, h(Text, stateProps(task.word), task.word))
1103
+ })
1104
+ spans.push(']')
1105
+ return spans
1106
+ }
1107
+ return parts.ids ? [` [${parts.ids}]`] : []
1108
+ }
1109
+
1110
+ /**
1111
+ * A lane in its card: the fixed cells as one Text, the gist wrapping beneath,
1112
+ * then the host's note, dim. The coloured words are spans nested in that one
1113
+ * Text, because a row of separate elements shrinks each into its own column
1114
+ * and breaks words apart once the line is wider than the card.
1115
+ */
1116
+ function drawLane($, e, row) {
1117
+ const { Box, Text } = $.ui.resolve(e)
1118
+ const key = boardKey(row.key)
1119
+ const parts = row.parts
1120
+ if (!parts) return h(Box, { key }, h(Text, { wrap: 'truncate-end' }, row.text))
1121
+ const head = [parts.batch, parts.label, parts.model, parts.tier, parts.effort].filter(Boolean).join(' ')
1122
+ const line = [
1123
+ ...(parts.state ? [`${head} `, h(Text, stateProps(parts.state), parts.state)] : [head]),
1124
+ ...idSpans(Text, parts),
1125
+ ...(Array.isArray(parts.after) && parts.after.length ? [` ←${parts.after.join(',')}`] : [])
1126
+ ]
1127
+ const kids = [
1128
+ h(Text, { wrap: 'wrap' }, ...line),
1129
+ parts.gist ? h(Box, { key: `${key}-gist` }, h(Text, { wrap: 'wrap' }, parts.gist)) : null,
1130
+ parts.note ? h(Box, { key: `${key}-note` }, h(Text, { dimColor: true }, parts.note)) : null
1131
+ ].filter(Boolean)
1132
+ return h(Box, { key, flexDirection: 'column' }, ...kids)
1133
+ }
1134
+
1135
+ function drawBoardCards($, e, board, width) {
1136
+ const { Box, Text } = $.ui.resolve(e)
1137
+ const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
1138
+ const out = []
1139
+ let index = null
1140
+ let kids = null
1141
+ const flush = () => {
1142
+ if (index === null) return
1143
+ out.push(
1144
+ h(
1145
+ Box,
1146
+ {
1147
+ key: `wave-card-${index}`,
1148
+ flexDirection: 'column',
1149
+ borderStyle: 'single',
1150
+ borderDimColor: true,
1151
+ width,
1152
+ paddingLeft: 1,
1153
+ paddingRight: 1,
1154
+ marginBottom: 1
1155
+ },
1156
+ ...kids
1157
+ )
1158
+ )
1159
+ index = null
1160
+ kids = null
1161
+ }
1162
+ for (const row of board) {
1163
+ if (row.key.startsWith('wave:') && row.key.endsWith(':end')) continue
1164
+ if (row.key.startsWith('wave:')) {
1165
+ flush()
1166
+ index = row.key.slice('wave:'.length)
1167
+ kids = [h(Box, { key: boardKey(row.key) }, h(Text, { bold: true }, waveTitleOf(row)))]
1168
+ continue
1169
+ }
1170
+ if (row.key.startsWith('lane:')) {
1171
+ const lane = drawLane($, e, row)
1172
+ if (kids) kids.push(lane)
1173
+ else out.push(lane)
1174
+ continue
1175
+ }
1176
+ flush()
1177
+ out.push(keyed(boardKey(row.key), row.text, row.key === 'header' || row.key === 'tail' ? null : { wrap: 'truncate-end' }))
1178
+ }
1179
+ flush()
1180
+ return out
1181
+ }
1182
+
1183
+ /**
1184
+ * The waves section (design D7, D8): the board once a plan summary has
1185
+ * crossed, one bordered card per wave at the body width capped at the board's
1186
+ * default, the rows between cards one truncating text each; then a flat
1187
+ * row for each spawn no planned lane carries. Before a summary, the flat rows,
1188
+ * beneath a spoken line once a batch was dispatched. Below the board's minimum
1189
+ * the renderer's spoken line stands in for it, and a renderer that throws is
1190
+ * named here and on the debug log. Everything drawn is the steps'.
1191
+ */
1192
+ function drawWaves($, e) {
1193
+ const { Box, Text } = $.ui.resolve(e)
1194
+ const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
1195
+ const line = (key, text) => keyed(key, text, { wrap: 'truncate-end' })
1196
+ const rows = [...run.rows.values()]
1197
+ if (!run.plan) {
1198
+ if (!rows.length) return [h(Text, { dimColor: true }, NO_LANES_LINE)]
1199
+ const dispatched = run.dispatched !== null || rows.some(r => r.batch)
1200
+ return [...(dispatched ? [keyed('wave-board-unrelayed', UNRELAYED_LINE)] : []), ...rows.map(r => flatRow(keyed, r))]
1201
+ }
1202
+ const width = boardColumns(e)
1203
+ let board
1204
+ try {
1205
+ board = drawPlanBoardRows(run.plan, { columns: width, state: boardOverlay(run.plan), notes: laneNotes() })
1206
+ } catch (err) {
1207
+ $.ui.log(`interlock meter: ${BOARD_FAILED_LINE}: ${messageOf(err)}`, { to: 'debug' })
1208
+ return [keyed('wave-board-failed', BOARD_FAILED_LINE), ...rows.map(r => flatRow(keyed, r))]
1209
+ }
1210
+ // Every board ends in its tail; the renderer's spoken line for a narrow body
1211
+ // is one row without one, and the lanes are then drawn flat beneath it.
1212
+ const drawn = board.length > 0 && board[board.length - 1].key === 'tail'
1213
+ const planned = new Set(drawn ? board.filter(row => row.key.startsWith('lane:')).map(row => row.key.slice('lane:'.length)) : [])
1214
+ const body = drawn ? drawBoardCards($, e, board, width) : board.map(row => line(boardKey(row.key), row.text))
1215
+ return [...body, ...rows.filter(r => !planned.has(r.label)).map(r => flatRow(keyed, r))]
1216
+ }
1217
+
409
1218
  async function drawPane($, e) {
410
1219
  const { Box, Text } = $.ui.resolve(e)
411
1220
  const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
1221
+ const heading = text => h(Box, { marginTop: 1 }, h(Text, { bold: true }, text))
412
1222
  if (run.phase === 'idle' && !run.summary) {
413
- return h(Box, { flexDirection: 'column' }, h(Text, { dimColor: true }, NO_RUN_LINE))
1223
+ // The guard runs in every session, so its line stands here too (design D5).
1224
+ return h(Box, { flexDirection: 'column' }, h(Text, { dimColor: true }, NO_RUN_LINE), keyed('launch-guard', await launchGuardText($)))
414
1225
  }
415
1226
 
416
1227
  const out = []
@@ -419,50 +1230,51 @@ async function drawPane($, e) {
419
1230
  out.push(keyed('last-activity', await lastActivityText($)))
420
1231
  out.push(h(Text, { dimColor: true }, RECORD_LINE))
421
1232
 
422
- out.push(h(Text, { bold: true }, 'waves'))
423
- if (!run.rows.size) out.push(h(Text, { dimColor: true }, 'no lanes dispatched yet'))
424
- for (const r of run.rows.values()) {
425
- const a = agentForSha(r.sha)
426
- const served = a && a.models.length ? a.models.join(', ') : '?'
427
- const state = a ? a.reason || 'running' : 'waiting'
428
- out.push(
429
- keyed(
430
- `wave-row-${r.label}`,
431
- `${r.title} · ${r.kind || 'agent'} · routed ${r.model || '?'} · served ${served} · effort ${r.effort || '?'} · ${state}`
432
- )
433
- )
434
- }
1233
+ out.push(heading('waves'))
1234
+ out.push(...drawWaves($, e))
435
1235
 
436
- out.push(h(Text, { bold: true }, 'agents'))
1236
+ out.push(heading('agents'))
437
1237
  if (!run.agents.size) out.push(h(Text, { dimColor: true }, 'no agent has reported a request yet'))
438
1238
  for (const a of run.agents.values()) {
439
1239
  const lane = laneForAgent(a)
440
1240
  const n = a.requests === 1 ? '1 request' : `${a.requests} requests`
441
1241
  const done = a.reason ? `${a.reason}${a.durationMs !== null ? ` ${(a.durationMs / 1000).toFixed(1)}s` : ''}` : 'running'
442
1242
  out.push(
443
- keyed(
444
- `agent-${a.id}`,
445
- `${lane || 'lane unknown'} · ${a.id} · ${a.models.join(', ') || 'model not reported'} · ${n} · ${tokensText(a)} · ${done}`
1243
+ h(
1244
+ Box,
1245
+ { key: `agent-${a.id}`, flexDirection: 'column' },
1246
+ h(Box, null, h(Text, null, `${lane || 'lane unknown'} · ${a.id} · ${a.models.join(', ') || 'model not reported'} · ${done}`)),
1247
+ h(Box, null, h(Text, { dimColor: true }, `${n} · ${tokensText(a)}`))
446
1248
  )
447
1249
  )
448
1250
  }
449
1251
 
450
- out.push(h(Text, { bold: true }, 'plan windows'))
451
- let windows = []
1252
+ // One plain read feeds the session lines and the plan windows; a breakdown
1253
+ // is never asked for, because only the plain call is free (design D4).
1254
+ let usage = null
1255
+ let failure = null
452
1256
  try {
453
- const usage = await $.session.usage()
454
- windows = usage && Array.isArray(usage.rateLimits) ? usage.rateLimits : []
455
- } catch {
456
- windows = []
1257
+ usage = await $.session.usage()
1258
+ if (!usage || typeof usage !== 'object') throw new Error(`the usage answered ${String(usage)}`)
1259
+ } catch (err) {
1260
+ failure = messageOf(err)
457
1261
  }
458
- if (!windows.length) out.push(h(Text, { dimColor: true }, 'none reported (off a subscription the session has none)'))
1262
+ const [contextLine, costLine] = sessionLines(usage, failure)
1263
+ out.push(heading('session'))
1264
+ out.push(keyed('session-context', contextLine))
1265
+ out.push(keyed('session-cost', costLine))
1266
+
1267
+ out.push(heading('plan windows'))
1268
+ const windows = usage && Array.isArray(usage.rateLimits) ? usage.rateLimits : []
1269
+ if (failure !== null) out.push(h(Text, { dimColor: true }, `plan windows unavailable: ${failure}`))
1270
+ else if (!windows.length) out.push(h(Text, { dimColor: true }, 'none reported (off a subscription the session has none)'))
459
1271
  for (const w of windows) {
460
1272
  if (!w || typeof w !== 'object') continue
461
1273
  const kind = String(w.kind)
462
1274
  out.push(keyed(`plan-window-${kind}`, `${kind} ${w.percentUsed}% used${w.resetsAt ? ` · resets ${w.resetsAt}` : ''}`))
463
1275
  }
464
1276
 
465
- out.push(h(Text, { bold: true }, 'banners'))
1277
+ out.push(heading('banners'))
466
1278
  out.push(
467
1279
  h(
468
1280
  Box,
@@ -471,8 +1283,13 @@ async function drawPane($, e) {
471
1283
  )
472
1284
  )
473
1285
 
1286
+ out.push(heading('refusals'))
1287
+ const denials = guardDenialLine(run.guardDenials)
1288
+ if (denials) out.push(keyed('guard-denials', denials))
1289
+ out.push(keyed('launch-guard', await launchGuardText($)))
1290
+
474
1291
  if (run.summary) {
475
- out.push(h(Text, { bold: true }, 'close summary'))
1292
+ out.push(heading('close summary'))
476
1293
  out.push(h(Box, { key: 'summary', flexDirection: 'column' }, h(Text, null, run.summary)))
477
1294
  }
478
1295
  return h(Box, { flexDirection: 'column' }, ...out)
@@ -480,9 +1297,11 @@ async function drawPane($, e) {
480
1297
 
481
1298
  export function register(on) {
482
1299
  on('session.start', async ($, e, next) => {
483
- session = { interactive: e.isInteractive === true, surface: e.surface || null }
1300
+ session = { interactive: e.isInteractive === true, surface: e.surface || null, cwd: str(e.cwd) }
484
1301
  stopTick()
485
1302
  run = freshRun()
1303
+ start = freshStart()
1304
+ spec = freshSpec()
486
1305
  try {
487
1306
  await $.command.register({
488
1307
  name: PANE,
@@ -492,6 +1311,33 @@ export function register(on) {
492
1311
  } catch (err) {
493
1312
  $.ui.log(`interlock meter: /interlock-meter not registered: ${(err && err.message) || err}`, { to: 'debug' })
494
1313
  }
1314
+ try {
1315
+ await $.command.register({
1316
+ name: PREFLIGHT_PANE,
1317
+ description: "Open this session start's Interlock preflight: the doctor's checks, interrupted runs and halt resume cards",
1318
+ immediate: true
1319
+ })
1320
+ } catch (err) {
1321
+ $.ui.log(`interlock meter: /${PREFLIGHT_PANE} not registered: ${messageOf(err)}`, { to: 'debug' })
1322
+ }
1323
+ try {
1324
+ await $.command.register({
1325
+ name: HANDOFF_PANE,
1326
+ description: 'Open the halt resume cards of open changes, as the session-start preflight listed them',
1327
+ immediate: true
1328
+ })
1329
+ } catch (err) {
1330
+ $.ui.log(`interlock meter: /${HANDOFF_PANE} not registered: ${messageOf(err)}`, { to: 'debug' })
1331
+ }
1332
+ try {
1333
+ await $.command.register({
1334
+ name: SPEC_PANE,
1335
+ description: 'Open the Interlock spec meter: the live /interlock:spec run, its artifact ladder, gates and writes',
1336
+ immediate: true
1337
+ })
1338
+ } catch (err) {
1339
+ $.ui.log(`interlock meter: /${SPEC_PANE} not registered: ${messageOf(err)}`, { to: 'debug' })
1340
+ }
495
1341
  let version = 'unknown'
496
1342
  try {
497
1343
  version = (await $.session.version()).version || 'unknown'
@@ -507,11 +1353,20 @@ export function register(on) {
507
1353
  })
508
1354
 
509
1355
  // A human prompt re-arms the next ship launch; nothing else does (design D3).
510
- // The prompt is passed on as it came: never rewritten, never dropped.
1356
+ // It is also the one event that reaches the module after the session start's
1357
+ // preflight wrote its report, so the report is read here (show-preflight-and-
1358
+ // interrupted-runs-at-session-start design D5). The prompt is passed on as it
1359
+ // came: never rewritten, never dropped. A typed `/interlock:spec` starts the
1360
+ // spec meter's run, read off the prompt's first word before it is passed on,
1361
+ // since the turn it starts runs inside the hand-on (observe-the-spec-run-live
1362
+ // design D1).
511
1363
  on('prompt.submit', async ($, e, next) => {
512
1364
  const kind = e.origin && typeof e.origin.kind === 'string' ? e.origin.kind : 'unstamped'
513
- if (HUMAN_ORIGINS.has(kind)) await changeRecord($, 'human prompt', withPrompt)
514
- else $.ui.log(`${GUARD}: a ${kind} prompt is not a human prompt; not counted`, { to: 'debug' })
1365
+ if (HUMAN_ORIGINS.has(kind)) {
1366
+ await changeRecord($, 'human prompt', withPrompt)
1367
+ if (session.interactive) await readPreflight($, session.cwd)
1368
+ } else $.ui.log(`${GUARD}: a ${kind} prompt is not a human prompt; not counted`, { to: 'debug' })
1369
+ if (session.interactive) await observeLoad($, promptSkill(e.text))
515
1370
  return next(e)
516
1371
  })
517
1372
 
@@ -529,8 +1384,12 @@ export function register(on) {
529
1384
  const { record, now } = await guardFacts($)
530
1385
  const launch = isShipLaunch(e, record)
531
1386
  const verdict = decideLaunch(record, now)
1387
+ // The refusal is spoken to the person in the same words the model reads (design D5).
1388
+ if (launch && session.interactive && verdict.reason) $.ui.toast(verdict.reason)
532
1389
  if (launch && verdict.decision === 'deny') return { deny: verdict.reason }
533
1390
  const r = await next(e)
1391
+ // The settings form's refusal, read off the result: a relaunch comes after its run closed (design D4).
1392
+ if (session.interactive) speakRefusal($, r)
534
1393
  const accepted = launch && r.isError !== true && isAcceptedLaunch(r.result)
535
1394
  if (accepted) {
536
1395
  await changeRecord($, 'ship launch', (current, at) =>
@@ -546,10 +1405,19 @@ export function register(on) {
546
1405
  // The meter: an accepted ship launch makes the run live, its first activity
547
1406
  // at the time the guard read (design D1), and starts its interval.
548
1407
  if (!session.interactive || !accepted) return r
1408
+ // The spec run ends before the ship run is live, so the one status line
1409
+ // passes from one to the other with no second writer (observe-the-spec-run-live design D7).
1410
+ const handing = spec.phase === 'live' || spec.phase === 'checkpoint'
1411
+ if (handing) {
1412
+ spec.phase = 'handed-over'
1413
+ spec.handedOver = true
1414
+ spec.handedOverAt = now
1415
+ }
549
1416
  stopTick()
550
1417
  run = freshRun()
551
1418
  const launched = run
552
1419
  run.phase = 'live'
1420
+ if (handing) setStatus($)
553
1421
  run.runId = str(r.result.runId)
554
1422
  run.workflowName = str(r.result.workflowName)
555
1423
  run.transcriptDir = str(r.result.transcriptDir)
@@ -562,21 +1430,50 @@ export function register(on) {
562
1430
  })
563
1431
 
564
1432
  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
565
- if (!session.interactive || typeof e.command !== 'string' || !DRIVER_LINE.test(e.command)) return next(e)
566
- if (!watching()) return idleStep($, e, next)
1433
+ if (!session.interactive) return next(e)
1434
+ const command = typeof e.command === 'string' ? e.command : ''
1435
+ const driver = DRIVER_LINE.test(command)
1436
+ // The spec flow's keyed lines, read only while a spec run is live (observe-the-spec-run-live design D3).
1437
+ const lines = specLines(command)
1438
+ const reader = lines.length ? specReader($, lines) : null
1439
+ if (!watching()) {
1440
+ if (!reader) return driver ? idleStep($, e, next) : next(e)
1441
+ const r = await next(e)
1442
+ if (driver) noteIdleStep($, r)
1443
+ await applySpecLines($, reader, lines, r)
1444
+ return r
1445
+ }
567
1446
  const held = run
568
1447
  const r = await next(e)
569
- try {
570
- const { record, problem } = readRecord(r.text)
571
- if (!record) $.ui.log(`interlock meter: ${e.command.slice(0, 80)}: not a step record (${problem})`, { to: 'debug' })
572
- else if (held === run) await applyRecord($, record)
573
- else if (run.phase === 'idle' && !run.idleStepNamed) nameIdleStep($)
574
- } catch (err) {
575
- $.ui.log(`interlock meter: step read failed: ${(err && err.message) || err}`, { to: 'debug' })
1448
+ // Any Bash call during the run is read for a guard's refusal (guard-commit); only a driver line for a step.
1449
+ if (held === run) speakRefusal($, r)
1450
+ if (driver) {
1451
+ try {
1452
+ const { record, problem } = readRecord(r.text)
1453
+ if (!record) $.ui.log(`interlock meter: ${command.slice(0, 80)}: not a step record (${problem})`, { to: 'debug' })
1454
+ else if (held === run) await applyRecord($, record)
1455
+ else if (run.phase === 'idle' && !run.idleStepNamed) nameIdleStep($)
1456
+ } catch (err) {
1457
+ $.ui.log(`interlock meter: step read failed: ${(err && err.message) || err}`, { to: 'debug' })
1458
+ }
576
1459
  }
1460
+ if (reader) await applySpecLines($, reader, lines, r)
577
1461
  return r
578
1462
  })
579
1463
 
1464
+ // A Skill tool load, observed after the engine resolved it and returned as
1465
+ // it came: the spec skill starts the spec meter's run (observe-the-spec-run-live design D1).
1466
+ on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
1467
+ if (!session.interactive) return next(e)
1468
+ const r = await next(e)
1469
+ if (!(r && r.isError === true)) await observeLoad($, e.skill)
1470
+ return r
1471
+ })
1472
+
1473
+ // The tools the manifest's `Edit|Write` matcher names for guard-tests and guard-tasks (design D4).
1474
+ on('tool.call', { tool: 'Edit' }, editCall)
1475
+ on('tool.call', { tool: 'Write' }, editCall)
1476
+
580
1477
  on('turn.step', async function* ($, e, next) {
581
1478
  if (!session.interactive || run.phase !== 'live' || !e.agentId) return yield* next(e)
582
1479
  // A run agent's request starting, and its answer arriving, are each activity (design D1).
@@ -597,22 +1494,30 @@ export function register(on) {
597
1494
  const a = agentOf(e.agentId)
598
1495
  a.reason = str(e.reason)
599
1496
  a.durationMs = typeof e.durationMs === 'number' ? e.durationMs : null
1497
+ // A turn the host ended without an answer is spoken once, in the host's
1498
+ // own word: no cause, no verdict. The close summary stays the record
1499
+ // (speak-lane-turn-ends-and-session-cost design D1-D3).
1500
+ if (a.reason && a.reason !== 'answer' && !a.endToasted) {
1501
+ a.endToasted = true
1502
+ const row = rowForAgent(a)
1503
+ try {
1504
+ $.ui.toast(`${row ? row.title : 'lane unknown'} · agent ${a.id} · turn ended: ${a.reason}`)
1505
+ } catch (err) {
1506
+ $.ui.log(`interlock meter: the turn-end toast for ${a.id} was refused: ${messageOf(err)}`, { to: 'debug' })
1507
+ }
1508
+ }
600
1509
  if (await stamp($)) redrawWord($)
601
1510
  $.ui.invalidate('ui.render')
602
1511
  }
603
1512
  return next(e)
604
1513
  })
605
1514
 
606
- on('agent.spawn', async ($, e, next) => {
607
- const r = await next(e)
608
- if (session.interactive && run.phase === 'live') {
609
- if (r && str(r.agentId) && typeof e.prompt === 'string') {
610
- const found = [...e.prompt.matchAll(BRIEFING_SHA)]
611
- if (found.length) agentOf(r.agentId).sha = found[found.length - 1][1]
612
- }
613
- if (await stamp($)) redrawWord($)
614
- }
615
- return r
1515
+ // Pass the spawn on unchanged. The three parameter names are plain identifiers
1516
+ // and are not bound anywhere else in this file, so the value given to the
1517
+ // next handler is the event parameter. permissionMode is left as the parent
1518
+ // set it. The meter reads the chain's result and returns that same result.
1519
+ on('agent.spawn', async (meterSpawnApi, meterSpawnInput, meterSpawnForward) => {
1520
+ return meterSpawnForward(meterSpawnInput).then(spawned => noteSpawn(meterSpawnApi, meterSpawnInput, spawned))
616
1521
  })
617
1522
 
618
1523
  // The session boundary (design D4). /clear, /resume and /branch start the
@@ -620,24 +1525,20 @@ export function register(on) {
620
1525
  // it, and `session.start` does not fire. The meter ends the run it holds on
621
1526
  // the same event. A compaction is the same session and the same run, and a
622
1527
  // source this cannot read changes nothing. The pane is left open: it redraws
623
- // to the no-run text, which is true, and the person dismisses it.
624
- on('classic.SessionStart', async ($, e, next) => {
625
- if (!session.interactive) return next(e)
626
- try {
627
- const boundary = BOUNDARIES.get(e.source)
628
- if (boundary) {
629
- const ended = run
630
- stopTick()
631
- run = freshRun()
632
- $.ui.status(undefined)
633
- $.ui.invalidate('ui.render')
634
- const what = ended.phase === 'idle' ? 'no run was held' : `run ${ended.runId || '(no id)'} is no longer drawn`
635
- $.ui.log(`interlock meter: ${boundary} started the session over; ${what}`, { to: 'debug' })
636
- }
637
- } catch (err) {
638
- $.ui.log(`interlock meter: the session boundary was not applied: ${messageOf(err)}`, { to: 'debug' })
639
- }
640
- return next(e)
1528
+ // to the no-run text, which is true, and the person dismisses it. Once the
1529
+ // settings hooks beneath have run, the preflight's report is read for every
1530
+ // source (show-preflight-and-interrupted-runs-at-session-start design D5).
1531
+ // The kit raises this event; Claude Code 2.1.291 raises no classic event to
1532
+ // a module, so there the prompt hook above reads the report instead.
1533
+ //
1534
+ // Pass the event on unchanged. The value this hook returns is the forward
1535
+ // itself: no first message, no permission decision. The meter's reset runs
1536
+ // before that chain and the preflight read after it; neither becomes the return.
1537
+ on('classic.SessionStart', async (meterSessionApi, meterSessionInput, meterSessionForward) => {
1538
+ endRunAtBoundary(meterSessionApi, meterSessionInput)
1539
+ const sessionForwarded = meterSessionForward(meterSessionInput)
1540
+ await sessionForwarded.then(() => readPreflightAfterStart(meterSessionApi, meterSessionInput))
1541
+ return sessionForwarded
641
1542
  })
642
1543
 
643
1544
  on('ui.render', { component: 'Spinner' }, ($, e, next) => {
@@ -648,8 +1549,35 @@ export function register(on) {
648
1549
 
649
1550
  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => drawPane($, e))
650
1551
 
651
- on('command.run', { command: PANE }, async $ => {
652
- await openPane($)
1552
+ on('ui.render', { component: 'AbovePrompt' }, drawBand)
1553
+
1554
+ on('ui.render', { component: 'Pane', requestId: PREFLIGHT_PANE }, async ($, e) => drawPreflightPane($, e))
1555
+
1556
+ on('ui.render', { component: 'Pane', requestId: HANDOFF_PANE }, async ($, e) => drawHandoffPane($, e))
1557
+
1558
+ on('ui.render', { component: 'Pane', requestId: SPEC_PANE }, async ($, e) => drawSpecPane($, e))
1559
+
1560
+ on('command.run', { command: PANE }, async paneHost => {
1561
+ await openPane(paneHost)
1562
+ return {}
1563
+ })
1564
+
1565
+ // Typed before any prompt, the command reads the report itself, so it never
1566
+ // opens on a report no prompt has fetched yet (design D5).
1567
+ on('command.run', { command: PREFLIGHT_PANE }, async preflightHost => {
1568
+ if (session.interactive) await readPreflight(preflightHost, session.cwd)
1569
+ await openPane(preflightHost, PREFLIGHT_PANE, 'Interlock preflight')
1570
+ return {}
1571
+ })
1572
+
1573
+ on('command.run', { command: HANDOFF_PANE }, async handoffHost => {
1574
+ if (session.interactive) await readPreflight(handoffHost, session.cwd)
1575
+ await openPane(handoffHost, HANDOFF_PANE, 'Interlock handoff')
1576
+ return {}
1577
+ })
1578
+
1579
+ on('command.run', { command: SPEC_PANE }, async specCmdHost => {
1580
+ await openPane(specCmdHost, SPEC_PANE, SPEC_TITLE)
653
1581
  return {}
654
1582
  })
655
1583
  }