@renzrollon/interlock 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 layout, the state words and the cut are the renderer's; the module keys
68
+ // its rows, appends what the host observed of a lane, and speaks every
69
+ // fallback: a CLI that relays no plan, a pane narrower than the board, a
70
+ // 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,232 @@ 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
+ /** One Explore investigator the chain let through (design D5). Never throws. */
599
+ async function countInvestigator($) {
600
+ try {
601
+ spec.explore.spawned++
602
+ await specStamp($)
603
+ redrawSpec($)
604
+ } catch (err) {
605
+ $.ui.log(`interlock spec: an investigator was not counted: ${messageOf(err)}`, { to: 'debug' })
606
+ }
607
+ }
608
+
609
+ /** `/interlock-spec` (design D6): the pure module's keyed lines on `Box` and `Text`, and nothing else. */
610
+ function drawSpecPane($, e) {
611
+ const { Box, Text } = $.ui.resolve(e)
612
+ const props = l => (l.bold ? { bold: true } : l.dim ? { dimColor: true } : null)
613
+ return h(Box, { flexDirection: 'column' }, ...specPaneLines(spec).map(l => h(Box, { key: l.key }, h(Text, props(l), l.text))))
614
+ }
615
+
616
+ /** The launch guard's ruling now, over the record its branch would read, in the rule's words (design D5). */
617
+ async function launchGuardText($) {
618
+ const { record, now } = await guardFacts($)
619
+ const ruling = decideLaunch(record, now)
620
+ return launchGuardLine(ruling, now)
621
+ }
622
+
266
623
  /** Names, once per run record, a step that crossed with no live run (design D4). */
267
624
  function nameIdleStep($) {
268
625
  run.idleStepNamed = true
@@ -277,12 +634,17 @@ function nameIdleStep($) {
277
634
  async function idleStep($, e, next) {
278
635
  if (run.phase !== 'idle' || run.idleStepNamed) return next(e)
279
636
  const r = await next(e)
637
+ noteIdleStep($, r)
638
+ return r
639
+ }
640
+
641
+ /** Names the first step record a result carries while no run is live; reads nothing else. */
642
+ function noteIdleStep($, r) {
280
643
  try {
281
- if (readRecord(r.text).record && run.phase === 'idle' && !run.idleStepNamed) nameIdleStep($)
644
+ if (run.phase === 'idle' && !run.idleStepNamed && readRecord(r.text).record) nameIdleStep($)
282
645
  } catch {
283
646
  // Reading the result is all this does; the call and its result are the engine's.
284
647
  }
285
- return r
286
648
  }
287
649
 
288
650
  /** The step record in a Bash result's text, guarded as the driver guards it: an object with a string `action`. */
@@ -302,7 +664,19 @@ function readRecord(text) {
302
664
  function agentOf(id) {
303
665
  let a = run.agents.get(id)
304
666
  if (!a) {
305
- a = { id, requests: 0, known: 0, partial: false, sums: null, models: [], reason: null, durationMs: null, sha: null }
667
+ a = {
668
+ id,
669
+ requests: 0,
670
+ known: 0,
671
+ partial: false,
672
+ sums: null,
673
+ models: [],
674
+ reason: null,
675
+ durationMs: null,
676
+ sha: null,
677
+ // Whether its turn end has been spoken: once per agent per run (speak-lane-turn-ends design D1).
678
+ endToasted: false
679
+ }
306
680
  run.agents.set(id, a)
307
681
  }
308
682
  return a
@@ -323,6 +697,76 @@ function addUsage(a, usage) {
323
697
  if (str(usage.model) && !a.models.includes(usage.model)) a.models.push(usage.model)
324
698
  }
325
699
 
700
+ /** What a malformed field is, for its debug line: never its content. */
701
+ const shapeOf = value => (value === null ? 'null' : Array.isArray(value) ? 'an array' : `a ${typeof value}`)
702
+
703
+ /** A plan summary as the run program states it: an object with an array of waves. */
704
+ const isPlanSummary = value => value !== null && typeof value === 'object' && Array.isArray(value.waves)
705
+
706
+ /** A `recorded` block as the run program states it: three lists of ids. */
707
+ const isRecordedIds = value =>
708
+ value !== null &&
709
+ typeof value === 'object' &&
710
+ ['ok', 'failed', 'notAttempted'].every(list => Array.isArray(value[list]))
711
+
712
+ /** The summary's wave at a position, as a failure record names it: its group and its kind. */
713
+ function waveAt(plan, index) {
714
+ if (!plan || !isInt(index)) return { wave: null, waveKind: null }
715
+ const wave = plan.waves[index]
716
+ if (wave && typeof wave === 'object') return { wave: wave.group ?? null, waveKind: str(wave.kind) }
717
+ return plan.testWave && index === plan.waves.length ? { wave: null, waveKind: 'test' } : { wave: null, waveKind: null }
718
+ }
719
+
720
+ /**
721
+ * What the wave board keeps from a step (draw-the-wave-board-in-the-meter-pane
722
+ * design D6). A `plan` or `recorded` without the shape the run program states
723
+ * is named once per run on the debug log and ignored; the rest of the step
724
+ * applies. The recorded ids are taken before the step's own position, because
725
+ * the step record-batch returns already names the next batch.
726
+ */
727
+ function takeBoard($, record) {
728
+ if (record.plan !== undefined) {
729
+ if (isPlanSummary(record.plan)) run.plan = record.plan
730
+ else if (!run.planNamed) {
731
+ run.planNamed = true
732
+ $.ui.log(`interlock meter: a step's plan is ${shapeOf(record.plan)}, not a plan summary; ignored, the board keeps what it held`, {
733
+ to: 'debug'
734
+ })
735
+ }
736
+ }
737
+ if (record.recorded !== undefined) {
738
+ if (isRecordedIds(record.recorded)) {
739
+ for (const list of ['ok', 'failed', 'notAttempted']) {
740
+ for (const id of record.recorded[list]) {
741
+ if (!str(id) || run.recorded[list].includes(id)) continue
742
+ run.recorded[list].push(id)
743
+ if (list === 'failed') run.failedAt.set(id, waveAt(run.plan, run.dispatched && run.dispatched.waveIndex))
744
+ }
745
+ }
746
+ } else if (!run.recordedNamed) {
747
+ run.recordedNamed = true
748
+ $.ui.log(
749
+ `interlock meter: a step's recorded is ${shapeOf(record.recorded)}, not three id lists; ignored, the board keeps the ids it held`,
750
+ { to: 'debug' }
751
+ )
752
+ }
753
+ }
754
+ if (isInt(record.waveIndex)) {
755
+ const at = { waveIndex: record.waveIndex, batchIndex: isInt(record.batchIndex) ? record.batchIndex : null }
756
+ if (BATCH_ACTIONS.has(record.action)) {
757
+ run.cursor = { ...at, phase: 'batch' }
758
+ run.dispatched = at
759
+ } else if (record.action === 'verify') {
760
+ run.cursor = { ...at, phase: 'verify' }
761
+ const reason = str(record.reason)
762
+ const seen = run.skips.some(skip => skip.waveIndex === at.waveIndex && skip.reason === reason)
763
+ if (record.skipped === true && !seen) run.skips.push({ waveIndex: at.waveIndex, wave: record.wave ?? null, reason })
764
+ }
765
+ }
766
+ // The close record repeats the action without the reason; the halt step's stands.
767
+ if (record.action === 'halt') run.halt = { reason: str(record.reason) || (run.halt ? run.halt.reason : null) }
768
+ }
769
+
326
770
  async function applyRecord($, record) {
327
771
  const held = run
328
772
  await stamp($)
@@ -341,9 +785,12 @@ async function applyRecord($, record) {
341
785
  kind: str(s.kind),
342
786
  model: str(s.model),
343
787
  effort: str(s.effort),
344
- sha: str(s.promptSha256)
788
+ sha: str(s.promptSha256),
789
+ // Spawned by a step that dispatches a batch: a lane, planned or not.
790
+ batch: BATCH_ACTIONS.has(record.action)
345
791
  })
346
792
  }
793
+ takeBoard($, record)
347
794
  for (const banner of Array.isArray(record.banners) ? record.banners : []) {
348
795
  if (typeof banner !== 'string' || !banner) continue
349
796
  if (!run.banners.includes(banner)) run.banners.push(banner)
@@ -357,19 +804,112 @@ async function applyRecord($, record) {
357
804
  run.exitCode = record.exitCode
358
805
  run.phase = 'closed'
359
806
  stopTick()
360
- $.ui.status(undefined)
361
807
  } else if (record.action === 'close' || record.action === 'halt') {
362
808
  run.phase = 'closing'
363
809
  stopTick()
364
- $.ui.status(undefined)
365
- } else {
366
- $.ui.status(statusText())
367
810
  }
811
+ setStatus($)
368
812
  $.ui.invalidate('ui.render')
369
813
  }
370
814
 
371
- async function openPane($) {
372
- return $.ui.open({ id: PANE, title: TITLE, closeOnEscape: true })
815
+ async function openPane($, id = PANE, title = TITLE) {
816
+ return $.ui.open({ id, title, closeOnEscape: true })
817
+ }
818
+
819
+ /**
820
+ * Reads the session start's report (design D5) and holds what `adoptPreflight`
821
+ * keeps: the same report changes nothing, a new one is drawn, and a file that
822
+ * cannot be read or is not a report is named once on the debug log. Never
823
+ * throws: a read that rejects is the event's to carry on past.
824
+ */
825
+ async function readPreflight($, cwd) {
826
+ const path = preflightFilePath(cwd)
827
+ let outcome
828
+ try {
829
+ outcome = readPreflightReport(await $.fs.read(path))
830
+ } catch (err) {
831
+ outcome = { problem: messageOf(err) }
832
+ }
833
+ try {
834
+ const { start: next, changed, log } = adoptPreflight(start, outcome, path)
835
+ start = next
836
+ if (log) $.ui.log(log, { to: 'debug' })
837
+ if (changed) $.ui.invalidate('ui.render')
838
+ } catch (err) {
839
+ $.ui.log(`interlock meter: the preflight report was not held: ${messageOf(err)}`, { to: 'debug' })
840
+ }
841
+ }
842
+
843
+ /** One keyed row per line, so the kit and the person both find each line on its own. */
844
+ function rowsOf($, e, prefix, lines) {
845
+ const { Box, Text } = $.ui.resolve(e)
846
+ return lines.map((line, n) => h(Box, { key: `${prefix}-${n}` }, h(Text, null, line)))
847
+ }
848
+
849
+ /**
850
+ * The band (design D6): the report's lines above whatever the other plugins
851
+ * drew, and a Hide control that collapses it until a new session start's
852
+ * report. It yields to a survey, and draws nothing of its own with no report,
853
+ * a hidden band, or a report with nothing to say.
854
+ */
855
+ async function drawBand($, e, next) {
856
+ if (!session.interactive || (e.props && e.props.hasSurvey) || start.hidden || !start.report) return next(e)
857
+ const lines = bandLines(start.report)
858
+ if (!lines.length) return next(e)
859
+ const beneath = await next(e)
860
+ try {
861
+ const { Box, Button } = $.ui.resolve(e)
862
+ const hide = () => {
863
+ start.hidden = true
864
+ $.ui.invalidate('ui.render')
865
+ }
866
+ return h(
867
+ Box,
868
+ { flexDirection: 'column' },
869
+ ...rowsOf($, e, 'preflight', lines),
870
+ h(Button, { key: 'hide', label: 'Hide', onPress: hide }),
871
+ ...(beneath ? [beneath] : [])
872
+ )
873
+ } catch (err) {
874
+ $.ui.log(`interlock meter: the session-start band was not drawn: ${messageOf(err)}`, { to: 'debug' })
875
+ return beneath
876
+ }
877
+ }
878
+
879
+ /** `/interlock-preflight`: the report's every line, or why there is none (design D6). */
880
+ function drawPreflightPane($, e) {
881
+ const { Box } = $.ui.resolve(e)
882
+ const lines = start.report
883
+ ? preflightPaneLines(start.report)
884
+ : [noReportText(start.path || preflightFilePath(session.cwd), start.problem)]
885
+ return h(Box, { flexDirection: 'column' }, ...rowsOf($, e, 'preflight-pane', lines))
886
+ }
887
+
888
+ /**
889
+ * `/interlock-handoff` (design D6): each card the report lists, newest first,
890
+ * read from its path now and rendered verbatim through `Markdown`, cut to the
891
+ * published cap. A card that cannot be read is named with the reason, and the
892
+ * next is drawn as before. Nothing here acts on a card.
893
+ */
894
+ async function drawHandoffPane($, e) {
895
+ const { Box, Text, Markdown } = $.ui.resolve(e)
896
+ const column = rows => h(Box, { flexDirection: 'column' }, ...rows)
897
+ if (!start.report) {
898
+ return column(rowsOf($, e, 'handoff', [noReportText(start.path || preflightFilePath(session.cwd), start.problem)]))
899
+ }
900
+ const cards = orderCards(start.report)
901
+ if (!cards.length) return column(rowsOf($, e, 'handoff', noCardText(start.report)))
902
+ const out = []
903
+ for (const [n, card] of cards.entries()) {
904
+ out.push(h(Box, { key: `card-${n}` }, h(Text, null, cardLine(card))))
905
+ try {
906
+ const text = await $.fs.read(card.path)
907
+ out.push(h(Box, { key: `card-${n}-body` }, h(Markdown, { text: clampCard(text, card.path).text })))
908
+ } catch (err) {
909
+ out.push(h(Box, { key: `card-${n}-unread` }, h(Text, null, `${card.path}: cannot be read: ${messageOf(err)}`)))
910
+ }
911
+ }
912
+ return column(out)
373
913
  }
374
914
 
375
915
  function tokensText(a) {
@@ -388,12 +928,38 @@ function agentForSha(sha) {
388
928
  return null
389
929
  }
390
930
 
391
- function laneForAgent(a) {
931
+ function rowForAgent(a) {
392
932
  if (!a.sha) return null
393
- for (const r of run.rows.values()) if (r.sha === a.sha) return r.label
933
+ for (const r of run.rows.values()) if (r.sha === a.sha) return r
394
934
  return null
395
935
  }
396
936
 
937
+ function laneForAgent(a) {
938
+ const r = rowForAgent(a)
939
+ return r ? r.label : null
940
+ }
941
+
942
+ /**
943
+ * The session lines (speak-lane-turn-ends-and-session-cost design D4, D6): the
944
+ * engine's own figures as it answered them, each said to be unreported where
945
+ * it is absent. Nothing here is computed: no percent from the tokens, no price.
946
+ */
947
+ function sessionLines(usage, failure) {
948
+ if (failure !== null) return [`context unavailable: ${failure}`, `session cost unavailable: ${failure}`]
949
+ const num = v => (typeof v === 'number' && Number.isFinite(v) ? v : null)
950
+ const context = usage.context && typeof usage.context === 'object' ? usage.context : {}
951
+ const [tokens, size, percent] = [num(context.tokens), num(context.window), num(context.percent)]
952
+ let fill = 'context not reported'
953
+ if (size !== null && tokens === null) fill = `context: window ${size} tokens · fill not yet reported`
954
+ else if (size !== null) fill = `context ${tokens} of ${size} tokens${percent !== null ? ` · ${percent}% used` : ''}`
955
+ const usd = usage.cost && typeof usage.cost === 'object' ? num(usage.cost.usd) : null
956
+ const cost =
957
+ usd === null
958
+ ? 'session cost not reported by this host'
959
+ : `session cost $${usd.toFixed(4)} · the engine's total for this session, not the run's`
960
+ return [fill, cost]
961
+ }
962
+
397
963
  /**
398
964
  * `last activity <ISO>` or `last activity unknown`, then the quiet word while a
399
965
  * live run is quiet. The word is recomputed from the clock at each draw, so a
@@ -406,11 +972,96 @@ async function lastActivityText($) {
406
972
  return `last activity ${at}${word ? ` · ${word}` : ''}`
407
973
  }
408
974
 
975
+ /** One spawn as a flat row, as the section drew every spawn before the board. */
976
+ function flatRow(keyed, r) {
977
+ const a = agentForSha(r.sha)
978
+ const served = a && a.models.length ? a.models.join(', ') : '?'
979
+ const state = a ? a.reason || 'running' : 'waiting'
980
+ return keyed(
981
+ `wave-row-${r.label}`,
982
+ `${r.title} · ${r.kind || 'agent'} · routed ${r.model || '?'} · served ${served} · effort ${r.effort || '?'} · ${state}`
983
+ )
984
+ }
985
+
986
+ /**
987
+ * The overlay the renderer reads in place of a wave state, built from the
988
+ * steps alone (design D6). The wave list is the summary's own, so the renderer
989
+ * never falls back to ids for a count the module made disagree.
990
+ */
991
+ function boardOverlay(plan) {
992
+ return {
993
+ waves: [...plan.waves, ...(plan.testWave ? [plan.testWave] : [])],
994
+ cursor: run.cursor,
995
+ completed: run.recorded.ok,
996
+ failures: run.recorded.failed.map(id => ({ id, ...(run.failedAt.get(id) || waveAt(null, null)), error: null })),
997
+ skippedVerifications: run.skips,
998
+ unresolved: [],
999
+ halt: run.halt
1000
+ }
1001
+ }
1002
+
1003
+ /** What the host observed of each spawned lane: its served models and, once joined to its agent, its turn. */
1004
+ function laneNotes() {
1005
+ const notes = {}
1006
+ for (const r of run.rows.values()) {
1007
+ const a = agentForSha(r.sha)
1008
+ const served = a && a.models.length ? a.models.join(', ') : '?'
1009
+ notes[r.label] = `served ${served}${a ? ` · ${a.reason || 'running'}` : ''}`
1010
+ }
1011
+ return notes
1012
+ }
1013
+
1014
+ /** The pane key a board row is drawn under: a lane keeps the flat row's key (design D7). */
1015
+ function boardKey(key) {
1016
+ if (key.startsWith('lane:')) return `wave-row-${key.slice('lane:'.length)}`
1017
+ if (key === 'header' || key === 'tail') return `wave-board-${key}`
1018
+ const [kind, ...rest] = key.split(':')
1019
+ return `${kind === 'wave' ? 'wave-block' : `wave-${kind}`}-${rest.join('-')}`
1020
+ }
1021
+
1022
+ /**
1023
+ * The waves section (design D7, D8): the board once a plan summary has
1024
+ * crossed, at the pane's own width, one truncating text per row; then a flat
1025
+ * row for each spawn no planned lane carries. Before a summary, the flat rows,
1026
+ * beneath a spoken line once a batch was dispatched. Below the board's minimum
1027
+ * the renderer's spoken line stands in for it, and a renderer that throws is
1028
+ * named here and on the debug log. Everything drawn is the steps'.
1029
+ */
1030
+ function drawWaves($, e) {
1031
+ const { Box, Text } = $.ui.resolve(e)
1032
+ const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
1033
+ const line = (key, text) => keyed(key, text, { wrap: 'truncate-end' })
1034
+ const rows = [...run.rows.values()]
1035
+ if (!run.plan) {
1036
+ if (!rows.length) return [h(Text, { dimColor: true }, NO_LANES_LINE)]
1037
+ const dispatched = run.dispatched !== null || rows.some(r => r.batch)
1038
+ return [...(dispatched ? [keyed('wave-board-unrelayed', UNRELAYED_LINE)] : []), ...rows.map(r => flatRow(keyed, r))]
1039
+ }
1040
+ const props = e.props || {}
1041
+ const width = isInt(props.bodyColumns) && props.bodyColumns > 0 ? props.bodyColumns : LIMITS.waveBoardDefaultColumns
1042
+ let board
1043
+ try {
1044
+ board = drawPlanBoardRows(run.plan, { columns: width, state: boardOverlay(run.plan), notes: laneNotes() })
1045
+ } catch (err) {
1046
+ $.ui.log(`interlock meter: ${BOARD_FAILED_LINE}: ${messageOf(err)}`, { to: 'debug' })
1047
+ return [keyed('wave-board-failed', BOARD_FAILED_LINE), ...rows.map(r => flatRow(keyed, r))]
1048
+ }
1049
+ // Every board ends in its tail; the renderer's spoken line for a narrow body
1050
+ // is one row without one, and the lanes are then drawn flat beneath it.
1051
+ const drawn = board.length > 0 && board[board.length - 1].key === 'tail'
1052
+ const planned = new Set(drawn ? board.filter(row => row.key.startsWith('lane:')).map(row => row.key.slice('lane:'.length)) : [])
1053
+ return [
1054
+ ...board.map(row => line(boardKey(row.key), row.text)),
1055
+ ...rows.filter(r => !planned.has(r.label)).map(r => flatRow(keyed, r))
1056
+ ]
1057
+ }
1058
+
409
1059
  async function drawPane($, e) {
410
1060
  const { Box, Text } = $.ui.resolve(e)
411
1061
  const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
412
1062
  if (run.phase === 'idle' && !run.summary) {
413
- return h(Box, { flexDirection: 'column' }, h(Text, { dimColor: true }, NO_RUN_LINE))
1063
+ // The guard runs in every session, so its line stands here too (design D5).
1064
+ return h(Box, { flexDirection: 'column' }, h(Text, { dimColor: true }, NO_RUN_LINE), keyed('launch-guard', await launchGuardText($)))
414
1065
  }
415
1066
 
416
1067
  const out = []
@@ -420,18 +1071,7 @@ async function drawPane($, e) {
420
1071
  out.push(h(Text, { dimColor: true }, RECORD_LINE))
421
1072
 
422
1073
  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
- }
1074
+ out.push(...drawWaves($, e))
435
1075
 
436
1076
  out.push(h(Text, { bold: true }, 'agents'))
437
1077
  if (!run.agents.size) out.push(h(Text, { dimColor: true }, 'no agent has reported a request yet'))
@@ -447,15 +1087,25 @@ async function drawPane($, e) {
447
1087
  )
448
1088
  }
449
1089
 
450
- out.push(h(Text, { bold: true }, 'plan windows'))
451
- let windows = []
1090
+ // One plain read feeds the session lines and the plan windows; a breakdown
1091
+ // is never asked for, because only the plain call is free (design D4).
1092
+ let usage = null
1093
+ let failure = null
452
1094
  try {
453
- const usage = await $.session.usage()
454
- windows = usage && Array.isArray(usage.rateLimits) ? usage.rateLimits : []
455
- } catch {
456
- windows = []
1095
+ usage = await $.session.usage()
1096
+ if (!usage || typeof usage !== 'object') throw new Error(`the usage answered ${String(usage)}`)
1097
+ } catch (err) {
1098
+ failure = messageOf(err)
457
1099
  }
458
- if (!windows.length) out.push(h(Text, { dimColor: true }, 'none reported (off a subscription the session has none)'))
1100
+ const [contextLine, costLine] = sessionLines(usage, failure)
1101
+ out.push(h(Text, { bold: true }, 'session'))
1102
+ out.push(keyed('session-context', contextLine))
1103
+ out.push(keyed('session-cost', costLine))
1104
+
1105
+ out.push(h(Text, { bold: true }, 'plan windows'))
1106
+ const windows = usage && Array.isArray(usage.rateLimits) ? usage.rateLimits : []
1107
+ if (failure !== null) out.push(h(Text, { dimColor: true }, `plan windows unavailable: ${failure}`))
1108
+ else if (!windows.length) out.push(h(Text, { dimColor: true }, 'none reported (off a subscription the session has none)'))
459
1109
  for (const w of windows) {
460
1110
  if (!w || typeof w !== 'object') continue
461
1111
  const kind = String(w.kind)
@@ -471,6 +1121,11 @@ async function drawPane($, e) {
471
1121
  )
472
1122
  )
473
1123
 
1124
+ out.push(h(Text, { bold: true }, 'refusals'))
1125
+ const denials = guardDenialLine(run.guardDenials)
1126
+ if (denials) out.push(keyed('guard-denials', denials))
1127
+ out.push(keyed('launch-guard', await launchGuardText($)))
1128
+
474
1129
  if (run.summary) {
475
1130
  out.push(h(Text, { bold: true }, 'close summary'))
476
1131
  out.push(h(Box, { key: 'summary', flexDirection: 'column' }, h(Text, null, run.summary)))
@@ -480,9 +1135,11 @@ async function drawPane($, e) {
480
1135
 
481
1136
  export function register(on) {
482
1137
  on('session.start', async ($, e, next) => {
483
- session = { interactive: e.isInteractive === true, surface: e.surface || null }
1138
+ session = { interactive: e.isInteractive === true, surface: e.surface || null, cwd: str(e.cwd) }
484
1139
  stopTick()
485
1140
  run = freshRun()
1141
+ start = freshStart()
1142
+ spec = freshSpec()
486
1143
  try {
487
1144
  await $.command.register({
488
1145
  name: PANE,
@@ -492,6 +1149,33 @@ export function register(on) {
492
1149
  } catch (err) {
493
1150
  $.ui.log(`interlock meter: /interlock-meter not registered: ${(err && err.message) || err}`, { to: 'debug' })
494
1151
  }
1152
+ try {
1153
+ await $.command.register({
1154
+ name: PREFLIGHT_PANE,
1155
+ description: "Open this session start's Interlock preflight: the doctor's checks, interrupted runs and halt resume cards",
1156
+ immediate: true
1157
+ })
1158
+ } catch (err) {
1159
+ $.ui.log(`interlock meter: /${PREFLIGHT_PANE} not registered: ${messageOf(err)}`, { to: 'debug' })
1160
+ }
1161
+ try {
1162
+ await $.command.register({
1163
+ name: HANDOFF_PANE,
1164
+ description: 'Open the halt resume cards of open changes, as the session-start preflight listed them',
1165
+ immediate: true
1166
+ })
1167
+ } catch (err) {
1168
+ $.ui.log(`interlock meter: /${HANDOFF_PANE} not registered: ${messageOf(err)}`, { to: 'debug' })
1169
+ }
1170
+ try {
1171
+ await $.command.register({
1172
+ name: SPEC_PANE,
1173
+ description: 'Open the Interlock spec meter: the live /interlock:spec run, its artifact ladder, gates and writes',
1174
+ immediate: true
1175
+ })
1176
+ } catch (err) {
1177
+ $.ui.log(`interlock meter: /${SPEC_PANE} not registered: ${messageOf(err)}`, { to: 'debug' })
1178
+ }
495
1179
  let version = 'unknown'
496
1180
  try {
497
1181
  version = (await $.session.version()).version || 'unknown'
@@ -507,11 +1191,20 @@ export function register(on) {
507
1191
  })
508
1192
 
509
1193
  // 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.
1194
+ // It is also the one event that reaches the module after the session start's
1195
+ // preflight wrote its report, so the report is read here (show-preflight-and-
1196
+ // interrupted-runs-at-session-start design D5). The prompt is passed on as it
1197
+ // came: never rewritten, never dropped. A typed `/interlock:spec` starts the
1198
+ // spec meter's run, read off the prompt's first word before it is passed on,
1199
+ // since the turn it starts runs inside the hand-on (observe-the-spec-run-live
1200
+ // design D1).
511
1201
  on('prompt.submit', async ($, e, next) => {
512
1202
  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' })
1203
+ if (HUMAN_ORIGINS.has(kind)) {
1204
+ await changeRecord($, 'human prompt', withPrompt)
1205
+ if (session.interactive) await readPreflight($, session.cwd)
1206
+ } else $.ui.log(`${GUARD}: a ${kind} prompt is not a human prompt; not counted`, { to: 'debug' })
1207
+ if (session.interactive) await observeLoad($, promptSkill(e.text))
515
1208
  return next(e)
516
1209
  })
517
1210
 
@@ -529,8 +1222,12 @@ export function register(on) {
529
1222
  const { record, now } = await guardFacts($)
530
1223
  const launch = isShipLaunch(e, record)
531
1224
  const verdict = decideLaunch(record, now)
1225
+ // The refusal is spoken to the person in the same words the model reads (design D5).
1226
+ if (launch && session.interactive && verdict.reason) $.ui.toast(verdict.reason)
532
1227
  if (launch && verdict.decision === 'deny') return { deny: verdict.reason }
533
1228
  const r = await next(e)
1229
+ // The settings form's refusal, read off the result: a relaunch comes after its run closed (design D4).
1230
+ if (session.interactive) speakRefusal($, r)
534
1231
  const accepted = launch && r.isError !== true && isAcceptedLaunch(r.result)
535
1232
  if (accepted) {
536
1233
  await changeRecord($, 'ship launch', (current, at) =>
@@ -546,10 +1243,19 @@ export function register(on) {
546
1243
  // The meter: an accepted ship launch makes the run live, its first activity
547
1244
  // at the time the guard read (design D1), and starts its interval.
548
1245
  if (!session.interactive || !accepted) return r
1246
+ // The spec run ends before the ship run is live, so the one status line
1247
+ // passes from one to the other with no second writer (observe-the-spec-run-live design D7).
1248
+ const handing = spec.phase === 'live' || spec.phase === 'checkpoint'
1249
+ if (handing) {
1250
+ spec.phase = 'handed-over'
1251
+ spec.handedOver = true
1252
+ spec.handedOverAt = now
1253
+ }
549
1254
  stopTick()
550
1255
  run = freshRun()
551
1256
  const launched = run
552
1257
  run.phase = 'live'
1258
+ if (handing) setStatus($)
553
1259
  run.runId = str(r.result.runId)
554
1260
  run.workflowName = str(r.result.workflowName)
555
1261
  run.transcriptDir = str(r.result.transcriptDir)
@@ -562,21 +1268,50 @@ export function register(on) {
562
1268
  })
563
1269
 
564
1270
  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)
1271
+ if (!session.interactive) return next(e)
1272
+ const command = typeof e.command === 'string' ? e.command : ''
1273
+ const driver = DRIVER_LINE.test(command)
1274
+ // The spec flow's keyed lines, read only while a spec run is live (observe-the-spec-run-live design D3).
1275
+ const lines = specLines(command)
1276
+ const reader = lines.length ? specReader($, lines) : null
1277
+ if (!watching()) {
1278
+ if (!reader) return driver ? idleStep($, e, next) : next(e)
1279
+ const r = await next(e)
1280
+ if (driver) noteIdleStep($, r)
1281
+ await applySpecLines($, reader, lines, r)
1282
+ return r
1283
+ }
567
1284
  const held = run
568
1285
  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' })
1286
+ // Any Bash call during the run is read for a guard's refusal (guard-commit); only a driver line for a step.
1287
+ if (held === run) speakRefusal($, r)
1288
+ if (driver) {
1289
+ try {
1290
+ const { record, problem } = readRecord(r.text)
1291
+ if (!record) $.ui.log(`interlock meter: ${command.slice(0, 80)}: not a step record (${problem})`, { to: 'debug' })
1292
+ else if (held === run) await applyRecord($, record)
1293
+ else if (run.phase === 'idle' && !run.idleStepNamed) nameIdleStep($)
1294
+ } catch (err) {
1295
+ $.ui.log(`interlock meter: step read failed: ${(err && err.message) || err}`, { to: 'debug' })
1296
+ }
576
1297
  }
1298
+ if (reader) await applySpecLines($, reader, lines, r)
577
1299
  return r
578
1300
  })
579
1301
 
1302
+ // A Skill tool load, observed after the engine resolved it and returned as
1303
+ // it came: the spec skill starts the spec meter's run (observe-the-spec-run-live design D1).
1304
+ on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
1305
+ if (!session.interactive) return next(e)
1306
+ const r = await next(e)
1307
+ if (!(r && r.isError === true)) await observeLoad($, e.skill)
1308
+ return r
1309
+ })
1310
+
1311
+ // The tools the manifest's `Edit|Write` matcher names for guard-tests and guard-tasks (design D4).
1312
+ on('tool.call', { tool: 'Edit' }, editCall)
1313
+ on('tool.call', { tool: 'Write' }, editCall)
1314
+
580
1315
  on('turn.step', async function* ($, e, next) {
581
1316
  if (!session.interactive || run.phase !== 'live' || !e.agentId) return yield* next(e)
582
1317
  // A run agent's request starting, and its answer arriving, are each activity (design D1).
@@ -597,6 +1332,18 @@ export function register(on) {
597
1332
  const a = agentOf(e.agentId)
598
1333
  a.reason = str(e.reason)
599
1334
  a.durationMs = typeof e.durationMs === 'number' ? e.durationMs : null
1335
+ // A turn the host ended without an answer is spoken once, in the host's
1336
+ // own word: no cause, no verdict. The close summary stays the record
1337
+ // (speak-lane-turn-ends-and-session-cost design D1-D3).
1338
+ if (a.reason && a.reason !== 'answer' && !a.endToasted) {
1339
+ a.endToasted = true
1340
+ const row = rowForAgent(a)
1341
+ try {
1342
+ $.ui.toast(`${row ? row.title : 'lane unknown'} · agent ${a.id} · turn ended: ${a.reason}`)
1343
+ } catch (err) {
1344
+ $.ui.log(`interlock meter: the turn-end toast for ${a.id} was refused: ${messageOf(err)}`, { to: 'debug' })
1345
+ }
1346
+ }
600
1347
  if (await stamp($)) redrawWord($)
601
1348
  $.ui.invalidate('ui.render')
602
1349
  }
@@ -612,6 +1359,8 @@ export function register(on) {
612
1359
  }
613
1360
  if (await stamp($)) redrawWord($)
614
1361
  }
1362
+ // An Explore investigator the chain let through, counted for the spec run (observe-the-spec-run-live design D5).
1363
+ if (specWatching() && e.subagentType === 'Explore' && r && typeof r.model === 'string') await countInvestigator($)
615
1364
  return r
616
1365
  })
617
1366
 
@@ -620,7 +1369,11 @@ export function register(on) {
620
1369
  // it, and `session.start` does not fire. The meter ends the run it holds on
621
1370
  // the same event. A compaction is the same session and the same run, and a
622
1371
  // 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.
1372
+ // to the no-run text, which is true, and the person dismisses it. Once the
1373
+ // settings hooks beneath have run, the preflight's report is read for every
1374
+ // source (show-preflight-and-interrupted-runs-at-session-start design D5).
1375
+ // The kit raises this event; Claude Code 2.1.291 raises no classic event to
1376
+ // a module, so there the prompt hook above reads the report instead.
624
1377
  on('classic.SessionStart', async ($, e, next) => {
625
1378
  if (!session.interactive) return next(e)
626
1379
  try {
@@ -629,7 +1382,9 @@ export function register(on) {
629
1382
  const ended = run
630
1383
  stopTick()
631
1384
  run = freshRun()
632
- $.ui.status(undefined)
1385
+ start = freshStart()
1386
+ spec = freshSpec({ afterBoundary: true })
1387
+ setStatus($)
633
1388
  $.ui.invalidate('ui.render')
634
1389
  const what = ended.phase === 'idle' ? 'no run was held' : `run ${ended.runId || '(no id)'} is no longer drawn`
635
1390
  $.ui.log(`interlock meter: ${boundary} started the session over; ${what}`, { to: 'debug' })
@@ -637,7 +1392,9 @@ export function register(on) {
637
1392
  } catch (err) {
638
1393
  $.ui.log(`interlock meter: the session boundary was not applied: ${messageOf(err)}`, { to: 'debug' })
639
1394
  }
640
- return next(e)
1395
+ const r = await next(e)
1396
+ await readPreflight($, str(e.cwd) || session.cwd)
1397
+ return r
641
1398
  })
642
1399
 
643
1400
  on('ui.render', { component: 'Spinner' }, ($, e, next) => {
@@ -648,8 +1405,35 @@ export function register(on) {
648
1405
 
649
1406
  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => drawPane($, e))
650
1407
 
1408
+ on('ui.render', { component: 'AbovePrompt' }, drawBand)
1409
+
1410
+ on('ui.render', { component: 'Pane', requestId: PREFLIGHT_PANE }, async ($, e) => drawPreflightPane($, e))
1411
+
1412
+ on('ui.render', { component: 'Pane', requestId: HANDOFF_PANE }, async ($, e) => drawHandoffPane($, e))
1413
+
1414
+ on('ui.render', { component: 'Pane', requestId: SPEC_PANE }, async ($, e) => drawSpecPane($, e))
1415
+
651
1416
  on('command.run', { command: PANE }, async $ => {
652
1417
  await openPane($)
653
1418
  return {}
654
1419
  })
1420
+
1421
+ // Typed before any prompt, the command reads the report itself, so it never
1422
+ // opens on a report no prompt has fetched yet (design D5).
1423
+ on('command.run', { command: PREFLIGHT_PANE }, async $ => {
1424
+ if (session.interactive) await readPreflight($, session.cwd)
1425
+ await openPane($, PREFLIGHT_PANE, 'Interlock preflight')
1426
+ return {}
1427
+ })
1428
+
1429
+ on('command.run', { command: HANDOFF_PANE }, async $ => {
1430
+ if (session.interactive) await readPreflight($, session.cwd)
1431
+ await openPane($, HANDOFF_PANE, 'Interlock handoff')
1432
+ return {}
1433
+ })
1434
+
1435
+ on('command.run', { command: SPEC_PANE }, async $ => {
1436
+ await openPane($, SPEC_PANE, SPEC_TITLE)
1437
+ return {}
1438
+ })
655
1439
  }