@renzrollon/interlock 1.3.0 → 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 -2
- package/CHANGELOG.md +26 -1
- package/hooks/mod.mjs +397 -89
- package/lib/draw-plan.mjs +31 -12
- package/lib/lane.mjs +31 -7
- package/lib/meter-palette.mjs +51 -0
- package/lib/waves.mjs +1 -1
- package/package.json +2 -2
- /package/{.claude-plugin → assets}/icon.png +0 -0
|
@@ -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",
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
},
|
|
10
10
|
"homepage": "https://github.com/renzrollon/interlock",
|
|
11
11
|
"repository": "https://github.com/renzrollon/interlock",
|
|
12
|
-
"icon": ".claude-plugin/icon.png",
|
|
13
12
|
"license": "MIT",
|
|
14
13
|
"keywords": [
|
|
15
14
|
"openspec",
|
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,32 @@ 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
|
-
##
|
|
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
|
+
|
|
24
|
+
## 1.3.1 — 2026-10-08
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **The ship meter draws each wave as a card, and colours only the state word.** The 1.3.0 pane stretched every wave rule to the pane's width and cut each lane's title with `…`. Each wave is now one bordered card, as wide as the pane up to the board's published default width. A lane is its fixed cells, then its title wrapping beneath them, then, once its agent has spawned, what the host reported serving it, dim. The state word is the only coloured text: `ok` green, `failed` red, `current` yellow, the rest dim, and a lane whose tasks disagree colours each task's own word beside its id. The fixed cells are one text with the coloured words inside it, so in a narrow docked pane the line wraps instead of breaking words apart. `interlock waves --format board` prints exactly what it printed before.
|
|
29
|
+
- **The renderer's rows carry their fields.** `drawPlanBoardRows` gives a wave-header row `parts.title` and a lane row `parts`: batch, label, model, tier, effort, state word, gist, note, task ids, ordered-after ids and each task's own word. A drawer colours a word without searching the cut line. The lines form and the Mermaid output are byte-identical to 1.3.0.
|
|
30
|
+
- **A hook parameter renamed from `$` is pinned.** The meter's spawn, session-start and command hooks name their parameters for a reader. The allow-list scan reads only `$.`, so `test/spine/mod-pins.test.mjs` now fails when a renamed engine handle is read through, or a renamed forward is called with anything but the hook's own input.
|
|
31
|
+
- **The listing image moved out of the manifest folder.** It sat in `.claude-plugin/`, where the plugin layout keeps only the manifest. It now ships from the plugin's assets folder, `package.json` lists that folder in `files`, and `plugin.json` no longer names the image.
|
|
32
|
+
|
|
33
|
+
## 1.3.0 — 2026-10-08
|
|
9
34
|
|
|
10
35
|
### Added
|
|
11
36
|
|
package/hooks/mod.mjs
CHANGED
|
@@ -64,10 +64,20 @@
|
|
|
64
64
|
// drawn from one source: the plan summary the adoption and replan steps carry,
|
|
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
|
-
// The
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// renderer that throws.
|
|
67
|
+
// The module lays those rows as one bordered card per wave, wraps the gist,
|
|
68
|
+
// and colours the state word the renderer chose and the lane's label. It keys the lanes,
|
|
69
|
+
// appends what the host observed of a lane, and speaks every fallback: a CLI
|
|
70
|
+
// that relays no plan, a pane narrower than the board, a renderer that throws.
|
|
71
|
+
//
|
|
72
|
+
// THE 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.
|
|
71
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
|
|
@@ -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.
|
|
@@ -595,6 +617,117 @@ async function fileWrite($, path) {
|
|
|
595
617
|
}
|
|
596
618
|
}
|
|
597
619
|
|
|
620
|
+
/** What an `agent.spawn` chain returned, read for the meter and handed back unchanged. */
|
|
621
|
+
async function noteSpawn($, input, spawned) {
|
|
622
|
+
if (session.interactive && run.phase === 'live') {
|
|
623
|
+
if (spawned && str(spawned.agentId) && input && typeof input.prompt === 'string') {
|
|
624
|
+
const found = [...input.prompt.matchAll(BRIEFING_SHA)]
|
|
625
|
+
if (found.length) agentOf(spawned.agentId).sha = found[found.length - 1][1]
|
|
626
|
+
}
|
|
627
|
+
if (await stamp($)) redrawWord($)
|
|
628
|
+
}
|
|
629
|
+
// An Explore investigator the chain let through (observe-the-spec-run-live design D5).
|
|
630
|
+
if (specWatching() && input && input.subagentType === 'Explore' && spawned && typeof spawned.model === 'string') {
|
|
631
|
+
await countInvestigator($)
|
|
632
|
+
}
|
|
633
|
+
return spawned
|
|
634
|
+
}
|
|
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
|
+
|
|
702
|
+
/**
|
|
703
|
+
* A clear, resume or fork ends the run the meter holds, before the chain
|
|
704
|
+
* beneath runs. Any other source, and a non-interactive session, changes
|
|
705
|
+
* nothing. Never throws.
|
|
706
|
+
*/
|
|
707
|
+
function endRunAtBoundary($, input) {
|
|
708
|
+
if (!session.interactive) return
|
|
709
|
+
try {
|
|
710
|
+
const boundary = BOUNDARIES.get(input.source)
|
|
711
|
+
if (!boundary) return
|
|
712
|
+
const ended = run
|
|
713
|
+
stopTick()
|
|
714
|
+
run = freshRun()
|
|
715
|
+
start = freshStart()
|
|
716
|
+
spec = freshSpec({ afterBoundary: true })
|
|
717
|
+
setStatus($)
|
|
718
|
+
$.ui.invalidate('ui.render')
|
|
719
|
+
const what = ended.phase === 'idle' ? 'no run was held' : `run ${ended.runId || '(no id)'} is no longer drawn`
|
|
720
|
+
$.ui.log(`interlock meter: ${boundary} started the session over; ${what}`, { to: 'debug' })
|
|
721
|
+
} catch (err) {
|
|
722
|
+
$.ui.log(`interlock meter: the session boundary was not applied: ${messageOf(err)}`, { to: 'debug' })
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Once the chain beneath has run: the preflight's report, read for every source. */
|
|
727
|
+
async function readPreflightAfterStart($, input) {
|
|
728
|
+
if (session.interactive) await readPreflight($, str(input.cwd) || session.cwd)
|
|
729
|
+
}
|
|
730
|
+
|
|
598
731
|
/** One Explore investigator the chain let through (design D5). Never throws. */
|
|
599
732
|
async function countInvestigator($) {
|
|
600
733
|
try {
|
|
@@ -613,11 +746,14 @@ function drawSpecPane($, e) {
|
|
|
613
746
|
return h(Box, { flexDirection: 'column' }, ...specPaneLines(spec).map(l => h(Box, { key: l.key }, h(Text, props(l), l.text))))
|
|
614
747
|
}
|
|
615
748
|
|
|
616
|
-
/**
|
|
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
|
+
*/
|
|
617
753
|
async function launchGuardText($) {
|
|
618
754
|
const { record, now } = await guardFacts($)
|
|
619
755
|
const ruling = decideLaunch(record, now)
|
|
620
|
-
return launchGuardLine(ruling, now)
|
|
756
|
+
return { text: launchGuardLine(ruling, now), refused: now !== null && ruling.decision !== 'allow' && Boolean(ruling.reason) }
|
|
621
757
|
}
|
|
622
758
|
|
|
623
759
|
/** Names, once per run record, a step that crossed with no live run (design D4). */
|
|
@@ -674,6 +810,8 @@ function agentOf(id) {
|
|
|
674
810
|
reason: null,
|
|
675
811
|
durationMs: null,
|
|
676
812
|
sha: null,
|
|
813
|
+
// The two words after `interlock` in the first driver line it ran while no row named it.
|
|
814
|
+
relay: null,
|
|
677
815
|
// Whether its turn end has been spoken: once per agent per run (speak-lane-turn-ends design D1).
|
|
678
816
|
endToasted: false
|
|
679
817
|
}
|
|
@@ -786,6 +924,8 @@ async function applyRecord($, record) {
|
|
|
786
924
|
model: str(s.model),
|
|
787
925
|
effort: str(s.effort),
|
|
788
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),
|
|
789
929
|
// Spawned by a step that dispatches a batch: a lane, planned or not.
|
|
790
930
|
batch: BATCH_ACTIONS.has(record.action)
|
|
791
931
|
})
|
|
@@ -934,9 +1074,14 @@ function rowForAgent(a) {
|
|
|
934
1074
|
return null
|
|
935
1075
|
}
|
|
936
1076
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
|
940
1085
|
}
|
|
941
1086
|
|
|
942
1087
|
/**
|
|
@@ -962,24 +1107,53 @@ function sessionLines(usage, failure) {
|
|
|
962
1107
|
|
|
963
1108
|
/**
|
|
964
1109
|
* `last activity <ISO>` or `last activity unknown`, then the quiet word while a
|
|
965
|
-
* live run is quiet
|
|
966
|
-
* 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).
|
|
967
1113
|
*/
|
|
968
1114
|
async function lastActivityText($) {
|
|
969
|
-
if (run.lastActivityAt === null) return 'last activity unknown'
|
|
1115
|
+
if (run.lastActivityAt === null) return { line: 'last activity unknown', word: null }
|
|
970
1116
|
const at = new Date(run.lastActivityAt).toISOString()
|
|
971
1117
|
const word = run.phase === 'live' ? quietWord(quietMs(run.lastActivityAt, await clockNow($))) : null
|
|
972
|
-
return `last activity ${at}
|
|
1118
|
+
return { line: `last activity ${at}`, word }
|
|
973
1119
|
}
|
|
974
1120
|
|
|
975
|
-
/**
|
|
976
|
-
|
|
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)
|
|
977
1145
|
const a = agentForSha(r.sha)
|
|
978
1146
|
const served = a && a.models.length ? a.models.join(', ') : '?'
|
|
979
1147
|
const state = a ? a.reason || 'running' : 'waiting'
|
|
980
|
-
return
|
|
981
|
-
|
|
982
|
-
|
|
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
|
+
)
|
|
983
1157
|
)
|
|
984
1158
|
}
|
|
985
1159
|
|
|
@@ -1019,9 +1193,136 @@ function boardKey(key) {
|
|
|
1019
1193
|
return `${kind === 'wave' ? 'wave-block' : `wave-${kind}`}-${rest.join('-')}`
|
|
1020
1194
|
}
|
|
1021
1195
|
|
|
1196
|
+
/** The width a card and the renderer share: the body, capped at the published default. */
|
|
1197
|
+
function boardColumns(e) {
|
|
1198
|
+
const body = e.props && isInt(e.props.bodyColumns) && e.props.bodyColumns > 0
|
|
1199
|
+
? e.props.bodyColumns
|
|
1200
|
+
: LIMITS.waveBoardDefaultColumns
|
|
1201
|
+
return body < LIMITS.waveBoardMinColumns ? body : Math.min(body, LIMITS.waveBoardDefaultColumns)
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function waveTitleOf(row) {
|
|
1205
|
+
if (row.parts && typeof row.parts.title === 'string' && row.parts.title) return row.parts.title
|
|
1206
|
+
const text = row.text || ''
|
|
1207
|
+
if (!text.startsWith('┌─ ')) return text
|
|
1208
|
+
const rest = text.slice(3)
|
|
1209
|
+
const cut = rest.search(/ ─/)
|
|
1210
|
+
return (cut === -1 ? rest : rest.slice(0, cut)).trimEnd()
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/** A lane's ids cell, as spans: each task's own state word coloured when its tasks disagree. */
|
|
1214
|
+
function idSpans(Text, parts) {
|
|
1215
|
+
if (parts.state === 'per task' && Array.isArray(parts.tasks) && parts.tasks.length) {
|
|
1216
|
+
const spans = [' [']
|
|
1217
|
+
parts.tasks.forEach((task, i) => {
|
|
1218
|
+
if (i) spans.push(', ')
|
|
1219
|
+
if (!task.word) return spans.push(task.id)
|
|
1220
|
+
spans.push(`${task.id} `, h(Text, stateProps(task.word), task.word))
|
|
1221
|
+
})
|
|
1222
|
+
spans.push(']')
|
|
1223
|
+
return spans
|
|
1224
|
+
}
|
|
1225
|
+
return parts.ids ? [` [${parts.ids}]`] : []
|
|
1226
|
+
}
|
|
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
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* A lane in its card: the fixed cells as one Text, the gist wrapping beneath,
|
|
1246
|
+
* then the host's note, dim. The coloured words are spans nested in that one
|
|
1247
|
+
* Text, because a row of separate elements shrinks each into its own column
|
|
1248
|
+
* and breaks words apart once the line is wider than the card.
|
|
1249
|
+
*/
|
|
1250
|
+
function drawLane($, e, row) {
|
|
1251
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
1252
|
+
const key = boardKey(row.key)
|
|
1253
|
+
const parts = row.parts
|
|
1254
|
+
if (!parts) return h(Box, { key }, h(Text, { wrap: 'truncate-end' }, row.text))
|
|
1255
|
+
const line = [
|
|
1256
|
+
...headSpans(Text, parts),
|
|
1257
|
+
...(parts.state ? [' ', h(Text, stateProps(parts.state), parts.state)] : []),
|
|
1258
|
+
...idSpans(Text, parts),
|
|
1259
|
+
...(Array.isArray(parts.after) && parts.after.length ? [` ←${parts.after.join(',')}`] : [])
|
|
1260
|
+
]
|
|
1261
|
+
const kids = [
|
|
1262
|
+
h(Text, { wrap: 'wrap' }, ...line),
|
|
1263
|
+
parts.gist ? h(Box, { key: `${key}-gist` }, h(Text, { wrap: 'wrap' }, parts.gist)) : null,
|
|
1264
|
+
parts.note ? h(Box, { key: `${key}-note` }, h(Text, { dimColor: true }, parts.note)) : null
|
|
1265
|
+
].filter(Boolean)
|
|
1266
|
+
return h(Box, { key, flexDirection: 'column' }, ...kids)
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function drawBoardCards($, e, board, width) {
|
|
1270
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
1271
|
+
const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
|
|
1272
|
+
const out = []
|
|
1273
|
+
let index = null
|
|
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
|
|
1277
|
+
const flush = () => {
|
|
1278
|
+
if (index === null) return
|
|
1279
|
+
out.push(
|
|
1280
|
+
h(
|
|
1281
|
+
Box,
|
|
1282
|
+
{
|
|
1283
|
+
key: `wave-card-${index}`,
|
|
1284
|
+
flexDirection: 'column',
|
|
1285
|
+
borderStyle: 'single',
|
|
1286
|
+
...(current ? { borderColor: PALETTE.current } : { borderDimColor: true }),
|
|
1287
|
+
width,
|
|
1288
|
+
paddingLeft: 1,
|
|
1289
|
+
paddingRight: 1,
|
|
1290
|
+
marginBottom: 1
|
|
1291
|
+
},
|
|
1292
|
+
...kids
|
|
1293
|
+
)
|
|
1294
|
+
)
|
|
1295
|
+
index = null
|
|
1296
|
+
kids = null
|
|
1297
|
+
current = false
|
|
1298
|
+
}
|
|
1299
|
+
for (const row of board) {
|
|
1300
|
+
if (row.key.startsWith('wave:') && row.key.endsWith(':end')) continue
|
|
1301
|
+
if (row.key.startsWith('wave:')) {
|
|
1302
|
+
flush()
|
|
1303
|
+
index = row.key.slice('wave:'.length)
|
|
1304
|
+
kids = [h(Box, { key: boardKey(row.key) }, h(Text, { bold: true }, waveTitleOf(row)))]
|
|
1305
|
+
continue
|
|
1306
|
+
}
|
|
1307
|
+
if (row.key.startsWith('lane:')) {
|
|
1308
|
+
const lane = drawLane($, e, row)
|
|
1309
|
+
if (kids) {
|
|
1310
|
+
kids.push(lane)
|
|
1311
|
+
if (isCurrentLane(row.parts)) current = true
|
|
1312
|
+
} else out.push(lane)
|
|
1313
|
+
continue
|
|
1314
|
+
}
|
|
1315
|
+
flush()
|
|
1316
|
+
out.push(keyed(boardKey(row.key), row.text, row.key === 'header' || row.key === 'tail' ? null : { wrap: 'truncate-end' }))
|
|
1317
|
+
}
|
|
1318
|
+
flush()
|
|
1319
|
+
return out
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1022
1322
|
/**
|
|
1023
1323
|
* The waves section (design D7, D8): the board once a plan summary has
|
|
1024
|
-
* crossed,
|
|
1324
|
+
* crossed, one bordered card per wave at the body width capped at the board's
|
|
1325
|
+
* default, the rows between cards one truncating text each; then a flat
|
|
1025
1326
|
* row for each spawn no planned lane carries. Before a summary, the flat rows,
|
|
1026
1327
|
* beneath a spoken line once a batch was dispatched. Below the board's minimum
|
|
1027
1328
|
* the renderer's spoken line stands in for it, and a renderer that throws is
|
|
@@ -1035,57 +1336,69 @@ function drawWaves($, e) {
|
|
|
1035
1336
|
if (!run.plan) {
|
|
1036
1337
|
if (!rows.length) return [h(Text, { dimColor: true }, NO_LANES_LINE)]
|
|
1037
1338
|
const dispatched = run.dispatched !== null || rows.some(r => r.batch)
|
|
1038
|
-
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))]
|
|
1039
1340
|
}
|
|
1040
|
-
const
|
|
1041
|
-
const width = isInt(props.bodyColumns) && props.bodyColumns > 0 ? props.bodyColumns : LIMITS.waveBoardDefaultColumns
|
|
1341
|
+
const width = boardColumns(e)
|
|
1042
1342
|
let board
|
|
1043
1343
|
try {
|
|
1044
1344
|
board = drawPlanBoardRows(run.plan, { columns: width, state: boardOverlay(run.plan), notes: laneNotes() })
|
|
1045
1345
|
} catch (err) {
|
|
1046
1346
|
$.ui.log(`interlock meter: ${BOARD_FAILED_LINE}: ${messageOf(err)}`, { to: 'debug' })
|
|
1047
|
-
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))]
|
|
1048
1348
|
}
|
|
1049
1349
|
// Every board ends in its tail; the renderer's spoken line for a narrow body
|
|
1050
1350
|
// is one row without one, and the lanes are then drawn flat beneath it.
|
|
1051
1351
|
const drawn = board.length > 0 && board[board.length - 1].key === 'tail'
|
|
1052
1352
|
const planned = new Set(drawn ? board.filter(row => row.key.startsWith('lane:')).map(row => row.key.slice('lane:'.length)) : [])
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
...rows.filter(r => !planned.has(r.label)).map(r => flatRow(keyed, r))
|
|
1056
|
-
]
|
|
1353
|
+
const body = drawn ? drawBoardCards($, e, board, width) : board.map(row => line(boardKey(row.key), row.text))
|
|
1354
|
+
return [...body, ...rows.filter(r => !planned.has(r.label)).map(r => flatRow($, e, r))]
|
|
1057
1355
|
}
|
|
1058
1356
|
|
|
1059
1357
|
async function drawPane($, e) {
|
|
1060
1358
|
const { Box, Text } = $.ui.resolve(e)
|
|
1061
1359
|
const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, 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
|
+
}
|
|
1062
1366
|
if (run.phase === 'idle' && !run.summary) {
|
|
1063
1367
|
// The guard runs in every session, so its line stands here too (design D5).
|
|
1064
|
-
return h(Box, { flexDirection: 'column' }, h(Text, { dimColor: true }, NO_RUN_LINE),
|
|
1368
|
+
return h(Box, { flexDirection: 'column' }, h(Text, { dimColor: true }, NO_RUN_LINE), await guardLine())
|
|
1065
1369
|
}
|
|
1066
1370
|
|
|
1067
1371
|
const out = []
|
|
1068
|
-
out.push(h(Text,
|
|
1372
|
+
out.push(h(Text, HEADING_PROPS, `${run.change || 'ship run'}${run.runId ? ` · run ${run.runId}` : ''}`))
|
|
1069
1373
|
out.push(keyed('action', `action: ${run.action || 'starting'}${run.phase === 'closed' ? ' (closed)' : ''}`))
|
|
1070
|
-
|
|
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
|
+
)
|
|
1071
1382
|
out.push(h(Text, { dimColor: true }, RECORD_LINE))
|
|
1072
1383
|
|
|
1073
|
-
out.push(
|
|
1384
|
+
out.push(heading('waves'))
|
|
1074
1385
|
out.push(...drawWaves($, e))
|
|
1075
1386
|
|
|
1076
|
-
out.push(
|
|
1387
|
+
out.push(heading('agents'))
|
|
1077
1388
|
if (!run.agents.size) out.push(h(Text, { dimColor: true }, 'no agent has reported a request yet'))
|
|
1078
1389
|
for (const a of run.agents.values()) {
|
|
1079
|
-
const lane = laneForAgent(a)
|
|
1080
1390
|
const n = a.requests === 1 ? '1 request' : `${a.requests} requests`
|
|
1081
1391
|
const done = a.reason ? `${a.reason}${a.durationMs !== null ? ` ${(a.durationMs / 1000).toFixed(1)}s` : ''}` : 'running'
|
|
1082
1392
|
out.push(
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1393
|
+
h(
|
|
1394
|
+
Box,
|
|
1395
|
+
{ key: `agent-${a.id}`, flexDirection: 'column' },
|
|
1396
|
+
h(Box, null, agentLine(Text, a, done)),
|
|
1397
|
+
h(Box, null, h(Text, { dimColor: true }, `${n} · ${tokensText(a)}`))
|
|
1086
1398
|
)
|
|
1087
1399
|
)
|
|
1088
1400
|
}
|
|
1401
|
+
if ([...run.agents.values()].some(a => !agentName(a))) out.push(keyed('agents-unmatched', UNMATCHED_LINE, { dimColor: true }))
|
|
1089
1402
|
|
|
1090
1403
|
// One plain read feeds the session lines and the plan windows; a breakdown
|
|
1091
1404
|
// is never asked for, because only the plain call is free (design D4).
|
|
@@ -1098,11 +1411,11 @@ async function drawPane($, e) {
|
|
|
1098
1411
|
failure = messageOf(err)
|
|
1099
1412
|
}
|
|
1100
1413
|
const [contextLine, costLine] = sessionLines(usage, failure)
|
|
1101
|
-
out.push(
|
|
1414
|
+
out.push(heading('session'))
|
|
1102
1415
|
out.push(keyed('session-context', contextLine))
|
|
1103
1416
|
out.push(keyed('session-cost', costLine))
|
|
1104
1417
|
|
|
1105
|
-
out.push(
|
|
1418
|
+
out.push(heading('plan windows'))
|
|
1106
1419
|
const windows = usage && Array.isArray(usage.rateLimits) ? usage.rateLimits : []
|
|
1107
1420
|
if (failure !== null) out.push(h(Text, { dimColor: true }, `plan windows unavailable: ${failure}`))
|
|
1108
1421
|
else if (!windows.length) out.push(h(Text, { dimColor: true }, 'none reported (off a subscription the session has none)'))
|
|
@@ -1112,22 +1425,22 @@ async function drawPane($, e) {
|
|
|
1112
1425
|
out.push(keyed(`plan-window-${kind}`, `${kind} ${w.percentUsed}% used${w.resetsAt ? ` · resets ${w.resetsAt}` : ''}`))
|
|
1113
1426
|
}
|
|
1114
1427
|
|
|
1115
|
-
out.push(
|
|
1428
|
+
out.push(heading('banners'))
|
|
1116
1429
|
out.push(
|
|
1117
1430
|
h(
|
|
1118
1431
|
Box,
|
|
1119
1432
|
{ key: 'banners', flexDirection: 'column' },
|
|
1120
|
-
...(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')])
|
|
1121
1434
|
)
|
|
1122
1435
|
)
|
|
1123
1436
|
|
|
1124
|
-
out.push(
|
|
1437
|
+
out.push(heading('refusals'))
|
|
1125
1438
|
const denials = guardDenialLine(run.guardDenials)
|
|
1126
|
-
if (denials) out.push(keyed('guard-denials', denials))
|
|
1127
|
-
out.push(
|
|
1439
|
+
if (denials) out.push(keyed('guard-denials', denials, { color: PALETTE.alarm }))
|
|
1440
|
+
out.push(await guardLine())
|
|
1128
1441
|
|
|
1129
1442
|
if (run.summary) {
|
|
1130
|
-
out.push(
|
|
1443
|
+
out.push(heading('close summary'))
|
|
1131
1444
|
out.push(h(Box, { key: 'summary', flexDirection: 'column' }, h(Text, null, run.summary)))
|
|
1132
1445
|
}
|
|
1133
1446
|
return h(Box, { flexDirection: 'column' }, ...out)
|
|
@@ -1282,6 +1595,12 @@ export function register(on) {
|
|
|
1282
1595
|
return r
|
|
1283
1596
|
}
|
|
1284
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
|
+
}
|
|
1285
1604
|
const r = await next(e)
|
|
1286
1605
|
// Any Bash call during the run is read for a guard's refusal (guard-commit); only a driver line for a step.
|
|
1287
1606
|
if (held === run) speakRefusal($, r)
|
|
@@ -1308,6 +1627,14 @@ export function register(on) {
|
|
|
1308
1627
|
return r
|
|
1309
1628
|
})
|
|
1310
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
|
+
|
|
1311
1638
|
// The tools the manifest's `Edit|Write` matcher names for guard-tests and guard-tasks (design D4).
|
|
1312
1639
|
on('tool.call', { tool: 'Edit' }, editCall)
|
|
1313
1640
|
on('tool.call', { tool: 'Write' }, editCall)
|
|
@@ -1337,9 +1664,8 @@ export function register(on) {
|
|
|
1337
1664
|
// (speak-lane-turn-ends-and-session-cost design D1-D3).
|
|
1338
1665
|
if (a.reason && a.reason !== 'answer' && !a.endToasted) {
|
|
1339
1666
|
a.endToasted = true
|
|
1340
|
-
const row = rowForAgent(a)
|
|
1341
1667
|
try {
|
|
1342
|
-
$.ui.toast(`${
|
|
1668
|
+
$.ui.toast(`${agentName(a) || UNMATCHED} · agent ${a.id} · turn ended: ${a.reason}`)
|
|
1343
1669
|
} catch (err) {
|
|
1344
1670
|
$.ui.log(`interlock meter: the turn-end toast for ${a.id} was refused: ${messageOf(err)}`, { to: 'debug' })
|
|
1345
1671
|
}
|
|
@@ -1350,18 +1676,12 @@ export function register(on) {
|
|
|
1350
1676
|
return next(e)
|
|
1351
1677
|
})
|
|
1352
1678
|
|
|
1353
|
-
on
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
}
|
|
1360
|
-
if (await stamp($)) redrawWord($)
|
|
1361
|
-
}
|
|
1362
|
-
// An Explore investigator the chain let through, counted for the spec run (observe-the-spec-run-live design D5).
|
|
1363
|
-
if (specWatching() && e.subagentType === 'Explore' && r && typeof r.model === 'string') await countInvestigator($)
|
|
1364
|
-
return r
|
|
1679
|
+
// Pass the spawn on unchanged. The three parameter names are plain identifiers
|
|
1680
|
+
// and are not bound anywhere else in this file, so the value given to the
|
|
1681
|
+
// next handler is the event parameter. permissionMode is left as the parent
|
|
1682
|
+
// set it. The meter reads the chain's result and returns that same result.
|
|
1683
|
+
on('agent.spawn', async (meterSpawnApi, meterSpawnInput, meterSpawnForward) => {
|
|
1684
|
+
return meterSpawnForward(meterSpawnInput).then(spawned => noteSpawn(meterSpawnApi, meterSpawnInput, spawned))
|
|
1365
1685
|
})
|
|
1366
1686
|
|
|
1367
1687
|
// The session boundary (design D4). /clear, /resume and /branch start the
|
|
@@ -1374,27 +1694,15 @@ export function register(on) {
|
|
|
1374
1694
|
// source (show-preflight-and-interrupted-runs-at-session-start design D5).
|
|
1375
1695
|
// The kit raises this event; Claude Code 2.1.291 raises no classic event to
|
|
1376
1696
|
// a module, so there the prompt hook above reads the report instead.
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
spec = freshSpec({ afterBoundary: true })
|
|
1387
|
-
setStatus($)
|
|
1388
|
-
$.ui.invalidate('ui.render')
|
|
1389
|
-
const what = ended.phase === 'idle' ? 'no run was held' : `run ${ended.runId || '(no id)'} is no longer drawn`
|
|
1390
|
-
$.ui.log(`interlock meter: ${boundary} started the session over; ${what}`, { to: 'debug' })
|
|
1391
|
-
}
|
|
1392
|
-
} catch (err) {
|
|
1393
|
-
$.ui.log(`interlock meter: the session boundary was not applied: ${messageOf(err)}`, { to: 'debug' })
|
|
1394
|
-
}
|
|
1395
|
-
const r = await next(e)
|
|
1396
|
-
await readPreflight($, str(e.cwd) || session.cwd)
|
|
1397
|
-
return r
|
|
1697
|
+
//
|
|
1698
|
+
// Pass the event on unchanged. The value this hook returns is the forward
|
|
1699
|
+
// itself: no first message, no permission decision. The meter's reset runs
|
|
1700
|
+
// before that chain and the preflight read after it; neither becomes the return.
|
|
1701
|
+
on('classic.SessionStart', async (meterSessionApi, meterSessionInput, meterSessionForward) => {
|
|
1702
|
+
endRunAtBoundary(meterSessionApi, meterSessionInput)
|
|
1703
|
+
const sessionForwarded = meterSessionForward(meterSessionInput)
|
|
1704
|
+
await sessionForwarded.then(() => readPreflightAfterStart(meterSessionApi, meterSessionInput))
|
|
1705
|
+
return sessionForwarded
|
|
1398
1706
|
})
|
|
1399
1707
|
|
|
1400
1708
|
on('ui.render', { component: 'Spinner' }, ($, e, next) => {
|
|
@@ -1413,27 +1721,27 @@ export function register(on) {
|
|
|
1413
1721
|
|
|
1414
1722
|
on('ui.render', { component: 'Pane', requestId: SPEC_PANE }, async ($, e) => drawSpecPane($, e))
|
|
1415
1723
|
|
|
1416
|
-
on('command.run', { command: PANE }, async
|
|
1417
|
-
await openPane(
|
|
1724
|
+
on('command.run', { command: PANE }, async paneHost => {
|
|
1725
|
+
await openPane(paneHost)
|
|
1418
1726
|
return {}
|
|
1419
1727
|
})
|
|
1420
1728
|
|
|
1421
1729
|
// Typed before any prompt, the command reads the report itself, so it never
|
|
1422
1730
|
// opens on a report no prompt has fetched yet (design D5).
|
|
1423
|
-
on('command.run', { command: PREFLIGHT_PANE }, async
|
|
1424
|
-
if (session.interactive) await readPreflight(
|
|
1425
|
-
await openPane(
|
|
1731
|
+
on('command.run', { command: PREFLIGHT_PANE }, async preflightHost => {
|
|
1732
|
+
if (session.interactive) await readPreflight(preflightHost, session.cwd)
|
|
1733
|
+
await openPane(preflightHost, PREFLIGHT_PANE, 'Interlock preflight')
|
|
1426
1734
|
return {}
|
|
1427
1735
|
})
|
|
1428
1736
|
|
|
1429
|
-
on('command.run', { command: HANDOFF_PANE }, async
|
|
1430
|
-
if (session.interactive) await readPreflight(
|
|
1431
|
-
await openPane(
|
|
1737
|
+
on('command.run', { command: HANDOFF_PANE }, async handoffHost => {
|
|
1738
|
+
if (session.interactive) await readPreflight(handoffHost, session.cwd)
|
|
1739
|
+
await openPane(handoffHost, HANDOFF_PANE, 'Interlock handoff')
|
|
1432
1740
|
return {}
|
|
1433
1741
|
})
|
|
1434
1742
|
|
|
1435
|
-
on('command.run', { command: SPEC_PANE }, async
|
|
1436
|
-
await openPane(
|
|
1743
|
+
on('command.run', { command: SPEC_PANE }, async specCmdHost => {
|
|
1744
|
+
await openPane(specCmdHost, SPEC_PANE, SPEC_TITLE)
|
|
1437
1745
|
return {}
|
|
1438
1746
|
})
|
|
1439
1747
|
}
|
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. */
|
|
@@ -145,12 +145,13 @@ function taskWord(overlay, id, position, batch) {
|
|
|
145
145
|
/** A lane's state cell and ids cell: one word when its tasks agree, else per task. */
|
|
146
146
|
function laneCells(overlay, lane, position, batch) {
|
|
147
147
|
const ids = lane.map(task => task.id)
|
|
148
|
-
if (!overlay) return { word: null, ids: ids.length > 1 ? ids.join(', ') : null }
|
|
148
|
+
if (!overlay) return { word: null, ids: ids.length > 1 ? ids.join(', ') : null, tasks: null }
|
|
149
149
|
const words = lane.map(task => taskWord(overlay, task.id, position, batch))
|
|
150
|
+
const tasks = ids.map((id, i) => ({ id, word: words[i] }))
|
|
150
151
|
if (words.every(word => word === words[0])) {
|
|
151
|
-
return { word: words[0], ids: ids.length > 1 ? ids.join(', ') : null }
|
|
152
|
+
return { word: words[0], ids: ids.length > 1 ? ids.join(', ') : null, tasks }
|
|
152
153
|
}
|
|
153
|
-
return { word: PER_TASK, ids: ids.map((id, i) => (words[i] ? `${id} ${words[i]}` : id)).join(', ') }
|
|
154
|
+
return { word: PER_TASK, ids: ids.map((id, i) => (words[i] ? `${id} ${words[i]}` : id)).join(', '), tasks }
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
/**
|
|
@@ -280,7 +281,6 @@ function lanesOf(positions, plan) {
|
|
|
280
281
|
for (const lane of batch) {
|
|
281
282
|
const label = laneLabel(lane)
|
|
282
283
|
const title = laneTitle(lane)
|
|
283
|
-
const prefix = `${label}${TITLE_SEPARATOR}`
|
|
284
284
|
const tier = laneTier(lane)
|
|
285
285
|
out.push({
|
|
286
286
|
lane,
|
|
@@ -288,7 +288,7 @@ function lanesOf(positions, plan) {
|
|
|
288
288
|
batch: b,
|
|
289
289
|
label,
|
|
290
290
|
title,
|
|
291
|
-
gist:
|
|
291
|
+
gist: laneGist(lane),
|
|
292
292
|
model: laneModel(lane),
|
|
293
293
|
tier: tier > 0 ? String(tier) : '?',
|
|
294
294
|
effort: laneEffort(lane) ?? 'inherit',
|
|
@@ -309,7 +309,9 @@ function topRule(text, width) {
|
|
|
309
309
|
|
|
310
310
|
/**
|
|
311
311
|
* The plan as rows of `{ key, text }`: `header`, `wave:<i>`, `lane:<label>`,
|
|
312
|
-
* `wave:<i>:end`, `verify:<i>`, `unplaced:<n>`, `tail`.
|
|
312
|
+
* `wave:<i>:end`, `verify:<i>`, `unplaced:<n>`, `tail`. Lane and wave-header
|
|
313
|
+
* rows also carry `parts`, so a drawer can colour a state word without
|
|
314
|
+
* searching the cut line; `text` is still the CLI line.
|
|
313
315
|
*/
|
|
314
316
|
function boardRows(plan, opts) {
|
|
315
317
|
const width = boardWidth(opts.columns)
|
|
@@ -323,7 +325,11 @@ function boardRows(plan, opts) {
|
|
|
323
325
|
const notes = new Map(Object.entries(isObject(opts.notes) ? opts.notes : {}).filter(([, text]) => isText(text)))
|
|
324
326
|
const mode = overlayHeader(overlay, positions, opts.stateNote)
|
|
325
327
|
const rows = []
|
|
326
|
-
const push = (key, text) =>
|
|
328
|
+
const push = (key, text, parts) => {
|
|
329
|
+
const row = { key, text: fitRow(text, width) }
|
|
330
|
+
if (parts) row.parts = parts
|
|
331
|
+
rows.push(row)
|
|
332
|
+
}
|
|
327
333
|
|
|
328
334
|
push(
|
|
329
335
|
'header',
|
|
@@ -340,9 +346,10 @@ function boardRows(plan, opts) {
|
|
|
340
346
|
const i = position.index - 1
|
|
341
347
|
push(`verify:${i}`, [` verify after idx ${i}`, ...texts[i]].join(' · '))
|
|
342
348
|
}
|
|
343
|
-
|
|
349
|
+
const title = waveTitle(position)
|
|
350
|
+
push(`wave:${position.index}`, topRule(title, width), { title })
|
|
344
351
|
for (const l of lanes.filter(l => l.position === position.index)) {
|
|
345
|
-
const { word, ids } = laneCells(overlay, l.lane, l.position, l.batch)
|
|
352
|
+
const { word, ids, tasks } = laneCells(overlay, l.lane, l.position, l.batch)
|
|
346
353
|
const cells = [
|
|
347
354
|
'│',
|
|
348
355
|
`b${l.batch}`.padEnd(batchCell),
|
|
@@ -359,7 +366,19 @@ function boardRows(plan, opts) {
|
|
|
359
366
|
if (ids) cells.push(`[${ids}]`)
|
|
360
367
|
if (l.after.length) cells.push(`←${l.after.join(',')}`)
|
|
361
368
|
const head = cells.join(' ')
|
|
362
|
-
push(`lane:${l.label}`, l.gist ? `${head}${TITLE_SEPARATOR}${l.gist}` : head.trimEnd()
|
|
369
|
+
push(`lane:${l.label}`, l.gist ? `${head}${TITLE_SEPARATOR}${l.gist}` : head.trimEnd(), {
|
|
370
|
+
batch: `b${l.batch}`,
|
|
371
|
+
label: l.label,
|
|
372
|
+
model: l.model,
|
|
373
|
+
tier: `T${l.tier}`,
|
|
374
|
+
effort: l.effort,
|
|
375
|
+
state: word,
|
|
376
|
+
gist: l.gist,
|
|
377
|
+
note: note || '',
|
|
378
|
+
ids: ids || '',
|
|
379
|
+
after: l.after,
|
|
380
|
+
tasks
|
|
381
|
+
})
|
|
363
382
|
}
|
|
364
383
|
push(`wave:${position.index}:end`, `└${'─'.repeat(width - 1)}`)
|
|
365
384
|
}
|
|
@@ -385,7 +404,7 @@ function boardRows(plan, opts) {
|
|
|
385
404
|
*
|
|
386
405
|
* @param {unknown} plan what `planWaves` emits, or a plan summary; any value draws
|
|
387
406
|
* @param {{columns?: number, state?: object|null, stateNote?: string, notes?: Record<string, string>}} [opts]
|
|
388
|
-
* @returns {{key: string, text: string}[]}
|
|
407
|
+
* @returns {{key: string, text: string, parts?: object}[]}
|
|
389
408
|
*/
|
|
390
409
|
export function drawPlanBoardRows(plan, opts = {}) {
|
|
391
410
|
return boardRows(plan, isObject(opts) ? opts : {})
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@renzrollon/interlock",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"agents",
|
|
23
23
|
"shared",
|
|
24
24
|
".claude-plugin/plugin.json",
|
|
25
|
-
"
|
|
25
|
+
"assets",
|
|
26
26
|
"README.md",
|
|
27
27
|
"LICENSE",
|
|
28
28
|
"CHANGELOG.md"
|
|
File without changes
|