@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/.claude-plugin/icon.png +0 -0
- package/.claude-plugin/plugin.json +16 -1
- package/CHANGELOG.md +68 -13
- package/bin/interlock +154 -18
- package/hooks/mod.mjs +835 -51
- package/hooks/preflight.mjs +209 -34
- package/lib/doctor.mjs +23 -18
- package/lib/draw-plan.mjs +496 -0
- package/lib/draw-run.mjs +466 -0
- package/lib/evals-triage.mjs +1 -1
- package/lib/lane.mjs +152 -0
- package/lib/limits.mjs +37 -2
- package/lib/meter-refusals.mjs +73 -0
- package/lib/notify-defaults.mjs +6 -0
- package/lib/notify.mjs +41 -25
- package/lib/plan-summary.mjs +119 -0
- package/lib/preflight-file.mjs +266 -0
- package/lib/resume-card.mjs +126 -8
- package/lib/run.mjs +43 -9
- package/lib/spec-meter.mjs +591 -0
- package/lib/waves.mjs +6 -107
- package/package.json +3 -2
- package/skills/evals/SKILL.md +3 -3
- package/skills/fix-tests/SKILL.md +2 -2
- package/skills/review-artifacts/SKILL.md +3 -3
- package/skills/spec/SKILL.md +6 -11
- package/skills/spec/scripts/compact-instructions.mjs +30 -0
- package/.claude-plugin/marketplace.json +0 -19
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')
|
|
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 (
|
|
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 = {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
451
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
} catch {
|
|
456
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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))
|
|
514
|
-
|
|
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
|
|
566
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|