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