@renzrollon/interlock 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +16 -0
- package/hooks/mod.mjs +209 -45
- package/lib/draw-plan.mjs +2 -3
- package/lib/lane.mjs +31 -7
- package/lib/meter-palette.mjs +51 -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.0",
|
|
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,22 @@ 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.0 — 2026-10-09
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **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.
|
|
13
|
+
- **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.
|
|
14
|
+
- **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.
|
|
15
|
+
- State words: `success`, `error` and `warning` for `ok`, `failed` and `current`, replacing raw green, red and yellow.
|
|
16
|
+
- Names: lane labels and agent names in `suggestion`, bold.
|
|
17
|
+
- Headings: the run header and section headings in `claude`.
|
|
18
|
+
- Cards: the card of the wave holding a `current` lane is bordered in `warning`; every other card stays dim.
|
|
19
|
+
- 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.
|
|
20
|
+
- Warnings: the quiet word and banners in `warning`.
|
|
21
|
+
- Refusals: the guard-denial line, and the launch-guard line when it refuses, in `error`.
|
|
22
|
+
- 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`.
|
|
23
|
+
|
|
8
24
|
## 1.3.1 — 2026-10-08
|
|
9
25
|
|
|
10
26
|
### Changed
|
package/hooks/mod.mjs
CHANGED
|
@@ -65,10 +65,20 @@
|
|
|
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
|
+
//
|
|
72
82
|
// THE SESSION START (show-preflight-and-interrupted-runs-at-session-start
|
|
73
83
|
// design D5-D8). Outside a run the module draws two things, both from the
|
|
74
84
|
// report `hooks/preflight.mjs` leaves at `.claude/ship/preflight.json`: the
|
|
@@ -101,6 +111,7 @@
|
|
|
101
111
|
import { drawPlanBoardRows } from '../lib/draw-plan.mjs'
|
|
102
112
|
import { decideLaunch, emptyRecord, isAcceptedLaunch, isShipLaunch, withLaunch, withPrompt } from '../lib/launch-rule.mjs'
|
|
103
113
|
import { LIMITS } from '../lib/limits.mjs'
|
|
114
|
+
import { PALETTE, stateProps, turnProps } from '../lib/meter-palette.mjs'
|
|
104
115
|
import { TICK_MS, quietMs, quietWord } from '../lib/meter-quiet.mjs'
|
|
105
116
|
import { guardDenialLine, guardOf, guardReason, launchGuardLine } from '../lib/meter-refusals.mjs'
|
|
106
117
|
import {
|
|
@@ -166,6 +177,17 @@ const GUARD = 'interlock guard'
|
|
|
166
177
|
const DRIVER_LINE = /^interlock\s/
|
|
167
178
|
// The bootstrap line a lane's spawn prompt carries (as `lib/agent-usage.mjs` reads it).
|
|
168
179
|
const BRIEFING_SHA = /Expected sha256: ([0-9a-f]{64})/g
|
|
180
|
+
// What an agent row and a turn-end toast read for an agent no row and no driver line names.
|
|
181
|
+
const UNMATCHED = 'unmatched agent'
|
|
182
|
+
const UNMATCHED_LINE = 'unmatched: no briefing read or interlock line seen from this agent yet'
|
|
183
|
+
// The characters a briefing path may stand behind in a Read path or a command,
|
|
184
|
+
// and the ones that would make it part of a longer file name after it.
|
|
185
|
+
const PATH_BEFORE = /[\s\/'"=]/
|
|
186
|
+
const PATH_AFTER = /[\w.+-]/
|
|
187
|
+
/** The props a name is drawn with: a lane label, a spawn's title, a joined agent's name. */
|
|
188
|
+
const NAME_PROPS = { bold: true, color: PALETTE.identity }
|
|
189
|
+
/** The props the run header and each section heading are drawn with. */
|
|
190
|
+
const HEADING_PROPS = { bold: true, color: PALETTE.accent }
|
|
169
191
|
const TOKEN_FIELDS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']
|
|
170
192
|
// The classic session-start sources that start the session over, as the person
|
|
171
193
|
// typed them (design D4). `startup` and `compact` are not among them.
|
|
@@ -611,6 +633,72 @@ async function noteSpawn($, input, spawned) {
|
|
|
611
633
|
return spawned
|
|
612
634
|
}
|
|
613
635
|
|
|
636
|
+
/** Whether `text` names `path` whole: behind its start or a separator, and not followed by more of a file name. */
|
|
637
|
+
function namesPath(text, path) {
|
|
638
|
+
for (let at = text.indexOf(path); at !== -1; at = text.indexOf(path, at + 1)) {
|
|
639
|
+
const before = at === 0 ? '' : text[at - 1]
|
|
640
|
+
const after = text[at + path.length] ?? ''
|
|
641
|
+
if ((before === '' || PATH_BEFORE.test(before)) && (after === '' || !PATH_AFTER.test(after))) return true
|
|
642
|
+
}
|
|
643
|
+
return false
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* The Workflow host's join: it raises no
|
|
648
|
+
* `agent.spawn` for a Workflow agent, but every run agent's first act is to
|
|
649
|
+
* read its own briefing. A Read path or a Bash command that names a
|
|
650
|
+
* dispatched spawn's briefing path joins the agent to that spawn's row,
|
|
651
|
+
* through the same briefing hash the spawn event joins on. The first briefing
|
|
652
|
+
* an agent reads wins; an agent that names none is left as it was, and no
|
|
653
|
+
* agent record is made for it. Never throws.
|
|
654
|
+
*/
|
|
655
|
+
function noteBriefing($, agentId, text) {
|
|
656
|
+
try {
|
|
657
|
+
const id = str(agentId)
|
|
658
|
+
if (!id || typeof text !== 'string') return
|
|
659
|
+
const known = run.agents.get(id)
|
|
660
|
+
if (known && known.sha) return
|
|
661
|
+
const said = text.split('\\').join('/')
|
|
662
|
+
for (const r of run.rows.values()) {
|
|
663
|
+
if (!r.sha || !r.promptPath) continue
|
|
664
|
+
if (!namesPath(said, r.promptPath.split('\\').join('/'))) continue
|
|
665
|
+
agentOf(id).sha = r.sha
|
|
666
|
+
$.ui.invalidate('ui.render')
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
} catch (err) {
|
|
670
|
+
$.ui.log(`interlock meter: a briefing read was not joined to its lane: ${messageOf(err)}`, { to: 'debug' })
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* `run next` for `interlock run next --results … --json`: the subcommand a
|
|
676
|
+
* driver line relays, as `workflows/ship.js` names its relay (`argv.slice(0, 2)`),
|
|
677
|
+
* the second word dropped when it is a flag or a shell operator.
|
|
678
|
+
*/
|
|
679
|
+
function relayOf(command) {
|
|
680
|
+
const words = command.trim().split(/\s+/).slice(1, 3)
|
|
681
|
+
if (!words.length || !words[0]) return null
|
|
682
|
+
const second = words[1]
|
|
683
|
+
return second && !/^(--|[;&|<>])/.test(second) ? `${words[0]} ${second}` : words[0]
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** An agent that ran a driver line before any briefing named it is a relay, shown by what it relayed. Never throws. */
|
|
687
|
+
function noteRelay($, agentId, command) {
|
|
688
|
+
try {
|
|
689
|
+
const id = str(agentId)
|
|
690
|
+
if (!id) return
|
|
691
|
+
const known = run.agents.get(id)
|
|
692
|
+
if (known && known.sha) return
|
|
693
|
+
const relay = relayOf(command)
|
|
694
|
+
if (!relay || (known && known.relay === relay)) return
|
|
695
|
+
agentOf(id).relay = relay
|
|
696
|
+
$.ui.invalidate('ui.render')
|
|
697
|
+
} catch (err) {
|
|
698
|
+
$.ui.log(`interlock meter: a driver line was not read for its agent: ${messageOf(err)}`, { to: 'debug' })
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
614
702
|
/**
|
|
615
703
|
* A clear, resume or fork ends the run the meter holds, before the chain
|
|
616
704
|
* beneath runs. Any other source, and a non-interactive session, changes
|
|
@@ -658,11 +746,14 @@ function drawSpecPane($, e) {
|
|
|
658
746
|
return h(Box, { flexDirection: 'column' }, ...specPaneLines(spec).map(l => h(Box, { key: l.key }, h(Text, props(l), l.text))))
|
|
659
747
|
}
|
|
660
748
|
|
|
661
|
-
/**
|
|
749
|
+
/**
|
|
750
|
+
* The launch guard's ruling now, over the record its branch would read, in the
|
|
751
|
+
* rule's words (design D5), and whether that ruling refuses the next launch.
|
|
752
|
+
*/
|
|
662
753
|
async function launchGuardText($) {
|
|
663
754
|
const { record, now } = await guardFacts($)
|
|
664
755
|
const ruling = decideLaunch(record, now)
|
|
665
|
-
return launchGuardLine(ruling, now)
|
|
756
|
+
return { text: launchGuardLine(ruling, now), refused: now !== null && ruling.decision !== 'allow' && Boolean(ruling.reason) }
|
|
666
757
|
}
|
|
667
758
|
|
|
668
759
|
/** Names, once per run record, a step that crossed with no live run (design D4). */
|
|
@@ -719,6 +810,8 @@ function agentOf(id) {
|
|
|
719
810
|
reason: null,
|
|
720
811
|
durationMs: null,
|
|
721
812
|
sha: null,
|
|
813
|
+
// The two words after `interlock` in the first driver line it ran while no row named it.
|
|
814
|
+
relay: null,
|
|
722
815
|
// Whether its turn end has been spoken: once per agent per run (speak-lane-turn-ends design D1).
|
|
723
816
|
endToasted: false
|
|
724
817
|
}
|
|
@@ -831,6 +924,8 @@ async function applyRecord($, record) {
|
|
|
831
924
|
model: str(s.model),
|
|
832
925
|
effort: str(s.effort),
|
|
833
926
|
sha: str(s.promptSha256),
|
|
927
|
+
// The briefing the spawn names, read off the agent's own first read of it.
|
|
928
|
+
promptPath: str(s.promptPath),
|
|
834
929
|
// Spawned by a step that dispatches a batch: a lane, planned or not.
|
|
835
930
|
batch: BATCH_ACTIONS.has(record.action)
|
|
836
931
|
})
|
|
@@ -979,9 +1074,14 @@ function rowForAgent(a) {
|
|
|
979
1074
|
return null
|
|
980
1075
|
}
|
|
981
1076
|
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1077
|
+
/**
|
|
1078
|
+
* What an agent is called: its joined spawn's title, else `cli · <what it
|
|
1079
|
+
* relayed>` for a driver-line agent, else `null`, which reads `unmatched agent`.
|
|
1080
|
+
*/
|
|
1081
|
+
function agentName(a) {
|
|
1082
|
+
const row = rowForAgent(a)
|
|
1083
|
+
if (row) return row.title
|
|
1084
|
+
return a.relay ? `cli · ${a.relay}` : null
|
|
985
1085
|
}
|
|
986
1086
|
|
|
987
1087
|
/**
|
|
@@ -1007,24 +1107,53 @@ function sessionLines(usage, failure) {
|
|
|
1007
1107
|
|
|
1008
1108
|
/**
|
|
1009
1109
|
* `last activity <ISO>` or `last activity unknown`, then the quiet word while a
|
|
1010
|
-
* live run is quiet
|
|
1011
|
-
* pane opened after the host stopped the
|
|
1110
|
+
* live run is quiet, as the line and the word apart. The word is recomputed
|
|
1111
|
+
* from the clock at each draw, so a pane opened after the host stopped the
|
|
1112
|
+
* interval still shows it (design D2, D5).
|
|
1012
1113
|
*/
|
|
1013
1114
|
async function lastActivityText($) {
|
|
1014
|
-
if (run.lastActivityAt === null) return 'last activity unknown'
|
|
1115
|
+
if (run.lastActivityAt === null) return { line: 'last activity unknown', word: null }
|
|
1015
1116
|
const at = new Date(run.lastActivityAt).toISOString()
|
|
1016
1117
|
const word = run.phase === 'live' ? quietWord(quietMs(run.lastActivityAt, await clockNow($))) : null
|
|
1017
|
-
return `last activity ${at}
|
|
1118
|
+
return { line: `last activity ${at}`, word }
|
|
1018
1119
|
}
|
|
1019
1120
|
|
|
1020
|
-
/**
|
|
1021
|
-
|
|
1121
|
+
/**
|
|
1122
|
+
* An agent's first line: its name, its id and models dim, then its turn word.
|
|
1123
|
+
* A joined agent is named in the identity colour, a relay dim, an unmatched
|
|
1124
|
+
* agent dim and italic. The turn word is the host's, coloured by what it means;
|
|
1125
|
+
* a relay's running or answered turn stays dim, and only its abnormal end is coloured.
|
|
1126
|
+
*/
|
|
1127
|
+
function agentLine(Text, a, done) {
|
|
1128
|
+
const row = rowForAgent(a)
|
|
1129
|
+
const name = agentName(a)
|
|
1130
|
+
const nameProps = row ? NAME_PROPS : a.relay ? { dimColor: true } : { dimColor: true, italic: true }
|
|
1131
|
+
const quiet = !row && a.relay && (!a.reason || a.reason === 'answer')
|
|
1132
|
+
return h(
|
|
1133
|
+
Text,
|
|
1134
|
+
null,
|
|
1135
|
+
h(Text, nameProps, name || UNMATCHED),
|
|
1136
|
+
h(Text, { dimColor: true }, ` · ${a.id} · ${a.models.join(', ') || 'model not reported'}`),
|
|
1137
|
+
' · ',
|
|
1138
|
+
h(Text, quiet ? { dimColor: true } : turnProps(a.reason), done)
|
|
1139
|
+
)
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/** One spawn as a flat row, as the section drew every spawn before the board: its title in the identity colour. */
|
|
1143
|
+
function flatRow($, e, r) {
|
|
1144
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
1022
1145
|
const a = agentForSha(r.sha)
|
|
1023
1146
|
const served = a && a.models.length ? a.models.join(', ') : '?'
|
|
1024
1147
|
const state = a ? a.reason || 'running' : 'waiting'
|
|
1025
|
-
return
|
|
1026
|
-
|
|
1027
|
-
|
|
1148
|
+
return h(
|
|
1149
|
+
Box,
|
|
1150
|
+
{ key: `wave-row-${r.label}` },
|
|
1151
|
+
h(
|
|
1152
|
+
Text,
|
|
1153
|
+
null,
|
|
1154
|
+
h(Text, NAME_PROPS, r.title),
|
|
1155
|
+
` · ${r.kind || 'agent'} · routed ${r.model || '?'} · served ${served} · effort ${r.effort || '?'} · ${state}`
|
|
1156
|
+
)
|
|
1028
1157
|
)
|
|
1029
1158
|
}
|
|
1030
1159
|
|
|
@@ -1064,17 +1193,6 @@ function boardKey(key) {
|
|
|
1064
1193
|
return `${kind === 'wave' ? 'wave-block' : `wave-${kind}`}-${rest.join('-')}`
|
|
1065
1194
|
}
|
|
1066
1195
|
|
|
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
1196
|
/** The width a card and the renderer share: the body, capped at the published default. */
|
|
1079
1197
|
function boardColumns(e) {
|
|
1080
1198
|
const body = e.props && isInt(e.props.bodyColumns) && e.props.bodyColumns > 0
|
|
@@ -1107,6 +1225,22 @@ function idSpans(Text, parts) {
|
|
|
1107
1225
|
return parts.ids ? [` [${parts.ids}]`] : []
|
|
1108
1226
|
}
|
|
1109
1227
|
|
|
1228
|
+
/**
|
|
1229
|
+
* A lane's batch, label, model, tier and effort cells, space-joined as the
|
|
1230
|
+
* board prints them, the label a span in the identity colour.
|
|
1231
|
+
*/
|
|
1232
|
+
function headSpans(Text, parts) {
|
|
1233
|
+
const before = [parts.batch].filter(Boolean).join(' ')
|
|
1234
|
+
const after = [parts.model, parts.tier, parts.effort].filter(Boolean).join(' ')
|
|
1235
|
+
if (!parts.label) return [[before, after].filter(Boolean).join(' ')]
|
|
1236
|
+
return [before ? `${before} ` : '', h(Text, NAME_PROPS, parts.label), after ? ` ${after}` : ''].filter(span => span !== '')
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** Whether a lane the board drew holds the cursor: its own state word, or a task's, is `current`. */
|
|
1240
|
+
const isCurrentLane = parts =>
|
|
1241
|
+
Boolean(parts) &&
|
|
1242
|
+
(parts.state === 'current' || (Array.isArray(parts.tasks) && parts.tasks.some(task => task && task.word === 'current')))
|
|
1243
|
+
|
|
1110
1244
|
/**
|
|
1111
1245
|
* A lane in its card: the fixed cells as one Text, the gist wrapping beneath,
|
|
1112
1246
|
* then the host's note, dim. The coloured words are spans nested in that one
|
|
@@ -1118,9 +1252,9 @@ function drawLane($, e, row) {
|
|
|
1118
1252
|
const key = boardKey(row.key)
|
|
1119
1253
|
const parts = row.parts
|
|
1120
1254
|
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
1255
|
const line = [
|
|
1123
|
-
...(
|
|
1256
|
+
...headSpans(Text, parts),
|
|
1257
|
+
...(parts.state ? [' ', h(Text, stateProps(parts.state), parts.state)] : []),
|
|
1124
1258
|
...idSpans(Text, parts),
|
|
1125
1259
|
...(Array.isArray(parts.after) && parts.after.length ? [` ←${parts.after.join(',')}`] : [])
|
|
1126
1260
|
]
|
|
@@ -1138,6 +1272,8 @@ function drawBoardCards($, e, board, width) {
|
|
|
1138
1272
|
const out = []
|
|
1139
1273
|
let index = null
|
|
1140
1274
|
let kids = null
|
|
1275
|
+
// Whether the card being filled holds a lane at the cursor: its border is then the current colour.
|
|
1276
|
+
let current = false
|
|
1141
1277
|
const flush = () => {
|
|
1142
1278
|
if (index === null) return
|
|
1143
1279
|
out.push(
|
|
@@ -1147,7 +1283,7 @@ function drawBoardCards($, e, board, width) {
|
|
|
1147
1283
|
key: `wave-card-${index}`,
|
|
1148
1284
|
flexDirection: 'column',
|
|
1149
1285
|
borderStyle: 'single',
|
|
1150
|
-
borderDimColor: true,
|
|
1286
|
+
...(current ? { borderColor: PALETTE.current } : { borderDimColor: true }),
|
|
1151
1287
|
width,
|
|
1152
1288
|
paddingLeft: 1,
|
|
1153
1289
|
paddingRight: 1,
|
|
@@ -1158,6 +1294,7 @@ function drawBoardCards($, e, board, width) {
|
|
|
1158
1294
|
)
|
|
1159
1295
|
index = null
|
|
1160
1296
|
kids = null
|
|
1297
|
+
current = false
|
|
1161
1298
|
}
|
|
1162
1299
|
for (const row of board) {
|
|
1163
1300
|
if (row.key.startsWith('wave:') && row.key.endsWith(':end')) continue
|
|
@@ -1169,8 +1306,10 @@ function drawBoardCards($, e, board, width) {
|
|
|
1169
1306
|
}
|
|
1170
1307
|
if (row.key.startsWith('lane:')) {
|
|
1171
1308
|
const lane = drawLane($, e, row)
|
|
1172
|
-
if (kids)
|
|
1173
|
-
|
|
1309
|
+
if (kids) {
|
|
1310
|
+
kids.push(lane)
|
|
1311
|
+
if (isCurrentLane(row.parts)) current = true
|
|
1312
|
+
} else out.push(lane)
|
|
1174
1313
|
continue
|
|
1175
1314
|
}
|
|
1176
1315
|
flush()
|
|
@@ -1197,7 +1336,7 @@ function drawWaves($, e) {
|
|
|
1197
1336
|
if (!run.plan) {
|
|
1198
1337
|
if (!rows.length) return [h(Text, { dimColor: true }, NO_LANES_LINE)]
|
|
1199
1338
|
const dispatched = run.dispatched !== null || rows.some(r => r.batch)
|
|
1200
|
-
return [...(dispatched ? [keyed('wave-board-unrelayed', UNRELAYED_LINE)] : []), ...rows.map(r => flatRow(
|
|
1339
|
+
return [...(dispatched ? [keyed('wave-board-unrelayed', UNRELAYED_LINE)] : []), ...rows.map(r => flatRow($, e, r))]
|
|
1201
1340
|
}
|
|
1202
1341
|
const width = boardColumns(e)
|
|
1203
1342
|
let board
|
|
@@ -1205,29 +1344,41 @@ function drawWaves($, e) {
|
|
|
1205
1344
|
board = drawPlanBoardRows(run.plan, { columns: width, state: boardOverlay(run.plan), notes: laneNotes() })
|
|
1206
1345
|
} catch (err) {
|
|
1207
1346
|
$.ui.log(`interlock meter: ${BOARD_FAILED_LINE}: ${messageOf(err)}`, { to: 'debug' })
|
|
1208
|
-
return [keyed('wave-board-failed', BOARD_FAILED_LINE), ...rows.map(r => flatRow(
|
|
1347
|
+
return [keyed('wave-board-failed', BOARD_FAILED_LINE), ...rows.map(r => flatRow($, e, r))]
|
|
1209
1348
|
}
|
|
1210
1349
|
// Every board ends in its tail; the renderer's spoken line for a narrow body
|
|
1211
1350
|
// is one row without one, and the lanes are then drawn flat beneath it.
|
|
1212
1351
|
const drawn = board.length > 0 && board[board.length - 1].key === 'tail'
|
|
1213
1352
|
const planned = new Set(drawn ? board.filter(row => row.key.startsWith('lane:')).map(row => row.key.slice('lane:'.length)) : [])
|
|
1214
1353
|
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(
|
|
1354
|
+
return [...body, ...rows.filter(r => !planned.has(r.label)).map(r => flatRow($, e, r))]
|
|
1216
1355
|
}
|
|
1217
1356
|
|
|
1218
1357
|
async function drawPane($, e) {
|
|
1219
1358
|
const { Box, Text } = $.ui.resolve(e)
|
|
1220
1359
|
const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
|
|
1221
|
-
const heading = text => h(Box, { marginTop: 1 }, h(Text,
|
|
1360
|
+
const heading = text => h(Box, { marginTop: 1 }, h(Text, HEADING_PROPS, text))
|
|
1361
|
+
// The launch-guard line, in the alarm colour only while its ruling refuses the next launch.
|
|
1362
|
+
const guardLine = async () => {
|
|
1363
|
+
const guard = await launchGuardText($)
|
|
1364
|
+
return keyed('launch-guard', guard.text, guard.refused ? { color: PALETTE.alarm } : null)
|
|
1365
|
+
}
|
|
1222
1366
|
if (run.phase === 'idle' && !run.summary) {
|
|
1223
1367
|
// 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),
|
|
1368
|
+
return h(Box, { flexDirection: 'column' }, h(Text, { dimColor: true }, NO_RUN_LINE), await guardLine())
|
|
1225
1369
|
}
|
|
1226
1370
|
|
|
1227
1371
|
const out = []
|
|
1228
|
-
out.push(h(Text,
|
|
1372
|
+
out.push(h(Text, HEADING_PROPS, `${run.change || 'ship run'}${run.runId ? ` · run ${run.runId}` : ''}`))
|
|
1229
1373
|
out.push(keyed('action', `action: ${run.action || 'starting'}${run.phase === 'closed' ? ' (closed)' : ''}`))
|
|
1230
|
-
|
|
1374
|
+
const activity = await lastActivityText($)
|
|
1375
|
+
out.push(
|
|
1376
|
+
h(
|
|
1377
|
+
Box,
|
|
1378
|
+
{ key: 'last-activity' },
|
|
1379
|
+
h(Text, null, activity.line, ...(activity.word ? [' · ', h(Text, { color: PALETTE.warn }, activity.word)] : []))
|
|
1380
|
+
)
|
|
1381
|
+
)
|
|
1231
1382
|
out.push(h(Text, { dimColor: true }, RECORD_LINE))
|
|
1232
1383
|
|
|
1233
1384
|
out.push(heading('waves'))
|
|
@@ -1236,18 +1387,18 @@ async function drawPane($, e) {
|
|
|
1236
1387
|
out.push(heading('agents'))
|
|
1237
1388
|
if (!run.agents.size) out.push(h(Text, { dimColor: true }, 'no agent has reported a request yet'))
|
|
1238
1389
|
for (const a of run.agents.values()) {
|
|
1239
|
-
const lane = laneForAgent(a)
|
|
1240
1390
|
const n = a.requests === 1 ? '1 request' : `${a.requests} requests`
|
|
1241
1391
|
const done = a.reason ? `${a.reason}${a.durationMs !== null ? ` ${(a.durationMs / 1000).toFixed(1)}s` : ''}` : 'running'
|
|
1242
1392
|
out.push(
|
|
1243
1393
|
h(
|
|
1244
1394
|
Box,
|
|
1245
1395
|
{ key: `agent-${a.id}`, flexDirection: 'column' },
|
|
1246
|
-
h(Box, null,
|
|
1396
|
+
h(Box, null, agentLine(Text, a, done)),
|
|
1247
1397
|
h(Box, null, h(Text, { dimColor: true }, `${n} · ${tokensText(a)}`))
|
|
1248
1398
|
)
|
|
1249
1399
|
)
|
|
1250
1400
|
}
|
|
1401
|
+
if ([...run.agents.values()].some(a => !agentName(a))) out.push(keyed('agents-unmatched', UNMATCHED_LINE, { dimColor: true }))
|
|
1251
1402
|
|
|
1252
1403
|
// One plain read feeds the session lines and the plan windows; a breakdown
|
|
1253
1404
|
// is never asked for, because only the plain call is free (design D4).
|
|
@@ -1279,14 +1430,14 @@ async function drawPane($, e) {
|
|
|
1279
1430
|
h(
|
|
1280
1431
|
Box,
|
|
1281
1432
|
{ key: 'banners', flexDirection: 'column' },
|
|
1282
|
-
...(run.banners.length ? run.banners.map(b => h(Text,
|
|
1433
|
+
...(run.banners.length ? run.banners.map(b => h(Text, { color: PALETTE.warn }, b)) : [h(Text, { dimColor: true }, 'none so far')])
|
|
1283
1434
|
)
|
|
1284
1435
|
)
|
|
1285
1436
|
|
|
1286
1437
|
out.push(heading('refusals'))
|
|
1287
1438
|
const denials = guardDenialLine(run.guardDenials)
|
|
1288
|
-
if (denials) out.push(keyed('guard-denials', denials))
|
|
1289
|
-
out.push(
|
|
1439
|
+
if (denials) out.push(keyed('guard-denials', denials, { color: PALETTE.alarm }))
|
|
1440
|
+
out.push(await guardLine())
|
|
1290
1441
|
|
|
1291
1442
|
if (run.summary) {
|
|
1292
1443
|
out.push(heading('close summary'))
|
|
@@ -1444,6 +1595,12 @@ export function register(on) {
|
|
|
1444
1595
|
return r
|
|
1445
1596
|
}
|
|
1446
1597
|
const held = run
|
|
1598
|
+
// A run agent's command names it: a
|
|
1599
|
+
// briefing it reads joins it to its lane, and a driver line names a relay.
|
|
1600
|
+
if (run.phase === 'live' && str(e.agentId)) {
|
|
1601
|
+
noteBriefing($, e.agentId, command)
|
|
1602
|
+
if (driver) noteRelay($, e.agentId, command)
|
|
1603
|
+
}
|
|
1447
1604
|
const r = await next(e)
|
|
1448
1605
|
// Any Bash call during the run is read for a guard's refusal (guard-commit); only a driver line for a step.
|
|
1449
1606
|
if (held === run) speakRefusal($, r)
|
|
@@ -1470,6 +1627,14 @@ export function register(on) {
|
|
|
1470
1627
|
return r
|
|
1471
1628
|
})
|
|
1472
1629
|
|
|
1630
|
+
// A run agent's Read, observed for the briefing it names and passed on as it
|
|
1631
|
+
// came: the Workflow host raises no spawn event, so a briefing read is how an
|
|
1632
|
+
// agent is joined to its lane.
|
|
1633
|
+
on('tool.call', { tool: 'Read' }, async ($, e, next) => {
|
|
1634
|
+
if (session.interactive && run.phase === 'live' && e.agentId) noteBriefing($, e.agentId, e.file_path)
|
|
1635
|
+
return next(e)
|
|
1636
|
+
})
|
|
1637
|
+
|
|
1473
1638
|
// The tools the manifest's `Edit|Write` matcher names for guard-tests and guard-tasks (design D4).
|
|
1474
1639
|
on('tool.call', { tool: 'Edit' }, editCall)
|
|
1475
1640
|
on('tool.call', { tool: 'Write' }, editCall)
|
|
@@ -1499,9 +1664,8 @@ export function register(on) {
|
|
|
1499
1664
|
// (speak-lane-turn-ends-and-session-cost design D1-D3).
|
|
1500
1665
|
if (a.reason && a.reason !== 'answer' && !a.endToasted) {
|
|
1501
1666
|
a.endToasted = true
|
|
1502
|
-
const row = rowForAgent(a)
|
|
1503
1667
|
try {
|
|
1504
|
-
$.ui.toast(`${
|
|
1668
|
+
$.ui.toast(`${agentName(a) || UNMATCHED} · agent ${a.id} · turn ended: ${a.reason}`)
|
|
1505
1669
|
} catch (err) {
|
|
1506
1670
|
$.ui.log(`interlock meter: the turn-end toast for ${a.id} was refused: ${messageOf(err)}`, { to: 'debug' })
|
|
1507
1671
|
}
|
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
|
+
}
|
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
|