@renzrollon/interlock 1.3.1 → 1.4.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 -1
- package/CHANGELOG.md +38 -0
- package/hooks/mod.mjs +425 -90
- package/lib/draw-plan.mjs +2 -3
- package/lib/lane.mjs +31 -7
- package/lib/meter-palette.mjs +51 -0
- package/lib/meter-time.mjs +43 -0
- package/lib/meter-timeline.mjs +161 -0
- package/lib/waves.mjs +1 -1
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "interlock",
|
|
3
3
|
"displayName": "Interlock",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.4.1",
|
|
5
5
|
"description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec. Onboard a repo, produce a reviewed spec, then ship it start-to-commit with parallel agents and a deterministic gate spine.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Carl Rollon",
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,44 @@ All notable changes to this project are documented here.
|
|
|
5
5
|
The format follows Keep a Changelog 1.1.0, and
|
|
6
6
|
this project adheres to Semantic Versioning 2.0.0.
|
|
7
7
|
|
|
8
|
+
## 1.4.1 — 2026-10-09
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **The ship meter pane reads as a timeline.** In 1.4.0 the `agents` section was a flat list in first-seen order: relay pings, the planner and lane workers were interleaved, nothing said when anything happened, and lanes that ran in parallel looked like lanes that ran in turn.
|
|
13
|
+
- **The `timeline` section replaces it.** There is one line per CLI step as it crossed, such as `run-batch · wave 2 · batch 1/2 · 3 in parallel` or `verify · wave 2 · skipped: <reason>`. The relay agent that carried the step is folded into that line: `cli <id>`, its served model, its request count and its duration.
|
|
14
|
+
- **Each step's agents** are indented beneath it in the order they started, with name, id, model, turn word and a dim requests-and-tokens line. A spawn no agent has picked up yet reads `waiting`. A relay whose line printed no step, such as `interlock limits`, stands at its own time, and agents nothing names are listed last under `unmatched`.
|
|
15
|
+
- **Every timed line has a local `HH:MM:SS` gutter**, blank where the clock gave no reading, never a guessed time.
|
|
16
|
+
- **The header** reads `started <date> <time> · last activity <time>`, replacing `last activity <ISO>`.
|
|
17
|
+
- **Durations** read `41.2s`, `2m 14s` or `1h 03m`, replacing `134.2s`.
|
|
18
|
+
- **Wave cards group their lanes by batch**, under a line with the batch's dispatch time and `N in parallel` or `1 lane`. The lanes are indented beneath it and no longer repeat the batch cell. A lane's note adds its duration once its turn ended.
|
|
19
|
+
- **Each section's body is indented** under its heading.
|
|
20
|
+
- **Relay token figures:** a relay's token breakdown is no longer drawn. The trajectory's `agent-result` line still carries it.
|
|
21
|
+
- **New modules:** the words and the order come from two new Node-free modules, `lib/meter-time.mjs` and `lib/meter-timeline.mjs`, pinned by `test/spine/meter-time.test.mjs` and `test/spine/meter-timeline.test.mjs`.
|
|
22
|
+
- **Layout probe:** a probe on 2.1.295 showed the gutter keeps a hanging indent when a line wraps, inside a card too. The pane is as tall as the engine seats it, and a long run scrolls.
|
|
23
|
+
- **Unchanged:** `interlock waves --format board` prints exactly what it printed before.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **A run resumed across the 1.4.0 upgrade does not re-run its lanes.** The 1.4.0 entry said it would, because the runtime caches an agent by its label. The runtime keys an agent on its prompt and its options together, the label among them, but a lane's label is the title its step carried, and on a resume the completed relays hand back the steps they first printed. So every lane the run had already dispatched is spawned under its old title and returns its saved result. `docs/04` now says the replay cache keys on the prompt and the options, not on the prompt text alone, and says why an upgrade does not rename the lanes a resume replays.
|
|
28
|
+
- **`docs/07` no longer says the board names a lane exactly as its spawn.** Since 1.4.0 the spawn is shown as `task 1.1 · …`, while the board, the briefing file, the worktree and the trajectory line name the lane by its label, `1.1`.
|
|
29
|
+
|
|
30
|
+
## 1.4.0 — 2026-10-09
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **A lane's worker is shown as `task 1.1 · <gist>`.** `/workflows`, `interlock-run`'s log, the meter's rows and toasts and the Mermaid plan all name a lane by `laneTitle`, which read `1.1 · Add the relaunch guard`. A bare `1.1` among `plan-waves`, `cli-3 · run next` and `verify` looked like an outline number rather than a worker. The title now opens with what the agent is doing: `task 1.1 · …` for a one-task lane and `tasks 1.1+2 · …` for a lane of several. The 48-character cap still applies to the label and gist, so a gist is cut where it was and `interlock waves --format board` prints byte-for-byte what it printed before. The label (`1.1`, `1.1+2`) is unchanged and still names the briefing file, the worktree, the trajectory line and `run record-batch`. Non-lane spawns keep their labels as titles. The workflow runtime caches an agent by its label, so resuming a run from `/workflows` that started before this upgrade re-runs its lane agents.
|
|
35
|
+
- **The ship meter names every agent on the Workflow host.** On that host every agent row and turn-end toast read `lane unknown`. The meter joined an agent to its lane only through `agent.spawn`, which the host never raises for a workflow agent, so the board's lane notes never left `served ?` either. Every run agent's first act is to read its own briefing, so the meter now joins on that read. A `Read` whose path names a dispatched spawn's briefing, or a Bash command that names it (a worker that `cat`s it), matches the agent to that spawn's row by the same briefing hash. A new `tool.call` observer on `Read` makes that join and passes the call on unchanged. An agent is named by its spawn's title (`task 1.1 · …`, `plan-waves`, `verify`). A relay ping is named by its own `interlock` line (`cli · run next`). An agent seen doing neither reads `unmatched agent`, and one line beneath the rows says why. The pane and the toast now use one name; before, the pane printed the label and the toast the title.
|
|
36
|
+
- **The meter pane draws in your theme's colours.** Every colour is a theme key, so the pane follows a light, dark or colour-blind theme. A probe on 2.1.295 showed theme keys resolve in a module's pane and an unknown key renders uncoloured.
|
|
37
|
+
- State words: `success`, `error` and `warning` for `ok`, `failed` and `current`, replacing raw green, red and yellow.
|
|
38
|
+
- Names: lane labels and agent names in `suggestion`, bold.
|
|
39
|
+
- Headings: the run header and section headings in `claude`.
|
|
40
|
+
- Cards: the card of the wave holding a `current` lane is bordered in `warning`; every other card stays dim.
|
|
41
|
+
- Agent rows: an agent's turn word in `warning` while running, `success` for `answer` and `error` for any other host word, with relay pings dim.
|
|
42
|
+
- Warnings: the quiet word and banners in `warning`.
|
|
43
|
+
- Refusals: the guard-denial line, and the launch-guard line when it refuses, in `error`.
|
|
44
|
+
- Uncoloured, as before: the context, cost and plan-window lines (colour there would read as a threshold) and a `RED` marker in a wave title. The rules are `lib/meter-palette.mjs`'s, a Node-free module pinned by `test/spine/meter-palette.test.mjs`.
|
|
45
|
+
|
|
8
46
|
## 1.3.1 — 2026-10-08
|
|
9
47
|
|
|
10
48
|
### Changed
|
package/hooks/mod.mjs
CHANGED
|
@@ -65,10 +65,31 @@
|
|
|
65
65
|
// with an overlay built from the steps alone (the cursor, the ids each recorded
|
|
66
66
|
// batch names, the skipped verifications, the halt). No file is read for it.
|
|
67
67
|
// The module lays those rows as one bordered card per wave, wraps the gist,
|
|
68
|
-
// and colours
|
|
68
|
+
// and colours the state word the renderer chose and the lane's label. It keys the lanes,
|
|
69
69
|
// appends what the host observed of a lane, and speaks every fallback: a CLI
|
|
70
70
|
// that relays no plan, a pane narrower than the board, a renderer that throws.
|
|
71
71
|
//
|
|
72
|
+
// THE AGENTS' NAMES AND THE PALETTE. The
|
|
73
|
+
// Workflow host raises no `agent.spawn` for a Workflow agent, so the spawn
|
|
74
|
+
// event's join never fires there. Every run agent's first act is to read its
|
|
75
|
+
// own briefing, and that Read or Bash call names the spawn's briefing path:
|
|
76
|
+
// the module joins the agent to the spawn's row by it, through the same
|
|
77
|
+
// briefing hash. A driver line from an agent no row names marks a relay; any
|
|
78
|
+
// other agent reads `unmatched agent`, said once beneath the rows. Every colour
|
|
79
|
+
// is a theme key from `lib/meter-palette.mjs` and names a meaning, never a
|
|
80
|
+
// threshold: the engine's own figures carry none.
|
|
81
|
+
//
|
|
82
|
+
// THE STEP TIMELINE. The agents list's place is a timeline: one line per step
|
|
83
|
+
// as it crossed, the agent whose Bash call printed it folded into that line,
|
|
84
|
+
// the agents a step spawned one indent beneath it in the order they started,
|
|
85
|
+
// relays no step names at their own time, and the agents nothing names last.
|
|
86
|
+
// Every timed line carries the local `HH:MM:SS` of a reading the module took
|
|
87
|
+
// itself (the launch's, a step's stamp, an agent's first request), blank
|
|
88
|
+
// where the clock gave none; durations are humanised. The order and the
|
|
89
|
+
// step's words are `lib/meter-timeline.mjs`'s, the time words
|
|
90
|
+
// `lib/meter-time.mjs`'s. Within a wave card the lanes sit beneath their
|
|
91
|
+
// batch's sub-header, timed by when that batch was first dispatched.
|
|
92
|
+
//
|
|
72
93
|
// THE SESSION START (show-preflight-and-interrupted-runs-at-session-start
|
|
73
94
|
// design D5-D8). Outside a run the module draws two things, both from the
|
|
74
95
|
// report `hooks/preflight.mjs` leaves at `.claude/ship/preflight.json`: the
|
|
@@ -101,7 +122,10 @@
|
|
|
101
122
|
import { drawPlanBoardRows } from '../lib/draw-plan.mjs'
|
|
102
123
|
import { decideLaunch, emptyRecord, isAcceptedLaunch, isShipLaunch, withLaunch, withPrompt } from '../lib/launch-rule.mjs'
|
|
103
124
|
import { LIMITS } from '../lib/limits.mjs'
|
|
125
|
+
import { PALETTE, stateProps, turnProps } from '../lib/meter-palette.mjs'
|
|
104
126
|
import { TICK_MS, quietMs, quietWord } from '../lib/meter-quiet.mjs'
|
|
127
|
+
import { clockText, durationText, stampText } from '../lib/meter-time.mjs'
|
|
128
|
+
import { BATCH_ACTIONS, batchText, laneCountText, planWaveCount, stepText, timeline } from '../lib/meter-timeline.mjs'
|
|
105
129
|
import { guardDenialLine, guardOf, guardReason, launchGuardLine } from '../lib/meter-refusals.mjs'
|
|
106
130
|
import {
|
|
107
131
|
adoptPreflight,
|
|
@@ -144,8 +168,6 @@ const UNPLACED_TOAST = '/interlock-meter opens the ship meter'
|
|
|
144
168
|
const NO_LANES_LINE = 'no lanes dispatched yet'
|
|
145
169
|
const UNRELAYED_LINE = 'plan structure not relayed by this CLI'
|
|
146
170
|
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
171
|
// The session start's two panes, each opened by the command of its name (design D6).
|
|
150
172
|
const PREFLIGHT_PANE = 'interlock-preflight'
|
|
151
173
|
const HANDOFF_PANE = 'interlock-handoff'
|
|
@@ -166,6 +188,23 @@ const GUARD = 'interlock guard'
|
|
|
166
188
|
const DRIVER_LINE = /^interlock\s/
|
|
167
189
|
// The bootstrap line a lane's spawn prompt carries (as `lib/agent-usage.mjs` reads it).
|
|
168
190
|
const BRIEFING_SHA = /Expected sha256: ([0-9a-f]{64})/g
|
|
191
|
+
// What an agent row and a turn-end toast read for an agent no row and no driver line names.
|
|
192
|
+
const UNMATCHED = 'unmatched agent'
|
|
193
|
+
const UNMATCHED_LINE = 'unmatched: no briefing read or interlock line seen from this agent yet'
|
|
194
|
+
// The characters a briefing path may stand behind in a Read path or a command,
|
|
195
|
+
// and the ones that would make it part of a longer file name after it.
|
|
196
|
+
const PATH_BEFORE = /[\s\/'"=]/
|
|
197
|
+
const PATH_AFTER = /[\w.+-]/
|
|
198
|
+
/** The props a name is drawn with: a lane label, a spawn's title, a joined agent's name. */
|
|
199
|
+
const NAME_PROPS = { bold: true, color: PALETTE.identity }
|
|
200
|
+
const DIM = { dimColor: true }
|
|
201
|
+
// The timeline's gutter: `HH:MM:SS` and two spaces, and how far a section body
|
|
202
|
+
// and a child line sit in from what holds them (layout probe P3, Claude Code 2.1.295).
|
|
203
|
+
const GUTTER_COLUMNS = 10
|
|
204
|
+
const INDENT = 2
|
|
205
|
+
const NO_STEP_LINE = 'no step has crossed yet'
|
|
206
|
+
/** The props the run header and each section heading are drawn with. */
|
|
207
|
+
const HEADING_PROPS = { bold: true, color: PALETTE.accent }
|
|
169
208
|
const TOKEN_FIELDS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']
|
|
170
209
|
// The classic session-start sources that start the session over, as the person
|
|
171
210
|
// typed them (design D4). `startup` and `compact` are not among them.
|
|
@@ -208,6 +247,13 @@ function freshRun() {
|
|
|
208
247
|
// activity (null until the clock answers), the word it makes, and the one
|
|
209
248
|
// interval that recomputes it, known by its handle and its generation.
|
|
210
249
|
lastActivityAt: null,
|
|
250
|
+
// The reading the last stamp took (null when the clock failed), and the
|
|
251
|
+
// launch's own: the timeline's times, never guessed.
|
|
252
|
+
stampedAt: null,
|
|
253
|
+
launchedAt: null,
|
|
254
|
+
// Every step applied, in crossing order, and the time each batch was first dispatched, by `<waveIndex>:<batchIndex>`.
|
|
255
|
+
steps: [],
|
|
256
|
+
batchAt: new Map(),
|
|
211
257
|
quietWord: null,
|
|
212
258
|
tick: null,
|
|
213
259
|
generation: 0,
|
|
@@ -286,7 +332,8 @@ function position() {
|
|
|
286
332
|
if (run.change) parts.push(run.change)
|
|
287
333
|
if (run.action) parts.push(run.action)
|
|
288
334
|
if (run.wave !== undefined && run.wave !== null) parts.push(`wave ${run.wave}`)
|
|
289
|
-
|
|
335
|
+
const batch = batchText(run.batchIndex, run.batchCount)
|
|
336
|
+
if (batch) parts.push(batch)
|
|
290
337
|
return parts.join(' · ')
|
|
291
338
|
}
|
|
292
339
|
|
|
@@ -354,6 +401,7 @@ async function stamp($) {
|
|
|
354
401
|
const held = run
|
|
355
402
|
const now = await clockNow($)
|
|
356
403
|
if (held !== run) return false
|
|
404
|
+
run.stampedAt = now
|
|
357
405
|
if (now !== null) run.lastActivityAt = run.lastActivityAt === null ? now : Math.max(run.lastActivityAt, now)
|
|
358
406
|
return setWord(null)
|
|
359
407
|
}
|
|
@@ -611,6 +659,72 @@ async function noteSpawn($, input, spawned) {
|
|
|
611
659
|
return spawned
|
|
612
660
|
}
|
|
613
661
|
|
|
662
|
+
/** Whether `text` names `path` whole: behind its start or a separator, and not followed by more of a file name. */
|
|
663
|
+
function namesPath(text, path) {
|
|
664
|
+
for (let at = text.indexOf(path); at !== -1; at = text.indexOf(path, at + 1)) {
|
|
665
|
+
const before = at === 0 ? '' : text[at - 1]
|
|
666
|
+
const after = text[at + path.length] ?? ''
|
|
667
|
+
if ((before === '' || PATH_BEFORE.test(before)) && (after === '' || !PATH_AFTER.test(after))) return true
|
|
668
|
+
}
|
|
669
|
+
return false
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* The Workflow host's join: it raises no
|
|
674
|
+
* `agent.spawn` for a Workflow agent, but every run agent's first act is to
|
|
675
|
+
* read its own briefing. A Read path or a Bash command that names a
|
|
676
|
+
* dispatched spawn's briefing path joins the agent to that spawn's row,
|
|
677
|
+
* through the same briefing hash the spawn event joins on. The first briefing
|
|
678
|
+
* an agent reads wins; an agent that names none is left as it was, and no
|
|
679
|
+
* agent record is made for it. Never throws.
|
|
680
|
+
*/
|
|
681
|
+
function noteBriefing($, agentId, text) {
|
|
682
|
+
try {
|
|
683
|
+
const id = str(agentId)
|
|
684
|
+
if (!id || typeof text !== 'string') return
|
|
685
|
+
const known = run.agents.get(id)
|
|
686
|
+
if (known && known.sha) return
|
|
687
|
+
const said = text.split('\\').join('/')
|
|
688
|
+
for (const r of run.rows.values()) {
|
|
689
|
+
if (!r.sha || !r.promptPath) continue
|
|
690
|
+
if (!namesPath(said, r.promptPath.split('\\').join('/'))) continue
|
|
691
|
+
agentOf(id).sha = r.sha
|
|
692
|
+
$.ui.invalidate('ui.render')
|
|
693
|
+
return
|
|
694
|
+
}
|
|
695
|
+
} catch (err) {
|
|
696
|
+
$.ui.log(`interlock meter: a briefing read was not joined to its lane: ${messageOf(err)}`, { to: 'debug' })
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* `run next` for `interlock run next --results … --json`: the subcommand a
|
|
702
|
+
* driver line relays, as `workflows/ship.js` names its relay (`argv.slice(0, 2)`),
|
|
703
|
+
* the second word dropped when it is a flag or a shell operator.
|
|
704
|
+
*/
|
|
705
|
+
function relayOf(command) {
|
|
706
|
+
const words = command.trim().split(/\s+/).slice(1, 3)
|
|
707
|
+
if (!words.length || !words[0]) return null
|
|
708
|
+
const second = words[1]
|
|
709
|
+
return second && !/^(--|[;&|<>])/.test(second) ? `${words[0]} ${second}` : words[0]
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** An agent that ran a driver line before any briefing named it is a relay, shown by what it relayed. Never throws. */
|
|
713
|
+
function noteRelay($, agentId, command) {
|
|
714
|
+
try {
|
|
715
|
+
const id = str(agentId)
|
|
716
|
+
if (!id) return
|
|
717
|
+
const known = run.agents.get(id)
|
|
718
|
+
if (known && known.sha) return
|
|
719
|
+
const relay = relayOf(command)
|
|
720
|
+
if (!relay || (known && known.relay === relay)) return
|
|
721
|
+
agentOf(id).relay = relay
|
|
722
|
+
$.ui.invalidate('ui.render')
|
|
723
|
+
} catch (err) {
|
|
724
|
+
$.ui.log(`interlock meter: a driver line was not read for its agent: ${messageOf(err)}`, { to: 'debug' })
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
614
728
|
/**
|
|
615
729
|
* A clear, resume or fork ends the run the meter holds, before the chain
|
|
616
730
|
* beneath runs. Any other source, and a non-interactive session, changes
|
|
@@ -658,11 +772,14 @@ function drawSpecPane($, e) {
|
|
|
658
772
|
return h(Box, { flexDirection: 'column' }, ...specPaneLines(spec).map(l => h(Box, { key: l.key }, h(Text, props(l), l.text))))
|
|
659
773
|
}
|
|
660
774
|
|
|
661
|
-
/**
|
|
775
|
+
/**
|
|
776
|
+
* The launch guard's ruling now, over the record its branch would read, in the
|
|
777
|
+
* rule's words (design D5), and whether that ruling refuses the next launch.
|
|
778
|
+
*/
|
|
662
779
|
async function launchGuardText($) {
|
|
663
780
|
const { record, now } = await guardFacts($)
|
|
664
781
|
const ruling = decideLaunch(record, now)
|
|
665
|
-
return launchGuardLine(ruling, now)
|
|
782
|
+
return { text: launchGuardLine(ruling, now), refused: now !== null && ruling.decision !== 'allow' && Boolean(ruling.reason) }
|
|
666
783
|
}
|
|
667
784
|
|
|
668
785
|
/** Names, once per run record, a step that crossed with no live run (design D4). */
|
|
@@ -719,14 +836,27 @@ function agentOf(id) {
|
|
|
719
836
|
reason: null,
|
|
720
837
|
durationMs: null,
|
|
721
838
|
sha: null,
|
|
839
|
+
// The two words after `interlock` in the first driver line it ran while no row named it.
|
|
840
|
+
relay: null,
|
|
722
841
|
// Whether its turn end has been spoken: once per agent per run (speak-lane-turn-ends design D1).
|
|
723
|
-
endToasted: false
|
|
842
|
+
endToasted: false,
|
|
843
|
+
// The reading at its first request, taken once (null when the clock failed then), and at its turn end.
|
|
844
|
+
stepped: false,
|
|
845
|
+
firstAt: null,
|
|
846
|
+
endAt: null
|
|
724
847
|
}
|
|
725
848
|
run.agents.set(id, a)
|
|
726
849
|
}
|
|
727
850
|
return a
|
|
728
851
|
}
|
|
729
852
|
|
|
853
|
+
/** The agent's first request, timed once by the stamp just taken: a later reading never moves it. */
|
|
854
|
+
function firstRequest(a) {
|
|
855
|
+
if (a.stepped) return
|
|
856
|
+
a.stepped = true
|
|
857
|
+
a.firstAt = run.stampedAt
|
|
858
|
+
}
|
|
859
|
+
|
|
730
860
|
/** Field by field: a field one request omitted leaves that figure unknown (`null`), never a guessed zero (design D5). */
|
|
731
861
|
function addUsage(a, usage) {
|
|
732
862
|
a.requests++
|
|
@@ -812,7 +942,34 @@ function takeBoard($, record) {
|
|
|
812
942
|
if (record.action === 'halt') run.halt = { reason: str(record.reason) || (run.halt ? run.halt.reason : null) }
|
|
813
943
|
}
|
|
814
944
|
|
|
815
|
-
|
|
945
|
+
/**
|
|
946
|
+
* One step as the timeline draws it, its fields the record's own: the time the
|
|
947
|
+
* stamp read, its position, a skip or halt reason, the size of a plan summary
|
|
948
|
+
* it carried, the agent whose Bash call printed it, and the spawns it named.
|
|
949
|
+
*/
|
|
950
|
+
function noteStep(record, relayId, spawns) {
|
|
951
|
+
const step = {
|
|
952
|
+
n: run.steps.length,
|
|
953
|
+
at: run.stampedAt,
|
|
954
|
+
action: record.action,
|
|
955
|
+
wave: record.wave,
|
|
956
|
+
waveIndex: record.waveIndex,
|
|
957
|
+
batchIndex: record.batchIndex,
|
|
958
|
+
batchCount: record.batchCount,
|
|
959
|
+
skipped: record.skipped,
|
|
960
|
+
reason: record.reason,
|
|
961
|
+
planWaves: isPlanSummary(record.plan) ? planWaveCount(record.plan) : null,
|
|
962
|
+
relayId: str(relayId),
|
|
963
|
+
spawns
|
|
964
|
+
}
|
|
965
|
+
run.steps.push(step)
|
|
966
|
+
if (BATCH_ACTIONS.has(record.action) && isInt(record.waveIndex) && isInt(record.batchIndex)) {
|
|
967
|
+
const at = `${record.waveIndex}:${record.batchIndex}`
|
|
968
|
+
if (!run.batchAt.has(at)) run.batchAt.set(at, step.at)
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async function applyRecord($, record, relayId) {
|
|
816
973
|
const held = run
|
|
817
974
|
await stamp($)
|
|
818
975
|
if (held !== run) return
|
|
@@ -822,8 +979,10 @@ async function applyRecord($, record) {
|
|
|
822
979
|
if (record.wave !== undefined) run.wave = record.wave
|
|
823
980
|
run.batchIndex = record.batchIndex
|
|
824
981
|
run.batchCount = record.batchCount
|
|
982
|
+
const spawns = []
|
|
825
983
|
for (const s of Array.isArray(record.spawns) ? record.spawns : []) {
|
|
826
984
|
if (!s || typeof s !== 'object' || !str(s.label)) continue
|
|
985
|
+
spawns.push({ label: s.label, title: str(s.title) || s.label, sha: str(s.promptSha256) })
|
|
827
986
|
run.rows.set(s.label, {
|
|
828
987
|
label: s.label,
|
|
829
988
|
title: str(s.title) || s.label,
|
|
@@ -831,10 +990,13 @@ async function applyRecord($, record) {
|
|
|
831
990
|
model: str(s.model),
|
|
832
991
|
effort: str(s.effort),
|
|
833
992
|
sha: str(s.promptSha256),
|
|
993
|
+
// The briefing the spawn names, read off the agent's own first read of it.
|
|
994
|
+
promptPath: str(s.promptPath),
|
|
834
995
|
// Spawned by a step that dispatches a batch: a lane, planned or not.
|
|
835
996
|
batch: BATCH_ACTIONS.has(record.action)
|
|
836
997
|
})
|
|
837
998
|
}
|
|
999
|
+
noteStep(record, relayId, spawns)
|
|
838
1000
|
takeBoard($, record)
|
|
839
1001
|
for (const banner of Array.isArray(record.banners) ? record.banners : []) {
|
|
840
1002
|
if (typeof banner !== 'string' || !banner) continue
|
|
@@ -979,9 +1141,14 @@ function rowForAgent(a) {
|
|
|
979
1141
|
return null
|
|
980
1142
|
}
|
|
981
1143
|
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1144
|
+
/**
|
|
1145
|
+
* What an agent is called: its joined spawn's title, else `cli · <what it
|
|
1146
|
+
* relayed>` for a driver-line agent, else `null`, which reads `unmatched agent`.
|
|
1147
|
+
*/
|
|
1148
|
+
function agentName(a) {
|
|
1149
|
+
const row = rowForAgent(a)
|
|
1150
|
+
if (row) return row.title
|
|
1151
|
+
return a.relay ? `cli · ${a.relay}` : null
|
|
985
1152
|
}
|
|
986
1153
|
|
|
987
1154
|
/**
|
|
@@ -1006,25 +1173,133 @@ function sessionLines(usage, failure) {
|
|
|
1006
1173
|
}
|
|
1007
1174
|
|
|
1008
1175
|
/**
|
|
1009
|
-
* `
|
|
1010
|
-
*
|
|
1011
|
-
*
|
|
1176
|
+
* `started <local date and time> · last activity <local time>` (the start left
|
|
1177
|
+
* out when the launch read no time, `last activity unknown` when no stamp did),
|
|
1178
|
+
* then the quiet word while a live run is quiet, as the line and the word
|
|
1179
|
+
* apart. The word is recomputed from the clock at each draw, so a pane opened
|
|
1180
|
+
* after the host stopped the interval still shows it (design D2, D5).
|
|
1012
1181
|
*/
|
|
1013
1182
|
async function lastActivityText($) {
|
|
1014
|
-
|
|
1015
|
-
|
|
1183
|
+
const started = run.launchedAt === null ? '' : `started ${stampText(run.launchedAt)} · `
|
|
1184
|
+
if (run.lastActivityAt === null) return { line: `${started}last activity unknown`, word: null }
|
|
1016
1185
|
const word = run.phase === 'live' ? quietWord(quietMs(run.lastActivityAt, await clockNow($))) : null
|
|
1017
|
-
return
|
|
1186
|
+
return { line: `${started}last activity ${clockText(run.lastActivityAt)}`, word }
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/** An agent id as a line shows it: its first eight characters. */
|
|
1190
|
+
const id8 = id => String(id).slice(0, 8)
|
|
1191
|
+
|
|
1192
|
+
/** `1 request`, `N requests`. */
|
|
1193
|
+
const requestsText = a => (a.requests === 1 ? '1 request' : `${a.requests} requests`)
|
|
1194
|
+
|
|
1195
|
+
/** An agent's turn: `running`, else the host's word and, once reported, how long the turn took. */
|
|
1196
|
+
const turnText = a =>
|
|
1197
|
+
a.reason ? `${a.reason}${a.durationMs !== null ? ` ${durationText(a.durationMs)}` : ''}` : 'running'
|
|
1198
|
+
|
|
1199
|
+
/** Whether the host ended the agent's turn without an answer. */
|
|
1200
|
+
const abnormal = a => Boolean(a.reason) && a.reason !== 'answer'
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* An agent's first line, one wrapping text: its name, its id and models dim,
|
|
1204
|
+
* then its turn word. A joined agent is named in the identity colour, a relay
|
|
1205
|
+
* dim, an unmatched agent dim and italic. The turn word is the host's, coloured
|
|
1206
|
+
* by what it means; a relay's running or answered turn stays dim, and only its
|
|
1207
|
+
* abnormal end is coloured.
|
|
1208
|
+
*/
|
|
1209
|
+
function agentLine(Text, a, name, kind) {
|
|
1210
|
+
const nameProps = kind === 'joined' ? NAME_PROPS : kind === 'relay' ? DIM : { dimColor: true, italic: true }
|
|
1211
|
+
const quiet = kind === 'relay' && !abnormal(a)
|
|
1212
|
+
return h(
|
|
1213
|
+
Text,
|
|
1214
|
+
{ wrap: 'wrap' },
|
|
1215
|
+
h(Text, nameProps, name),
|
|
1216
|
+
h(Text, DIM, ` · ${id8(a.id)} · ${a.models.join(', ') || 'model not reported'}`),
|
|
1217
|
+
' · ',
|
|
1218
|
+
h(Text, quiet ? DIM : turnProps(a.reason), turnText(a))
|
|
1219
|
+
)
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* One timed line (layout probe P3): the local time in a fixed dim gutter,
|
|
1224
|
+
* blank when no time was read, then the content in a column that wraps beside
|
|
1225
|
+
* it, `depth` steps in. Each content line is one Text with its coloured words
|
|
1226
|
+
* nested inside it, never a row of sibling Texts.
|
|
1227
|
+
*/
|
|
1228
|
+
function gutterRow(Box, Text, key, at, depth, ...content) {
|
|
1229
|
+
return h(
|
|
1230
|
+
Box,
|
|
1231
|
+
{ key, flexDirection: 'row' },
|
|
1232
|
+
h(Box, { width: GUTTER_COLUMNS, flexShrink: 0 }, h(Text, DIM, clockText(at))),
|
|
1233
|
+
h(Box, { flexGrow: 1, flexShrink: 1, flexDirection: 'column', paddingLeft: depth * INDENT }, ...content)
|
|
1234
|
+
)
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* A step's line: its own words, then the agent whose Bash call printed it,
|
|
1239
|
+
* dim, with its served models, its requests and, once its turn ended, how long
|
|
1240
|
+
* it took; the relay's turn word only when the host ended it without an answer.
|
|
1241
|
+
*/
|
|
1242
|
+
function stepLine(Text, step) {
|
|
1243
|
+
const spans = [stepText(step)]
|
|
1244
|
+
if (step.relayId) {
|
|
1245
|
+
const a = run.agents.get(step.relayId)
|
|
1246
|
+
const figures = a ? ` · ${a.models.join(', ') || 'model not reported'} · ${requestsText(a)}` : ''
|
|
1247
|
+
spans.push(h(Text, DIM, ` · cli ${id8(step.relayId)}${figures}`))
|
|
1248
|
+
if (a && abnormal(a)) spans.push(' · ', h(Text, turnProps(a.reason), turnText(a)))
|
|
1249
|
+
else if (a && a.durationMs !== null) spans.push(h(Text, DIM, ` · ${durationText(a.durationMs)}`))
|
|
1250
|
+
}
|
|
1251
|
+
return h(Text, { wrap: 'wrap' }, ...spans)
|
|
1018
1252
|
}
|
|
1019
1253
|
|
|
1020
|
-
/**
|
|
1021
|
-
|
|
1254
|
+
/**
|
|
1255
|
+
* The timeline section: `lib/meter-timeline.mjs`'s entries, each a gutter
|
|
1256
|
+
* line. An agent a step spawned and an agent no step names keep the requests
|
|
1257
|
+
* and tokens line beneath their own; a relay's figures ride its step's line or
|
|
1258
|
+
* its own one line, and its token breakdown is the trajectory's.
|
|
1259
|
+
*/
|
|
1260
|
+
function drawTimeline($, e) {
|
|
1261
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
1262
|
+
const entries = timeline({
|
|
1263
|
+
steps: run.steps,
|
|
1264
|
+
agents: [...run.agents.values()],
|
|
1265
|
+
launch: { at: run.launchedAt, name: run.workflowName }
|
|
1266
|
+
})
|
|
1267
|
+
const figures = a => h(Text, { dimColor: true, wrap: 'wrap' }, `${requestsText(a)} · ${tokensText(a)}`)
|
|
1268
|
+
const out = []
|
|
1269
|
+
for (const entry of entries) {
|
|
1270
|
+
const row = (...content) => gutterRow(Box, Text, entry.key, entry.at, entry.depth, ...content)
|
|
1271
|
+
if (entry.kind === 'launch') out.push(row(h(Text, { wrap: 'wrap' }, `launch · ${entry.name || 'interlock:ship'}`)))
|
|
1272
|
+
else if (entry.kind === 'step') out.push(row(stepLine(Text, entry.step)))
|
|
1273
|
+
else if (entry.kind === 'spawn' && entry.agent) {
|
|
1274
|
+
out.push(row(agentLine(Text, entry.agent, entry.spawn.title, 'joined'), figures(entry.agent)))
|
|
1275
|
+
} else if (entry.kind === 'spawn') {
|
|
1276
|
+
out.push(row(h(Text, { wrap: 'wrap' }, h(Text, NAME_PROPS, entry.spawn.title), ' · ', h(Text, DIM, 'waiting'))))
|
|
1277
|
+
} else if (entry.kind === 'relay') out.push(row(agentLine(Text, entry.agent, `cli · ${entry.agent.relay}`, 'relay')))
|
|
1278
|
+
else if (entry.kind === 'unmatched-head') out.push(h(Box, { key: entry.key }, h(Text, DIM, 'unmatched')))
|
|
1279
|
+
else if (entry.kind === 'unmatched') out.push(row(agentLine(Text, entry.agent, UNMATCHED, 'unmatched'), figures(entry.agent)))
|
|
1280
|
+
}
|
|
1281
|
+
if (entries.some(entry => entry.kind === 'unmatched')) {
|
|
1282
|
+
out.push(h(Box, { key: 'agents-unmatched', paddingLeft: INDENT }, h(Text, { dimColor: true, wrap: 'wrap' }, UNMATCHED_LINE)))
|
|
1283
|
+
}
|
|
1284
|
+
if (!run.steps.length) out.push(h(Box, { key: 'timeline-no-step' }, h(Text, DIM, NO_STEP_LINE)))
|
|
1285
|
+
return out
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/** One spawn as a flat row, as the section drew every spawn before the board: its title in the identity colour. */
|
|
1289
|
+
function flatRow($, e, r) {
|
|
1290
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
1022
1291
|
const a = agentForSha(r.sha)
|
|
1023
1292
|
const served = a && a.models.length ? a.models.join(', ') : '?'
|
|
1024
1293
|
const state = a ? a.reason || 'running' : 'waiting'
|
|
1025
|
-
return
|
|
1026
|
-
|
|
1027
|
-
|
|
1294
|
+
return h(
|
|
1295
|
+
Box,
|
|
1296
|
+
{ key: `wave-row-${r.label}` },
|
|
1297
|
+
h(
|
|
1298
|
+
Text,
|
|
1299
|
+
null,
|
|
1300
|
+
h(Text, NAME_PROPS, r.title),
|
|
1301
|
+
` · ${r.kind || 'agent'} · routed ${r.model || '?'} · served ${served} · effort ${r.effort || '?'} · ${state}`
|
|
1302
|
+
)
|
|
1028
1303
|
)
|
|
1029
1304
|
}
|
|
1030
1305
|
|
|
@@ -1051,7 +1326,7 @@ function laneNotes() {
|
|
|
1051
1326
|
for (const r of run.rows.values()) {
|
|
1052
1327
|
const a = agentForSha(r.sha)
|
|
1053
1328
|
const served = a && a.models.length ? a.models.join(', ') : '?'
|
|
1054
|
-
notes[r.label] = `served ${served}${a ? ` · ${a
|
|
1329
|
+
notes[r.label] = `served ${served}${a ? ` · ${turnText(a)}` : ''}`
|
|
1055
1330
|
}
|
|
1056
1331
|
return notes
|
|
1057
1332
|
}
|
|
@@ -1064,22 +1339,13 @@ function boardKey(key) {
|
|
|
1064
1339
|
return `${kind === 'wave' ? 'wave-block' : `wave-${kind}`}-${rest.join('-')}`
|
|
1065
1340
|
}
|
|
1066
1341
|
|
|
1067
|
-
/**
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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. */
|
|
1342
|
+
/**
|
|
1343
|
+
* The width a card and the renderer share: the section body (the pane's body
|
|
1344
|
+
* less the body's indent beneath its heading), capped at the published default.
|
|
1345
|
+
*/
|
|
1079
1346
|
function boardColumns(e) {
|
|
1080
|
-
const
|
|
1081
|
-
|
|
1082
|
-
: LIMITS.waveBoardDefaultColumns
|
|
1347
|
+
const pane = e.props && isInt(e.props.bodyColumns) && e.props.bodyColumns > 0 ? e.props.bodyColumns : null
|
|
1348
|
+
const body = pane === null ? LIMITS.waveBoardDefaultColumns : Math.max(1, pane - INDENT)
|
|
1083
1349
|
return body < LIMITS.waveBoardMinColumns ? body : Math.min(body, LIMITS.waveBoardDefaultColumns)
|
|
1084
1350
|
}
|
|
1085
1351
|
|
|
@@ -1108,28 +1374,71 @@ function idSpans(Text, parts) {
|
|
|
1108
1374
|
}
|
|
1109
1375
|
|
|
1110
1376
|
/**
|
|
1111
|
-
* A lane
|
|
1112
|
-
*
|
|
1113
|
-
*
|
|
1114
|
-
|
|
1377
|
+
* A lane's label, model, tier and effort cells, space-joined as the board
|
|
1378
|
+
* prints them, the label a span in the identity colour. The batch cell is the
|
|
1379
|
+
* sub-header the lane sits under, so the lane does not repeat it.
|
|
1380
|
+
*/
|
|
1381
|
+
function headSpans(Text, parts) {
|
|
1382
|
+
const after = [parts.model, parts.tier, parts.effort].filter(Boolean).join(' ')
|
|
1383
|
+
if (!parts.label) return [after]
|
|
1384
|
+
return [h(Text, NAME_PROPS, parts.label), ...(after ? [` ${after}`] : [])]
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
/** Whether a lane the board drew holds the cursor: its own state word, or a task's, is `current`. */
|
|
1388
|
+
const isCurrentLane = parts =>
|
|
1389
|
+
Boolean(parts) &&
|
|
1390
|
+
(parts.state === 'current' || (Array.isArray(parts.tasks) && parts.tasks.some(task => task && task.word === 'current')))
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* A lane in its card, one step in beneath its batch's sub-header with a blank
|
|
1394
|
+
* gutter: the fixed cells as one Text, the gist wrapping beneath, then the
|
|
1395
|
+
* host's note, dim. The coloured words are spans nested in that one Text,
|
|
1396
|
+
* because a row of separate elements shrinks each into its own column and
|
|
1397
|
+
* breaks words apart once the line is wider than the card.
|
|
1115
1398
|
*/
|
|
1116
1399
|
function drawLane($, e, row) {
|
|
1117
1400
|
const { Box, Text } = $.ui.resolve(e)
|
|
1118
1401
|
const key = boardKey(row.key)
|
|
1119
1402
|
const parts = row.parts
|
|
1120
1403
|
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
1404
|
const line = [
|
|
1123
|
-
...(
|
|
1405
|
+
...headSpans(Text, parts),
|
|
1406
|
+
...(parts.state ? [' ', h(Text, stateProps(parts.state), parts.state)] : []),
|
|
1124
1407
|
...idSpans(Text, parts),
|
|
1125
1408
|
...(Array.isArray(parts.after) && parts.after.length ? [` ←${parts.after.join(',')}`] : [])
|
|
1126
1409
|
]
|
|
1127
1410
|
const kids = [
|
|
1128
1411
|
h(Text, { wrap: 'wrap' }, ...line),
|
|
1129
1412
|
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
|
|
1413
|
+
parts.note ? h(Box, { key: `${key}-note` }, h(Text, { dimColor: true, wrap: 'wrap' }, parts.note)) : null
|
|
1131
1414
|
].filter(Boolean)
|
|
1132
|
-
return
|
|
1415
|
+
return gutterRow(Box, Text, key, null, 1, ...kids)
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* A card's lanes, grouped by the batch the board printed for each: before each
|
|
1420
|
+
* group a gutter line with the time that batch was first dispatched and how
|
|
1421
|
+
* many lanes it runs side by side. It carries no state word: the cursor card's
|
|
1422
|
+
* border already marks where the run is.
|
|
1423
|
+
*/
|
|
1424
|
+
function drawBatches($, e, index, lanes) {
|
|
1425
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
1426
|
+
const groups = new Map()
|
|
1427
|
+
for (const row of lanes) {
|
|
1428
|
+
const batch = row.parts && typeof row.parts.batch === 'string' ? row.parts.batch : null
|
|
1429
|
+
if (!groups.has(batch)) groups.set(batch, [])
|
|
1430
|
+
groups.get(batch).push(row)
|
|
1431
|
+
}
|
|
1432
|
+
const out = []
|
|
1433
|
+
for (const [batch, rows] of groups) {
|
|
1434
|
+
const k = batch && /^b\d+$/.test(batch) ? batch.slice(1) : null
|
|
1435
|
+
if (k !== null) {
|
|
1436
|
+
const at = run.batchAt.has(`${index}:${k}`) ? run.batchAt.get(`${index}:${k}`) : null
|
|
1437
|
+
out.push(gutterRow(Box, Text, `wave-batch-${index}-${k}`, at, 0, h(Text, { wrap: 'wrap' }, `${batch} · ${laneCountText(rows.length)}`)))
|
|
1438
|
+
}
|
|
1439
|
+
out.push(...rows.map(row => drawLane($, e, row)))
|
|
1440
|
+
}
|
|
1441
|
+
return out
|
|
1133
1442
|
}
|
|
1134
1443
|
|
|
1135
1444
|
function drawBoardCards($, e, board, width) {
|
|
@@ -1138,8 +1447,12 @@ function drawBoardCards($, e, board, width) {
|
|
|
1138
1447
|
const out = []
|
|
1139
1448
|
let index = null
|
|
1140
1449
|
let kids = null
|
|
1450
|
+
let lanes = null
|
|
1451
|
+
// Whether the card being filled holds a lane at the cursor: its border is then the current colour.
|
|
1452
|
+
let current = false
|
|
1141
1453
|
const flush = () => {
|
|
1142
1454
|
if (index === null) return
|
|
1455
|
+
kids.push(...drawBatches($, e, index, lanes))
|
|
1143
1456
|
out.push(
|
|
1144
1457
|
h(
|
|
1145
1458
|
Box,
|
|
@@ -1147,7 +1460,7 @@ function drawBoardCards($, e, board, width) {
|
|
|
1147
1460
|
key: `wave-card-${index}`,
|
|
1148
1461
|
flexDirection: 'column',
|
|
1149
1462
|
borderStyle: 'single',
|
|
1150
|
-
borderDimColor: true,
|
|
1463
|
+
...(current ? { borderColor: PALETTE.current } : { borderDimColor: true }),
|
|
1151
1464
|
width,
|
|
1152
1465
|
paddingLeft: 1,
|
|
1153
1466
|
paddingRight: 1,
|
|
@@ -1158,6 +1471,8 @@ function drawBoardCards($, e, board, width) {
|
|
|
1158
1471
|
)
|
|
1159
1472
|
index = null
|
|
1160
1473
|
kids = null
|
|
1474
|
+
lanes = null
|
|
1475
|
+
current = false
|
|
1161
1476
|
}
|
|
1162
1477
|
for (const row of board) {
|
|
1163
1478
|
if (row.key.startsWith('wave:') && row.key.endsWith(':end')) continue
|
|
@@ -1165,12 +1480,14 @@ function drawBoardCards($, e, board, width) {
|
|
|
1165
1480
|
flush()
|
|
1166
1481
|
index = row.key.slice('wave:'.length)
|
|
1167
1482
|
kids = [h(Box, { key: boardKey(row.key) }, h(Text, { bold: true }, waveTitleOf(row)))]
|
|
1483
|
+
lanes = []
|
|
1168
1484
|
continue
|
|
1169
1485
|
}
|
|
1170
1486
|
if (row.key.startsWith('lane:')) {
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1487
|
+
if (lanes) {
|
|
1488
|
+
lanes.push(row)
|
|
1489
|
+
if (isCurrentLane(row.parts)) current = true
|
|
1490
|
+
} else out.push(drawLane($, e, row))
|
|
1174
1491
|
continue
|
|
1175
1492
|
}
|
|
1176
1493
|
flush()
|
|
@@ -1197,7 +1514,7 @@ function drawWaves($, e) {
|
|
|
1197
1514
|
if (!run.plan) {
|
|
1198
1515
|
if (!rows.length) return [h(Text, { dimColor: true }, NO_LANES_LINE)]
|
|
1199
1516
|
const dispatched = run.dispatched !== null || rows.some(r => r.batch)
|
|
1200
|
-
return [...(dispatched ? [keyed('wave-board-unrelayed', UNRELAYED_LINE)] : []), ...rows.map(r => flatRow(
|
|
1517
|
+
return [...(dispatched ? [keyed('wave-board-unrelayed', UNRELAYED_LINE)] : []), ...rows.map(r => flatRow($, e, r))]
|
|
1201
1518
|
}
|
|
1202
1519
|
const width = boardColumns(e)
|
|
1203
1520
|
let board
|
|
@@ -1205,49 +1522,50 @@ function drawWaves($, e) {
|
|
|
1205
1522
|
board = drawPlanBoardRows(run.plan, { columns: width, state: boardOverlay(run.plan), notes: laneNotes() })
|
|
1206
1523
|
} catch (err) {
|
|
1207
1524
|
$.ui.log(`interlock meter: ${BOARD_FAILED_LINE}: ${messageOf(err)}`, { to: 'debug' })
|
|
1208
|
-
return [keyed('wave-board-failed', BOARD_FAILED_LINE), ...rows.map(r => flatRow(
|
|
1525
|
+
return [keyed('wave-board-failed', BOARD_FAILED_LINE), ...rows.map(r => flatRow($, e, r))]
|
|
1209
1526
|
}
|
|
1210
1527
|
// Every board ends in its tail; the renderer's spoken line for a narrow body
|
|
1211
1528
|
// is one row without one, and the lanes are then drawn flat beneath it.
|
|
1212
1529
|
const drawn = board.length > 0 && board[board.length - 1].key === 'tail'
|
|
1213
1530
|
const planned = new Set(drawn ? board.filter(row => row.key.startsWith('lane:')).map(row => row.key.slice('lane:'.length)) : [])
|
|
1214
1531
|
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(
|
|
1532
|
+
return [...body, ...rows.filter(r => !planned.has(r.label)).map(r => flatRow($, e, r))]
|
|
1216
1533
|
}
|
|
1217
1534
|
|
|
1218
1535
|
async function drawPane($, e) {
|
|
1219
1536
|
const { Box, Text } = $.ui.resolve(e)
|
|
1220
1537
|
const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
|
|
1221
|
-
const heading = text => h(Box, { marginTop: 1 }, h(Text,
|
|
1538
|
+
const heading = text => h(Box, { marginTop: 1 }, h(Text, HEADING_PROPS, text))
|
|
1539
|
+
// Each section's body sits one indent in beneath its heading.
|
|
1540
|
+
const body = (key, ...kids) => h(Box, { key, flexDirection: 'column', paddingLeft: INDENT }, ...kids)
|
|
1541
|
+
// The launch-guard line, in the alarm colour only while its ruling refuses the next launch.
|
|
1542
|
+
const guardLine = async () => {
|
|
1543
|
+
const guard = await launchGuardText($)
|
|
1544
|
+
return keyed('launch-guard', guard.text, guard.refused ? { color: PALETTE.alarm } : null)
|
|
1545
|
+
}
|
|
1222
1546
|
if (run.phase === 'idle' && !run.summary) {
|
|
1223
1547
|
// 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),
|
|
1548
|
+
return h(Box, { flexDirection: 'column' }, h(Text, { dimColor: true }, NO_RUN_LINE), await guardLine())
|
|
1225
1549
|
}
|
|
1226
1550
|
|
|
1227
1551
|
const out = []
|
|
1228
|
-
out.push(h(Text,
|
|
1552
|
+
out.push(h(Text, HEADING_PROPS, `${run.change || 'ship run'}${run.runId ? ` · run ${run.runId}` : ''}`))
|
|
1229
1553
|
out.push(keyed('action', `action: ${run.action || 'starting'}${run.phase === 'closed' ? ' (closed)' : ''}`))
|
|
1230
|
-
|
|
1554
|
+
const activity = await lastActivityText($)
|
|
1555
|
+
out.push(
|
|
1556
|
+
h(
|
|
1557
|
+
Box,
|
|
1558
|
+
{ key: 'last-activity' },
|
|
1559
|
+
h(Text, null, activity.line, ...(activity.word ? [' · ', h(Text, { color: PALETTE.warn }, activity.word)] : []))
|
|
1560
|
+
)
|
|
1561
|
+
)
|
|
1231
1562
|
out.push(h(Text, { dimColor: true }, RECORD_LINE))
|
|
1232
1563
|
|
|
1233
1564
|
out.push(heading('waves'))
|
|
1234
|
-
out.push(...drawWaves($, e))
|
|
1235
|
-
|
|
1236
|
-
out.push(heading('
|
|
1237
|
-
|
|
1238
|
-
for (const a of run.agents.values()) {
|
|
1239
|
-
const lane = laneForAgent(a)
|
|
1240
|
-
const n = a.requests === 1 ? '1 request' : `${a.requests} requests`
|
|
1241
|
-
const done = a.reason ? `${a.reason}${a.durationMs !== null ? ` ${(a.durationMs / 1000).toFixed(1)}s` : ''}` : 'running'
|
|
1242
|
-
out.push(
|
|
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)}`))
|
|
1248
|
-
)
|
|
1249
|
-
)
|
|
1250
|
-
}
|
|
1565
|
+
out.push(body('waves-body', ...drawWaves($, e)))
|
|
1566
|
+
|
|
1567
|
+
out.push(heading('timeline'))
|
|
1568
|
+
out.push(body('timeline-body', ...drawTimeline($, e)))
|
|
1251
1569
|
|
|
1252
1570
|
// One plain read feeds the session lines and the plan windows; a breakdown
|
|
1253
1571
|
// is never asked for, because only the plain call is free (design D4).
|
|
@@ -1261,36 +1579,35 @@ async function drawPane($, e) {
|
|
|
1261
1579
|
}
|
|
1262
1580
|
const [contextLine, costLine] = sessionLines(usage, failure)
|
|
1263
1581
|
out.push(heading('session'))
|
|
1264
|
-
out.push(keyed('session-context', contextLine))
|
|
1265
|
-
out.push(keyed('session-cost', costLine))
|
|
1582
|
+
out.push(body('session-body', keyed('session-context', contextLine), keyed('session-cost', costLine)))
|
|
1266
1583
|
|
|
1267
1584
|
out.push(heading('plan windows'))
|
|
1268
1585
|
const windows = usage && Array.isArray(usage.rateLimits) ? usage.rateLimits : []
|
|
1269
|
-
|
|
1270
|
-
|
|
1586
|
+
const planLines = []
|
|
1587
|
+
if (failure !== null) planLines.push(h(Text, { dimColor: true }, `plan windows unavailable: ${failure}`))
|
|
1588
|
+
else if (!windows.length) planLines.push(h(Text, { dimColor: true }, 'none reported (off a subscription the session has none)'))
|
|
1271
1589
|
for (const w of windows) {
|
|
1272
1590
|
if (!w || typeof w !== 'object') continue
|
|
1273
1591
|
const kind = String(w.kind)
|
|
1274
|
-
|
|
1592
|
+
planLines.push(keyed(`plan-window-${kind}`, `${kind} ${w.percentUsed}% used${w.resetsAt ? ` · resets ${w.resetsAt}` : ''}`))
|
|
1275
1593
|
}
|
|
1594
|
+
out.push(body('plan-windows-body', ...planLines))
|
|
1276
1595
|
|
|
1277
1596
|
out.push(heading('banners'))
|
|
1278
1597
|
out.push(
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
{
|
|
1282
|
-
...(run.banners.length ? run.banners.map(b => h(Text, null, b)) : [h(Text, { dimColor: true }, 'none so far')])
|
|
1598
|
+
body(
|
|
1599
|
+
'banners',
|
|
1600
|
+
...(run.banners.length ? run.banners.map(b => h(Text, { color: PALETTE.warn }, b)) : [h(Text, { dimColor: true }, 'none so far')])
|
|
1283
1601
|
)
|
|
1284
1602
|
)
|
|
1285
1603
|
|
|
1286
1604
|
out.push(heading('refusals'))
|
|
1287
1605
|
const denials = guardDenialLine(run.guardDenials)
|
|
1288
|
-
|
|
1289
|
-
out.push(keyed('launch-guard', await launchGuardText($)))
|
|
1606
|
+
out.push(body('refusals-body', ...(denials ? [keyed('guard-denials', denials, { color: PALETTE.alarm })] : []), await guardLine()))
|
|
1290
1607
|
|
|
1291
1608
|
if (run.summary) {
|
|
1292
1609
|
out.push(heading('close summary'))
|
|
1293
|
-
out.push(
|
|
1610
|
+
out.push(body('summary', h(Text, null, run.summary)))
|
|
1294
1611
|
}
|
|
1295
1612
|
return h(Box, { flexDirection: 'column' }, ...out)
|
|
1296
1613
|
}
|
|
@@ -1305,7 +1622,7 @@ export function register(on) {
|
|
|
1305
1622
|
try {
|
|
1306
1623
|
await $.command.register({
|
|
1307
1624
|
name: PANE,
|
|
1308
|
-
description: 'Open the Interlock ship meter: the live run, its waves,
|
|
1625
|
+
description: 'Open the Interlock ship meter: the live run, its waves, step timeline, plan windows and banners',
|
|
1309
1626
|
immediate: true
|
|
1310
1627
|
})
|
|
1311
1628
|
} catch (err) {
|
|
@@ -1422,6 +1739,7 @@ export function register(on) {
|
|
|
1422
1739
|
run.workflowName = str(r.result.workflowName)
|
|
1423
1740
|
run.transcriptDir = str(r.result.transcriptDir)
|
|
1424
1741
|
run.lastActivityAt = now
|
|
1742
|
+
run.launchedAt = now
|
|
1425
1743
|
$.ui.invalidate('ui.render')
|
|
1426
1744
|
const placed = await openPane($)
|
|
1427
1745
|
if (!placed || placed.isPlaced !== true) $.ui.toast(UNPLACED_TOAST)
|
|
@@ -1444,6 +1762,12 @@ export function register(on) {
|
|
|
1444
1762
|
return r
|
|
1445
1763
|
}
|
|
1446
1764
|
const held = run
|
|
1765
|
+
// A run agent's command names it: a
|
|
1766
|
+
// briefing it reads joins it to its lane, and a driver line names a relay.
|
|
1767
|
+
if (run.phase === 'live' && str(e.agentId)) {
|
|
1768
|
+
noteBriefing($, e.agentId, command)
|
|
1769
|
+
if (driver) noteRelay($, e.agentId, command)
|
|
1770
|
+
}
|
|
1447
1771
|
const r = await next(e)
|
|
1448
1772
|
// Any Bash call during the run is read for a guard's refusal (guard-commit); only a driver line for a step.
|
|
1449
1773
|
if (held === run) speakRefusal($, r)
|
|
@@ -1451,7 +1775,7 @@ export function register(on) {
|
|
|
1451
1775
|
try {
|
|
1452
1776
|
const { record, problem } = readRecord(r.text)
|
|
1453
1777
|
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)
|
|
1778
|
+
else if (held === run) await applyRecord($, record, str(e.agentId))
|
|
1455
1779
|
else if (run.phase === 'idle' && !run.idleStepNamed) nameIdleStep($)
|
|
1456
1780
|
} catch (err) {
|
|
1457
1781
|
$.ui.log(`interlock meter: step read failed: ${(err && err.message) || err}`, { to: 'debug' })
|
|
@@ -1470,6 +1794,14 @@ export function register(on) {
|
|
|
1470
1794
|
return r
|
|
1471
1795
|
})
|
|
1472
1796
|
|
|
1797
|
+
// A run agent's Read, observed for the briefing it names and passed on as it
|
|
1798
|
+
// came: the Workflow host raises no spawn event, so a briefing read is how an
|
|
1799
|
+
// agent is joined to its lane.
|
|
1800
|
+
on('tool.call', { tool: 'Read' }, async ($, e, next) => {
|
|
1801
|
+
if (session.interactive && run.phase === 'live' && e.agentId) noteBriefing($, e.agentId, e.file_path)
|
|
1802
|
+
return next(e)
|
|
1803
|
+
})
|
|
1804
|
+
|
|
1473
1805
|
// The tools the manifest's `Edit|Write` matcher names for guard-tests and guard-tasks (design D4).
|
|
1474
1806
|
on('tool.call', { tool: 'Edit' }, editCall)
|
|
1475
1807
|
on('tool.call', { tool: 'Write' }, editCall)
|
|
@@ -1477,7 +1809,9 @@ export function register(on) {
|
|
|
1477
1809
|
on('turn.step', async function* ($, e, next) {
|
|
1478
1810
|
if (!session.interactive || run.phase !== 'live' || !e.agentId) return yield* next(e)
|
|
1479
1811
|
// A run agent's request starting, and its answer arriving, are each activity (design D1).
|
|
1812
|
+
const held = run
|
|
1480
1813
|
if (await stamp($)) redrawWord($)
|
|
1814
|
+
if (held === run) firstRequest(agentOf(e.agentId))
|
|
1481
1815
|
const result = yield* next(e)
|
|
1482
1816
|
if (await stamp($)) redrawWord($)
|
|
1483
1817
|
try {
|
|
@@ -1499,14 +1833,15 @@ export function register(on) {
|
|
|
1499
1833
|
// (speak-lane-turn-ends-and-session-cost design D1-D3).
|
|
1500
1834
|
if (a.reason && a.reason !== 'answer' && !a.endToasted) {
|
|
1501
1835
|
a.endToasted = true
|
|
1502
|
-
const row = rowForAgent(a)
|
|
1503
1836
|
try {
|
|
1504
|
-
$.ui.toast(`${
|
|
1837
|
+
$.ui.toast(`${agentName(a) || UNMATCHED} · agent ${a.id} · turn ended: ${a.reason}`)
|
|
1505
1838
|
} catch (err) {
|
|
1506
1839
|
$.ui.log(`interlock meter: the turn-end toast for ${a.id} was refused: ${messageOf(err)}`, { to: 'debug' })
|
|
1507
1840
|
}
|
|
1508
1841
|
}
|
|
1842
|
+
const held = run
|
|
1509
1843
|
if (await stamp($)) redrawWord($)
|
|
1844
|
+
if (held === run) a.endAt = run.stampedAt
|
|
1510
1845
|
$.ui.invalidate('ui.render')
|
|
1511
1846
|
}
|
|
1512
1847
|
return next(e)
|
package/lib/draw-plan.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// `lib/limits.mjs` only (`test/spine/draw-plan.test.mjs` walks it). The widths
|
|
17
17
|
// are `interlock limits`'s.
|
|
18
18
|
|
|
19
|
-
import { TITLE_SEPARATOR, fitRow, laneEffort, laneLabel, laneModel, laneTier, laneTitle } from './lane.mjs'
|
|
19
|
+
import { TITLE_SEPARATOR, fitRow, laneEffort, laneGist, laneLabel, laneModel, laneTier, laneTitle } from './lane.mjs'
|
|
20
20
|
import { LIMITS } from './limits.mjs'
|
|
21
21
|
|
|
22
22
|
/** The words a lane's state cell may read, each the state's own record. */
|
|
@@ -281,7 +281,6 @@ function lanesOf(positions, plan) {
|
|
|
281
281
|
for (const lane of batch) {
|
|
282
282
|
const label = laneLabel(lane)
|
|
283
283
|
const title = laneTitle(lane)
|
|
284
|
-
const prefix = `${label}${TITLE_SEPARATOR}`
|
|
285
284
|
const tier = laneTier(lane)
|
|
286
285
|
out.push({
|
|
287
286
|
lane,
|
|
@@ -289,7 +288,7 @@ function lanesOf(positions, plan) {
|
|
|
289
288
|
batch: b,
|
|
290
289
|
label,
|
|
291
290
|
title,
|
|
292
|
-
gist:
|
|
291
|
+
gist: laneGist(lane),
|
|
293
292
|
model: laneModel(lane),
|
|
294
293
|
tier: tier > 0 ? String(tier) : '?',
|
|
295
294
|
effort: laneEffort(lane) ?? 'inherit',
|
package/lib/lane.mjs
CHANGED
|
@@ -53,8 +53,35 @@ export function titleGist(description) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
56
|
+
* What a lane is called in words: `task 1.1` for a one-task lane, `tasks 1.1+2`
|
|
57
|
+
* for a lane of several. The role word names what the agent is doing for a
|
|
58
|
+
* reader of /workflows and the meter, where a bare `1.5` reads as an outline
|
|
59
|
+
* sub-number. Display only: the join key stays `laneLabel`.
|
|
60
|
+
*/
|
|
61
|
+
export function laneName(lane) {
|
|
62
|
+
return `${lane.length === 1 ? 'task' : 'tasks'} ${laneLabel(lane)}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The words a lane's title adds after its name: the first words of its first
|
|
67
|
+
* task's description, cut so that label + separator + gist fits TITLE_MAX
|
|
68
|
+
* (ellipsis included); '' when the description says nothing. The role word does
|
|
69
|
+
* not count against the cap, so a gist is cut exactly where it always was.
|
|
70
|
+
*/
|
|
71
|
+
export function laneGist(lane) {
|
|
72
|
+
const gist = titleGist(lane[0] && lane[0].description)
|
|
73
|
+
if (!gist) return ''
|
|
74
|
+
const head = `${laneLabel(lane)}${TITLE_SEPARATOR}`
|
|
75
|
+
const full = `${head}${gist}`
|
|
76
|
+
const cut = full.length <= TITLE_MAX ? full : `${full.slice(0, TITLE_MAX - 1).trimEnd()}…`
|
|
77
|
+
return cut.slice(head.length)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The name a lane is SHOWN under — its role word and label, then the first words
|
|
82
|
+
* of its first task's description: `tasks 1.1+5 · Add the relaunch guard to…`.
|
|
83
|
+
* The role word names what the agent is doing for a reader of /workflows and the
|
|
84
|
+
* meter; the cap is on the label and gist, so a gist is cut where it always was.
|
|
58
85
|
*
|
|
59
86
|
* Display only. Everything that joins on a lane — its briefing file, worktree,
|
|
60
87
|
* trajectory line, record-batch fold — keys on `laneLabel`, because a key built
|
|
@@ -62,11 +89,8 @@ export function titleGist(description) {
|
|
|
62
89
|
* function of the lane, so a replay displays, and cache-hits, the same agent.
|
|
63
90
|
*/
|
|
64
91
|
export function laneTitle(lane) {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
if (!gist) return label
|
|
68
|
-
const full = `${label}${TITLE_SEPARATOR}${gist}`
|
|
69
|
-
return full.length <= TITLE_MAX ? full : `${full.slice(0, TITLE_MAX - 1).trimEnd()}…`
|
|
92
|
+
const gist = laneGist(lane)
|
|
93
|
+
return gist ? `${laneName(lane)}${TITLE_SEPARATOR}${gist}` : laneName(lane)
|
|
70
94
|
}
|
|
71
95
|
|
|
72
96
|
/**
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The ship meter's colours, as the theme keys the pane draws with.
|
|
2
|
+
//
|
|
3
|
+
// Each value is a key of the person's own theme, never a fixed hue: `Text` and
|
|
4
|
+
// `Box` colour props resolve a theme key, so the pane follows the light, dark
|
|
5
|
+
// or colour-blind theme the person chose (probed on Claude Code 2.1.295: the
|
|
6
|
+
// keys below resolve, nested spans inside one `Text` keep their own, a
|
|
7
|
+
// bordered `Box` takes one for its border, and an unknown key draws uncoloured
|
|
8
|
+
// with no error). A colour names what a word means: a state the renderer
|
|
9
|
+
// printed, a name, a heading, a word the host or the CLI sent. It is never a
|
|
10
|
+
// threshold or a verdict of the meter's own, so a figure the engine reports
|
|
11
|
+
// (the context, the cost, a plan window) carries none.
|
|
12
|
+
//
|
|
13
|
+
// Pure and Node-free: the hooks module runs in the engine with no Node, and
|
|
14
|
+
// imports this file.
|
|
15
|
+
|
|
16
|
+
export const PALETTE = Object.freeze({
|
|
17
|
+
// A state word the board printed: the recorded ok, the recorded failure, the cursor.
|
|
18
|
+
ok: 'success',
|
|
19
|
+
failed: 'error',
|
|
20
|
+
current: 'warning',
|
|
21
|
+
// A lane label and an agent's name.
|
|
22
|
+
identity: 'suggestion',
|
|
23
|
+
// The run header and the section headings.
|
|
24
|
+
accent: 'claude',
|
|
25
|
+
// A word the CLI or the host raised to be read: a banner, the quiet word, a running turn.
|
|
26
|
+
warn: 'warning',
|
|
27
|
+
// A refusal, and a turn the host ended without an answer.
|
|
28
|
+
alarm: 'error'
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const DIM_WORDS = new Set(['pending', 'not reached', 'not recorded', 'per task'])
|
|
32
|
+
|
|
33
|
+
/** The props a state word the board printed is drawn with: a hue for ok, failed and current, dim for the rest, else none. */
|
|
34
|
+
export function stateProps(word) {
|
|
35
|
+
if (word === 'ok') return { color: PALETTE.ok }
|
|
36
|
+
if (word === 'failed') return { color: PALETTE.failed }
|
|
37
|
+
if (word === 'current') return { color: PALETTE.current }
|
|
38
|
+
if (DIM_WORDS.has(word)) return { dimColor: true }
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The props an agent's turn word is drawn with. No reason is a turn still
|
|
44
|
+
* running; `answer` is the host's ordinary end; any other word the host sends
|
|
45
|
+
* is a turn it ended without one. The caller shows the word itself verbatim.
|
|
46
|
+
*/
|
|
47
|
+
export function turnProps(reason) {
|
|
48
|
+
if (reason === null || reason === undefined) return { color: PALETTE.warn }
|
|
49
|
+
if (reason === 'answer') return { color: PALETTE.ok }
|
|
50
|
+
return { color: PALETTE.alarm }
|
|
51
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The ship meter's time words: the clock reading a line was stamped with, and
|
|
2
|
+
// how long a turn took, as a person reads them.
|
|
3
|
+
//
|
|
4
|
+
// Each reading is the engine's own (`$.clock.now()` in the hooks module), so a
|
|
5
|
+
// time is either a real reading or absent: absent draws as nothing, never as a
|
|
6
|
+
// guessed time. The wall-clock words are the local time of the machine the
|
|
7
|
+
// engine runs on, from `Date`'s own local fields. The duration bands are unit
|
|
8
|
+
// conversions (seconds, minutes, hours), not thresholds: no band is a verdict
|
|
9
|
+
// on the figure it formats.
|
|
10
|
+
//
|
|
11
|
+
// Pure and Node-free: the hooks module runs in the engine with no Node, and
|
|
12
|
+
// imports this file.
|
|
13
|
+
|
|
14
|
+
const isTime = ms => typeof ms === 'number' && Number.isFinite(ms)
|
|
15
|
+
const pad = n => String(n).padStart(2, '0')
|
|
16
|
+
|
|
17
|
+
/** The local wall-clock time of a reading, `HH:MM:SS`; `''` for no reading. */
|
|
18
|
+
export function clockText(ms) {
|
|
19
|
+
if (!isTime(ms)) return ''
|
|
20
|
+
const d = new Date(ms)
|
|
21
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The local date and time of a reading, `YYYY-MM-DD HH:MM:SS`; `''` for no reading. */
|
|
25
|
+
export function stampText(ms) {
|
|
26
|
+
if (!isTime(ms)) return ''
|
|
27
|
+
const d = new Date(ms)
|
|
28
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${clockText(ms)}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A duration in milliseconds as a person reads it: under a minute, seconds to
|
|
33
|
+
* one decimal, cut down (`41.2s`, `0.8s`, never `60.0s`); under an hour, whole
|
|
34
|
+
* minutes and seconds (`2m 14s`); else hours and minutes (`1h 03m`). `''` for
|
|
35
|
+
* no figure, a negative one or one that is not a finite number.
|
|
36
|
+
*/
|
|
37
|
+
export function durationText(ms) {
|
|
38
|
+
if (!isTime(ms) || ms < 0) return ''
|
|
39
|
+
if (ms < 60_000) return `${(Math.floor(ms / 100) / 10).toFixed(1)}s`
|
|
40
|
+
const seconds = Math.floor(ms / 1000)
|
|
41
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${pad(seconds % 60)}s`
|
|
42
|
+
return `${Math.floor(seconds / 3600)}h ${pad(Math.floor((seconds % 3600) / 60))}m`
|
|
43
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// The ship meter's step timeline: what each step the CLI printed says, and the
|
|
2
|
+
// order the pane lays the steps, the agents they spawned and the agents no
|
|
3
|
+
// step names in.
|
|
4
|
+
//
|
|
5
|
+
// A step's words are its own fields, verbatim: the action, its position, how
|
|
6
|
+
// many lanes a batch dispatched, a skipped verification's reason, a halt's
|
|
7
|
+
// reason and the plan size a step relayed. No word here is a verdict, a cause
|
|
8
|
+
// or a state the step did not carry.
|
|
9
|
+
//
|
|
10
|
+
// The order is pure data over plain inputs, so it is pinned under Node
|
|
11
|
+
// (`test/spine/meter-timeline.test.mjs`) and the hooks module only draws it:
|
|
12
|
+
//
|
|
13
|
+
// - The top level is the launch, every step and every relay agent no step
|
|
14
|
+
// names, laid by time. An entry with a time sorts by it, ties keeping their
|
|
15
|
+
// input order (the launch, then the steps as they crossed, then the relays as
|
|
16
|
+
// first seen); an entry with no time follows every timed one, in input order.
|
|
17
|
+
// - Beneath a step, at depth 1, the agents it spawned: a spawn is joined to an
|
|
18
|
+
// agent by its briefing hash, one agent per spawn in step order (an agent
|
|
19
|
+
// beyond the spawns that carry its hash stays with the last of them). The
|
|
20
|
+
// joined spawns are laid by their agent's first request time, by the same
|
|
21
|
+
// rule; the spawns no agent has joined yet follow, `waiting`, in the step's
|
|
22
|
+
// spawn order. A step's relay is drawn on the step's own line and nowhere
|
|
23
|
+
// else.
|
|
24
|
+
// - Last, the agents nothing names, under one head, in first-seen order.
|
|
25
|
+
//
|
|
26
|
+
// Pure and Node-free: the hooks module runs in the engine with no Node, and
|
|
27
|
+
// imports this file.
|
|
28
|
+
|
|
29
|
+
/** The actions that dispatch a batch of lanes. */
|
|
30
|
+
export const BATCH_ACTIONS = new Set(['run-batch', 'test-wave'])
|
|
31
|
+
|
|
32
|
+
const isInt = v => Number.isInteger(v)
|
|
33
|
+
const isTime = v => typeof v === 'number' && Number.isFinite(v)
|
|
34
|
+
const str = v => (typeof v === 'string' && v ? v : null)
|
|
35
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
|
|
36
|
+
|
|
37
|
+
/** `batch <j>/<k>`, one-based, as the status line says it; `null` when the step carries no position. */
|
|
38
|
+
export function batchText(batchIndex, batchCount) {
|
|
39
|
+
return isInt(batchIndex) && isInt(batchCount) ? `batch ${batchIndex + 1}/${batchCount}` : null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How many lanes run side by side: `N in parallel`, `1 lane`, or `null` for none. */
|
|
43
|
+
export function laneCountText(n) {
|
|
44
|
+
if (!isInt(n) || n < 1) return null
|
|
45
|
+
return n === 1 ? '1 lane' : `${n} in parallel`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The waves a plan summary holds, its test wave counted; `null` for anything that is not a summary. */
|
|
49
|
+
export function planWaveCount(plan) {
|
|
50
|
+
if (plan === null || typeof plan !== 'object' || !Array.isArray(plan.waves)) return null
|
|
51
|
+
return plan.waves.length + (plan.testWave ? 1 : 0)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A step's words, from its own fields only. */
|
|
55
|
+
export function stepText(step) {
|
|
56
|
+
const s = step && typeof step === 'object' ? step : {}
|
|
57
|
+
const parts = [String(s.action)]
|
|
58
|
+
if (s.wave !== undefined && s.wave !== null) parts.push(`wave ${s.wave}`)
|
|
59
|
+
const batch = batchText(s.batchIndex, s.batchCount)
|
|
60
|
+
if (batch) parts.push(batch)
|
|
61
|
+
if (BATCH_ACTIONS.has(s.action)) {
|
|
62
|
+
const lanes = laneCountText(Array.isArray(s.spawns) ? s.spawns.length : 0)
|
|
63
|
+
if (lanes) parts.push(lanes)
|
|
64
|
+
}
|
|
65
|
+
if (s.action === 'verify' && s.skipped === true) parts.push(str(s.reason) ? `skipped: ${s.reason}` : 'skipped')
|
|
66
|
+
if (s.action === 'halt' && str(s.reason)) parts.push(s.reason)
|
|
67
|
+
if (isInt(s.planWaves)) parts.push(`plan: ${plural(s.planWaves, 'wave')}`)
|
|
68
|
+
return parts.join(' · ')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The entries with a time laid by it, ties in input order; then the entries with none, in input order. */
|
|
72
|
+
function byTime(entries) {
|
|
73
|
+
const timed = entries.map((entry, i) => ({ entry, i })).filter(({ entry }) => isTime(entry.at))
|
|
74
|
+
timed.sort((a, b) => a.entry.at - b.entry.at || a.i - b.i)
|
|
75
|
+
return [...timed.map(({ entry }) => entry), ...entries.filter(entry => !isTime(entry.at))]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The timeline as ordered display entries, each `{ key, kind, at, depth, ... }`:
|
|
80
|
+
* `launch` (`name`), `step` (`step`), `spawn` (`step`, `spawn`, `agent` or
|
|
81
|
+
* `null` while it waits), `relay` (`agent`, a relay agent no step names),
|
|
82
|
+
* `unmatched-head`, and `unmatched` (`agent`). An agent's entry is keyed
|
|
83
|
+
* `agent-<id>`, a waiting spawn's `spawn-<n>-<label>`, a step's `step-<n>`.
|
|
84
|
+
*
|
|
85
|
+
* @param {{
|
|
86
|
+
* steps?: {n: number, at: number|null, action: string, wave?: unknown, waveIndex?: unknown, batchIndex?: unknown,
|
|
87
|
+
* batchCount?: unknown, skipped?: unknown, reason?: unknown, planWaves?: number|null, relayId?: string|null,
|
|
88
|
+
* spawns: {label: string, title: string, sha: string|null}[]}[],
|
|
89
|
+
* agents?: {id: string, sha?: string|null, relay?: string|null, firstAt?: number|null}[],
|
|
90
|
+
* launch?: {at: number|null, name?: string|null}|null
|
|
91
|
+
* }} input
|
|
92
|
+
* @returns {object[]}
|
|
93
|
+
*/
|
|
94
|
+
export function timeline({ steps = [], agents = [], launch = null } = {}) {
|
|
95
|
+
const relays = new Set(steps.map(step => str(step.relayId)).filter(Boolean))
|
|
96
|
+
|
|
97
|
+
// Each spawn carrying a hash, in step order, and the agents carrying it, first seen first.
|
|
98
|
+
const slots = new Map()
|
|
99
|
+
const spawnsOf = step => (Array.isArray(step.spawns) ? step.spawns : [])
|
|
100
|
+
steps.forEach((step, i) =>
|
|
101
|
+
spawnsOf(step).forEach((spawn, j) => {
|
|
102
|
+
const sha = str(spawn.sha)
|
|
103
|
+
if (!sha) return
|
|
104
|
+
if (!slots.has(sha)) slots.set(sha, [])
|
|
105
|
+
slots.get(sha).push({ pos: `${i}:${j}`, agents: [] })
|
|
106
|
+
})
|
|
107
|
+
)
|
|
108
|
+
const placed = new Set()
|
|
109
|
+
const counts = new Map()
|
|
110
|
+
for (const agent of agents) {
|
|
111
|
+
const sha = str(agent.sha)
|
|
112
|
+
if (!sha || relays.has(agent.id) || !slots.has(sha)) continue
|
|
113
|
+
const held = slots.get(sha)
|
|
114
|
+
const at = counts.get(sha) || 0
|
|
115
|
+
held[Math.min(at, held.length - 1)].agents.push(agent)
|
|
116
|
+
counts.set(sha, at + 1)
|
|
117
|
+
placed.add(agent.id)
|
|
118
|
+
}
|
|
119
|
+
const joinedBy = new Map()
|
|
120
|
+
for (const held of slots.values()) for (const slot of held) joinedBy.set(slot.pos, slot.agents)
|
|
121
|
+
|
|
122
|
+
const top = []
|
|
123
|
+
if (launch && typeof launch === 'object') {
|
|
124
|
+
top.push({ key: 'launch', kind: 'launch', at: isTime(launch.at) ? launch.at : null, depth: 0, name: str(launch.name) })
|
|
125
|
+
}
|
|
126
|
+
for (const step of steps) top.push({ key: `step-${step.n}`, kind: 'step', at: isTime(step.at) ? step.at : null, depth: 0, step })
|
|
127
|
+
const orphans = agents.filter(agent => str(agent.relay) && !relays.has(agent.id) && !placed.has(agent.id))
|
|
128
|
+
for (const agent of orphans) {
|
|
129
|
+
top.push({ key: `agent-${agent.id}`, kind: 'relay', at: isTime(agent.firstAt) ? agent.firstAt : null, depth: 0, agent })
|
|
130
|
+
placed.add(agent.id)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const out = []
|
|
134
|
+
for (const entry of byTime(top)) {
|
|
135
|
+
out.push(entry)
|
|
136
|
+
if (entry.kind !== 'step') continue
|
|
137
|
+
const i = steps.indexOf(entry.step)
|
|
138
|
+
const joined = []
|
|
139
|
+
const waiting = []
|
|
140
|
+
for (const [j, spawn] of spawnsOf(entry.step).entries()) {
|
|
141
|
+
const held = joinedBy.get(`${i}:${j}`) || []
|
|
142
|
+
if (!held.length) {
|
|
143
|
+
waiting.push({ key: `spawn-${entry.step.n}-${spawn.label}`, kind: 'spawn', at: null, depth: 1, step: entry.step, spawn, agent: null })
|
|
144
|
+
}
|
|
145
|
+
for (const agent of held) {
|
|
146
|
+
const at = isTime(agent.firstAt) ? agent.firstAt : null
|
|
147
|
+
joined.push({ key: `agent-${agent.id}`, kind: 'spawn', at, depth: 1, step: entry.step, spawn, agent })
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
out.push(...byTime(joined), ...waiting)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const unmatched = agents.filter(agent => !relays.has(agent.id) && !placed.has(agent.id))
|
|
154
|
+
if (unmatched.length) {
|
|
155
|
+
out.push({ key: 'agents-unmatched-head', kind: 'unmatched-head', at: null, depth: 0 })
|
|
156
|
+
for (const agent of unmatched) {
|
|
157
|
+
out.push({ key: `agent-${agent.id}`, kind: 'unmatched', at: isTime(agent.firstAt) ? agent.firstAt : null, depth: 1, agent })
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out
|
|
161
|
+
}
|
package/lib/waves.mjs
CHANGED
|
@@ -484,7 +484,7 @@ function capForTier(caps, tier) {
|
|
|
484
484
|
// The lane rule — tier, label, title, model, effort — lives in `lib/lane.mjs`,
|
|
485
485
|
// a module the engine can load (draw-wave-plan-and-handoff-graph-from-the-cli
|
|
486
486
|
// design D3). Re-exported so every importer of it from here is unchanged.
|
|
487
|
-
export { laneEffort, laneLabel, laneModel, laneTier, laneTitle, TITLE_SEPARATOR } from './lane.mjs'
|
|
487
|
+
export { laneEffort, laneGist, laneLabel, laneModel, laneName, laneTier, laneTitle, TITLE_SEPARATOR } from './lane.mjs'
|
|
488
488
|
|
|
489
489
|
/**
|
|
490
490
|
* Group a group's tasks into lanes: connected components over canonical-path
|