@renzrollon/interlock 1.3.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -2
- package/CHANGELOG.md +10 -1
- package/hooks/mod.mjs +208 -64
- package/lib/draw-plan.mjs +29 -9
- 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.3.
|
|
4
|
+
"version": "1.3.1",
|
|
5
5
|
"description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec. Onboard a repo, produce a reviewed spec, then ship it start-to-commit with parallel agents and a deterministic gate spine.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Carl Rollon",
|
|
@@ -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,16 @@ 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.3.1 — 2026-10-08
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **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.
|
|
13
|
+
- **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.
|
|
14
|
+
- **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.
|
|
15
|
+
- **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.
|
|
16
|
+
|
|
17
|
+
## 1.3.0 — 2026-10-08
|
|
9
18
|
|
|
10
19
|
### Added
|
|
11
20
|
|
package/hooks/mod.mjs
CHANGED
|
@@ -64,10 +64,10 @@
|
|
|
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 only the state word the renderer chose. It keys the lanes,
|
|
69
|
+
// appends what the host observed of a lane, and speaks every fallback: a CLI
|
|
70
|
+
// that relays no plan, a pane narrower than the board, a renderer that throws.
|
|
71
71
|
//
|
|
72
72
|
// THE SESSION START (show-preflight-and-interrupted-runs-at-session-start
|
|
73
73
|
// design D5-D8). Outside a run the module draws two things, both from the
|
|
@@ -595,6 +595,51 @@ async function fileWrite($, path) {
|
|
|
595
595
|
}
|
|
596
596
|
}
|
|
597
597
|
|
|
598
|
+
/** What an `agent.spawn` chain returned, read for the meter and handed back unchanged. */
|
|
599
|
+
async function noteSpawn($, input, spawned) {
|
|
600
|
+
if (session.interactive && run.phase === 'live') {
|
|
601
|
+
if (spawned && str(spawned.agentId) && input && typeof input.prompt === 'string') {
|
|
602
|
+
const found = [...input.prompt.matchAll(BRIEFING_SHA)]
|
|
603
|
+
if (found.length) agentOf(spawned.agentId).sha = found[found.length - 1][1]
|
|
604
|
+
}
|
|
605
|
+
if (await stamp($)) redrawWord($)
|
|
606
|
+
}
|
|
607
|
+
// An Explore investigator the chain let through (observe-the-spec-run-live design D5).
|
|
608
|
+
if (specWatching() && input && input.subagentType === 'Explore' && spawned && typeof spawned.model === 'string') {
|
|
609
|
+
await countInvestigator($)
|
|
610
|
+
}
|
|
611
|
+
return spawned
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* A clear, resume or fork ends the run the meter holds, before the chain
|
|
616
|
+
* beneath runs. Any other source, and a non-interactive session, changes
|
|
617
|
+
* nothing. Never throws.
|
|
618
|
+
*/
|
|
619
|
+
function endRunAtBoundary($, input) {
|
|
620
|
+
if (!session.interactive) return
|
|
621
|
+
try {
|
|
622
|
+
const boundary = BOUNDARIES.get(input.source)
|
|
623
|
+
if (!boundary) return
|
|
624
|
+
const ended = run
|
|
625
|
+
stopTick()
|
|
626
|
+
run = freshRun()
|
|
627
|
+
start = freshStart()
|
|
628
|
+
spec = freshSpec({ afterBoundary: true })
|
|
629
|
+
setStatus($)
|
|
630
|
+
$.ui.invalidate('ui.render')
|
|
631
|
+
const what = ended.phase === 'idle' ? 'no run was held' : `run ${ended.runId || '(no id)'} is no longer drawn`
|
|
632
|
+
$.ui.log(`interlock meter: ${boundary} started the session over; ${what}`, { to: 'debug' })
|
|
633
|
+
} catch (err) {
|
|
634
|
+
$.ui.log(`interlock meter: the session boundary was not applied: ${messageOf(err)}`, { to: 'debug' })
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Once the chain beneath has run: the preflight's report, read for every source. */
|
|
639
|
+
async function readPreflightAfterStart($, input) {
|
|
640
|
+
if (session.interactive) await readPreflight($, str(input.cwd) || session.cwd)
|
|
641
|
+
}
|
|
642
|
+
|
|
598
643
|
/** One Explore investigator the chain let through (design D5). Never throws. */
|
|
599
644
|
async function countInvestigator($) {
|
|
600
645
|
try {
|
|
@@ -1019,9 +1064,126 @@ function boardKey(key) {
|
|
|
1019
1064
|
return `${kind === 'wave' ? 'wave-block' : `wave-${kind}`}-${rest.join('-')}`
|
|
1020
1065
|
}
|
|
1021
1066
|
|
|
1067
|
+
/** Colour only a state word the board printed: hue for ok/failed/current, dim for the rest. */
|
|
1068
|
+
function stateProps(word) {
|
|
1069
|
+
if (word === 'ok') return { color: 'green' }
|
|
1070
|
+
if (word === 'failed') return { color: 'red' }
|
|
1071
|
+
if (word === 'current') return { color: 'yellow' }
|
|
1072
|
+
if (word === 'pending' || word === 'not reached' || word === 'not recorded' || word === 'per task') {
|
|
1073
|
+
return { dimColor: true }
|
|
1074
|
+
}
|
|
1075
|
+
return null
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** The width a card and the renderer share: the body, capped at the published default. */
|
|
1079
|
+
function boardColumns(e) {
|
|
1080
|
+
const body = e.props && isInt(e.props.bodyColumns) && e.props.bodyColumns > 0
|
|
1081
|
+
? e.props.bodyColumns
|
|
1082
|
+
: LIMITS.waveBoardDefaultColumns
|
|
1083
|
+
return body < LIMITS.waveBoardMinColumns ? body : Math.min(body, LIMITS.waveBoardDefaultColumns)
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function waveTitleOf(row) {
|
|
1087
|
+
if (row.parts && typeof row.parts.title === 'string' && row.parts.title) return row.parts.title
|
|
1088
|
+
const text = row.text || ''
|
|
1089
|
+
if (!text.startsWith('┌─ ')) return text
|
|
1090
|
+
const rest = text.slice(3)
|
|
1091
|
+
const cut = rest.search(/ ─/)
|
|
1092
|
+
return (cut === -1 ? rest : rest.slice(0, cut)).trimEnd()
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/** A lane's ids cell, as spans: each task's own state word coloured when its tasks disagree. */
|
|
1096
|
+
function idSpans(Text, parts) {
|
|
1097
|
+
if (parts.state === 'per task' && Array.isArray(parts.tasks) && parts.tasks.length) {
|
|
1098
|
+
const spans = [' [']
|
|
1099
|
+
parts.tasks.forEach((task, i) => {
|
|
1100
|
+
if (i) spans.push(', ')
|
|
1101
|
+
if (!task.word) return spans.push(task.id)
|
|
1102
|
+
spans.push(`${task.id} `, h(Text, stateProps(task.word), task.word))
|
|
1103
|
+
})
|
|
1104
|
+
spans.push(']')
|
|
1105
|
+
return spans
|
|
1106
|
+
}
|
|
1107
|
+
return parts.ids ? [` [${parts.ids}]`] : []
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* A lane in its card: the fixed cells as one Text, the gist wrapping beneath,
|
|
1112
|
+
* then the host's note, dim. The coloured words are spans nested in that one
|
|
1113
|
+
* Text, because a row of separate elements shrinks each into its own column
|
|
1114
|
+
* and breaks words apart once the line is wider than the card.
|
|
1115
|
+
*/
|
|
1116
|
+
function drawLane($, e, row) {
|
|
1117
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
1118
|
+
const key = boardKey(row.key)
|
|
1119
|
+
const parts = row.parts
|
|
1120
|
+
if (!parts) return h(Box, { key }, h(Text, { wrap: 'truncate-end' }, row.text))
|
|
1121
|
+
const head = [parts.batch, parts.label, parts.model, parts.tier, parts.effort].filter(Boolean).join(' ')
|
|
1122
|
+
const line = [
|
|
1123
|
+
...(parts.state ? [`${head} `, h(Text, stateProps(parts.state), parts.state)] : [head]),
|
|
1124
|
+
...idSpans(Text, parts),
|
|
1125
|
+
...(Array.isArray(parts.after) && parts.after.length ? [` ←${parts.after.join(',')}`] : [])
|
|
1126
|
+
]
|
|
1127
|
+
const kids = [
|
|
1128
|
+
h(Text, { wrap: 'wrap' }, ...line),
|
|
1129
|
+
parts.gist ? h(Box, { key: `${key}-gist` }, h(Text, { wrap: 'wrap' }, parts.gist)) : null,
|
|
1130
|
+
parts.note ? h(Box, { key: `${key}-note` }, h(Text, { dimColor: true }, parts.note)) : null
|
|
1131
|
+
].filter(Boolean)
|
|
1132
|
+
return h(Box, { key, flexDirection: 'column' }, ...kids)
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function drawBoardCards($, e, board, width) {
|
|
1136
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
1137
|
+
const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
|
|
1138
|
+
const out = []
|
|
1139
|
+
let index = null
|
|
1140
|
+
let kids = null
|
|
1141
|
+
const flush = () => {
|
|
1142
|
+
if (index === null) return
|
|
1143
|
+
out.push(
|
|
1144
|
+
h(
|
|
1145
|
+
Box,
|
|
1146
|
+
{
|
|
1147
|
+
key: `wave-card-${index}`,
|
|
1148
|
+
flexDirection: 'column',
|
|
1149
|
+
borderStyle: 'single',
|
|
1150
|
+
borderDimColor: true,
|
|
1151
|
+
width,
|
|
1152
|
+
paddingLeft: 1,
|
|
1153
|
+
paddingRight: 1,
|
|
1154
|
+
marginBottom: 1
|
|
1155
|
+
},
|
|
1156
|
+
...kids
|
|
1157
|
+
)
|
|
1158
|
+
)
|
|
1159
|
+
index = null
|
|
1160
|
+
kids = null
|
|
1161
|
+
}
|
|
1162
|
+
for (const row of board) {
|
|
1163
|
+
if (row.key.startsWith('wave:') && row.key.endsWith(':end')) continue
|
|
1164
|
+
if (row.key.startsWith('wave:')) {
|
|
1165
|
+
flush()
|
|
1166
|
+
index = row.key.slice('wave:'.length)
|
|
1167
|
+
kids = [h(Box, { key: boardKey(row.key) }, h(Text, { bold: true }, waveTitleOf(row)))]
|
|
1168
|
+
continue
|
|
1169
|
+
}
|
|
1170
|
+
if (row.key.startsWith('lane:')) {
|
|
1171
|
+
const lane = drawLane($, e, row)
|
|
1172
|
+
if (kids) kids.push(lane)
|
|
1173
|
+
else out.push(lane)
|
|
1174
|
+
continue
|
|
1175
|
+
}
|
|
1176
|
+
flush()
|
|
1177
|
+
out.push(keyed(boardKey(row.key), row.text, row.key === 'header' || row.key === 'tail' ? null : { wrap: 'truncate-end' }))
|
|
1178
|
+
}
|
|
1179
|
+
flush()
|
|
1180
|
+
return out
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1022
1183
|
/**
|
|
1023
1184
|
* The waves section (design D7, D8): the board once a plan summary has
|
|
1024
|
-
* crossed,
|
|
1185
|
+
* crossed, one bordered card per wave at the body width capped at the board's
|
|
1186
|
+
* default, the rows between cards one truncating text each; then a flat
|
|
1025
1187
|
* row for each spawn no planned lane carries. Before a summary, the flat rows,
|
|
1026
1188
|
* beneath a spoken line once a batch was dispatched. Below the board's minimum
|
|
1027
1189
|
* the renderer's spoken line stands in for it, and a renderer that throws is
|
|
@@ -1037,8 +1199,7 @@ function drawWaves($, e) {
|
|
|
1037
1199
|
const dispatched = run.dispatched !== null || rows.some(r => r.batch)
|
|
1038
1200
|
return [...(dispatched ? [keyed('wave-board-unrelayed', UNRELAYED_LINE)] : []), ...rows.map(r => flatRow(keyed, r))]
|
|
1039
1201
|
}
|
|
1040
|
-
const
|
|
1041
|
-
const width = isInt(props.bodyColumns) && props.bodyColumns > 0 ? props.bodyColumns : LIMITS.waveBoardDefaultColumns
|
|
1202
|
+
const width = boardColumns(e)
|
|
1042
1203
|
let board
|
|
1043
1204
|
try {
|
|
1044
1205
|
board = drawPlanBoardRows(run.plan, { columns: width, state: boardOverlay(run.plan), notes: laneNotes() })
|
|
@@ -1050,15 +1211,14 @@ function drawWaves($, e) {
|
|
|
1050
1211
|
// is one row without one, and the lanes are then drawn flat beneath it.
|
|
1051
1212
|
const drawn = board.length > 0 && board[board.length - 1].key === 'tail'
|
|
1052
1213
|
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
|
-
]
|
|
1214
|
+
const body = drawn ? drawBoardCards($, e, board, width) : board.map(row => line(boardKey(row.key), row.text))
|
|
1215
|
+
return [...body, ...rows.filter(r => !planned.has(r.label)).map(r => flatRow(keyed, r))]
|
|
1057
1216
|
}
|
|
1058
1217
|
|
|
1059
1218
|
async function drawPane($, e) {
|
|
1060
1219
|
const { Box, Text } = $.ui.resolve(e)
|
|
1061
1220
|
const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
|
|
1221
|
+
const heading = text => h(Box, { marginTop: 1 }, h(Text, { bold: true }, text))
|
|
1062
1222
|
if (run.phase === 'idle' && !run.summary) {
|
|
1063
1223
|
// The guard runs in every session, so its line stands here too (design D5).
|
|
1064
1224
|
return h(Box, { flexDirection: 'column' }, h(Text, { dimColor: true }, NO_RUN_LINE), keyed('launch-guard', await launchGuardText($)))
|
|
@@ -1070,19 +1230,21 @@ async function drawPane($, e) {
|
|
|
1070
1230
|
out.push(keyed('last-activity', await lastActivityText($)))
|
|
1071
1231
|
out.push(h(Text, { dimColor: true }, RECORD_LINE))
|
|
1072
1232
|
|
|
1073
|
-
out.push(
|
|
1233
|
+
out.push(heading('waves'))
|
|
1074
1234
|
out.push(...drawWaves($, e))
|
|
1075
1235
|
|
|
1076
|
-
out.push(
|
|
1236
|
+
out.push(heading('agents'))
|
|
1077
1237
|
if (!run.agents.size) out.push(h(Text, { dimColor: true }, 'no agent has reported a request yet'))
|
|
1078
1238
|
for (const a of run.agents.values()) {
|
|
1079
1239
|
const lane = laneForAgent(a)
|
|
1080
1240
|
const n = a.requests === 1 ? '1 request' : `${a.requests} requests`
|
|
1081
1241
|
const done = a.reason ? `${a.reason}${a.durationMs !== null ? ` ${(a.durationMs / 1000).toFixed(1)}s` : ''}` : 'running'
|
|
1082
1242
|
out.push(
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1243
|
+
h(
|
|
1244
|
+
Box,
|
|
1245
|
+
{ key: `agent-${a.id}`, flexDirection: 'column' },
|
|
1246
|
+
h(Box, null, h(Text, null, `${lane || 'lane unknown'} · ${a.id} · ${a.models.join(', ') || 'model not reported'} · ${done}`)),
|
|
1247
|
+
h(Box, null, h(Text, { dimColor: true }, `${n} · ${tokensText(a)}`))
|
|
1086
1248
|
)
|
|
1087
1249
|
)
|
|
1088
1250
|
}
|
|
@@ -1098,11 +1260,11 @@ async function drawPane($, e) {
|
|
|
1098
1260
|
failure = messageOf(err)
|
|
1099
1261
|
}
|
|
1100
1262
|
const [contextLine, costLine] = sessionLines(usage, failure)
|
|
1101
|
-
out.push(
|
|
1263
|
+
out.push(heading('session'))
|
|
1102
1264
|
out.push(keyed('session-context', contextLine))
|
|
1103
1265
|
out.push(keyed('session-cost', costLine))
|
|
1104
1266
|
|
|
1105
|
-
out.push(
|
|
1267
|
+
out.push(heading('plan windows'))
|
|
1106
1268
|
const windows = usage && Array.isArray(usage.rateLimits) ? usage.rateLimits : []
|
|
1107
1269
|
if (failure !== null) out.push(h(Text, { dimColor: true }, `plan windows unavailable: ${failure}`))
|
|
1108
1270
|
else if (!windows.length) out.push(h(Text, { dimColor: true }, 'none reported (off a subscription the session has none)'))
|
|
@@ -1112,7 +1274,7 @@ async function drawPane($, e) {
|
|
|
1112
1274
|
out.push(keyed(`plan-window-${kind}`, `${kind} ${w.percentUsed}% used${w.resetsAt ? ` · resets ${w.resetsAt}` : ''}`))
|
|
1113
1275
|
}
|
|
1114
1276
|
|
|
1115
|
-
out.push(
|
|
1277
|
+
out.push(heading('banners'))
|
|
1116
1278
|
out.push(
|
|
1117
1279
|
h(
|
|
1118
1280
|
Box,
|
|
@@ -1121,13 +1283,13 @@ async function drawPane($, e) {
|
|
|
1121
1283
|
)
|
|
1122
1284
|
)
|
|
1123
1285
|
|
|
1124
|
-
out.push(
|
|
1286
|
+
out.push(heading('refusals'))
|
|
1125
1287
|
const denials = guardDenialLine(run.guardDenials)
|
|
1126
1288
|
if (denials) out.push(keyed('guard-denials', denials))
|
|
1127
1289
|
out.push(keyed('launch-guard', await launchGuardText($)))
|
|
1128
1290
|
|
|
1129
1291
|
if (run.summary) {
|
|
1130
|
-
out.push(
|
|
1292
|
+
out.push(heading('close summary'))
|
|
1131
1293
|
out.push(h(Box, { key: 'summary', flexDirection: 'column' }, h(Text, null, run.summary)))
|
|
1132
1294
|
}
|
|
1133
1295
|
return h(Box, { flexDirection: 'column' }, ...out)
|
|
@@ -1350,18 +1512,12 @@ export function register(on) {
|
|
|
1350
1512
|
return next(e)
|
|
1351
1513
|
})
|
|
1352
1514
|
|
|
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
|
|
1515
|
+
// Pass the spawn on unchanged. The three parameter names are plain identifiers
|
|
1516
|
+
// and are not bound anywhere else in this file, so the value given to the
|
|
1517
|
+
// next handler is the event parameter. permissionMode is left as the parent
|
|
1518
|
+
// set it. The meter reads the chain's result and returns that same result.
|
|
1519
|
+
on('agent.spawn', async (meterSpawnApi, meterSpawnInput, meterSpawnForward) => {
|
|
1520
|
+
return meterSpawnForward(meterSpawnInput).then(spawned => noteSpawn(meterSpawnApi, meterSpawnInput, spawned))
|
|
1365
1521
|
})
|
|
1366
1522
|
|
|
1367
1523
|
// The session boundary (design D4). /clear, /resume and /branch start the
|
|
@@ -1374,27 +1530,15 @@ export function register(on) {
|
|
|
1374
1530
|
// source (show-preflight-and-interrupted-runs-at-session-start design D5).
|
|
1375
1531
|
// The kit raises this event; Claude Code 2.1.291 raises no classic event to
|
|
1376
1532
|
// 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
|
|
1533
|
+
//
|
|
1534
|
+
// Pass the event on unchanged. The value this hook returns is the forward
|
|
1535
|
+
// itself: no first message, no permission decision. The meter's reset runs
|
|
1536
|
+
// before that chain and the preflight read after it; neither becomes the return.
|
|
1537
|
+
on('classic.SessionStart', async (meterSessionApi, meterSessionInput, meterSessionForward) => {
|
|
1538
|
+
endRunAtBoundary(meterSessionApi, meterSessionInput)
|
|
1539
|
+
const sessionForwarded = meterSessionForward(meterSessionInput)
|
|
1540
|
+
await sessionForwarded.then(() => readPreflightAfterStart(meterSessionApi, meterSessionInput))
|
|
1541
|
+
return sessionForwarded
|
|
1398
1542
|
})
|
|
1399
1543
|
|
|
1400
1544
|
on('ui.render', { component: 'Spinner' }, ($, e, next) => {
|
|
@@ -1413,27 +1557,27 @@ export function register(on) {
|
|
|
1413
1557
|
|
|
1414
1558
|
on('ui.render', { component: 'Pane', requestId: SPEC_PANE }, async ($, e) => drawSpecPane($, e))
|
|
1415
1559
|
|
|
1416
|
-
on('command.run', { command: PANE }, async
|
|
1417
|
-
await openPane(
|
|
1560
|
+
on('command.run', { command: PANE }, async paneHost => {
|
|
1561
|
+
await openPane(paneHost)
|
|
1418
1562
|
return {}
|
|
1419
1563
|
})
|
|
1420
1564
|
|
|
1421
1565
|
// Typed before any prompt, the command reads the report itself, so it never
|
|
1422
1566
|
// 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(
|
|
1567
|
+
on('command.run', { command: PREFLIGHT_PANE }, async preflightHost => {
|
|
1568
|
+
if (session.interactive) await readPreflight(preflightHost, session.cwd)
|
|
1569
|
+
await openPane(preflightHost, PREFLIGHT_PANE, 'Interlock preflight')
|
|
1426
1570
|
return {}
|
|
1427
1571
|
})
|
|
1428
1572
|
|
|
1429
|
-
on('command.run', { command: HANDOFF_PANE }, async
|
|
1430
|
-
if (session.interactive) await readPreflight(
|
|
1431
|
-
await openPane(
|
|
1573
|
+
on('command.run', { command: HANDOFF_PANE }, async handoffHost => {
|
|
1574
|
+
if (session.interactive) await readPreflight(handoffHost, session.cwd)
|
|
1575
|
+
await openPane(handoffHost, HANDOFF_PANE, 'Interlock handoff')
|
|
1432
1576
|
return {}
|
|
1433
1577
|
})
|
|
1434
1578
|
|
|
1435
|
-
on('command.run', { command: SPEC_PANE }, async
|
|
1436
|
-
await openPane(
|
|
1579
|
+
on('command.run', { command: SPEC_PANE }, async specCmdHost => {
|
|
1580
|
+
await openPane(specCmdHost, SPEC_PANE, SPEC_TITLE)
|
|
1437
1581
|
return {}
|
|
1438
1582
|
})
|
|
1439
1583
|
}
|
package/lib/draw-plan.mjs
CHANGED
|
@@ -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
|
/**
|
|
@@ -309,7 +310,9 @@ function topRule(text, width) {
|
|
|
309
310
|
|
|
310
311
|
/**
|
|
311
312
|
* The plan as rows of `{ key, text }`: `header`, `wave:<i>`, `lane:<label>`,
|
|
312
|
-
* `wave:<i>:end`, `verify:<i>`, `unplaced:<n>`, `tail`.
|
|
313
|
+
* `wave:<i>:end`, `verify:<i>`, `unplaced:<n>`, `tail`. Lane and wave-header
|
|
314
|
+
* rows also carry `parts`, so a drawer can colour a state word without
|
|
315
|
+
* searching the cut line; `text` is still the CLI line.
|
|
313
316
|
*/
|
|
314
317
|
function boardRows(plan, opts) {
|
|
315
318
|
const width = boardWidth(opts.columns)
|
|
@@ -323,7 +326,11 @@ function boardRows(plan, opts) {
|
|
|
323
326
|
const notes = new Map(Object.entries(isObject(opts.notes) ? opts.notes : {}).filter(([, text]) => isText(text)))
|
|
324
327
|
const mode = overlayHeader(overlay, positions, opts.stateNote)
|
|
325
328
|
const rows = []
|
|
326
|
-
const push = (key, text) =>
|
|
329
|
+
const push = (key, text, parts) => {
|
|
330
|
+
const row = { key, text: fitRow(text, width) }
|
|
331
|
+
if (parts) row.parts = parts
|
|
332
|
+
rows.push(row)
|
|
333
|
+
}
|
|
327
334
|
|
|
328
335
|
push(
|
|
329
336
|
'header',
|
|
@@ -340,9 +347,10 @@ function boardRows(plan, opts) {
|
|
|
340
347
|
const i = position.index - 1
|
|
341
348
|
push(`verify:${i}`, [` verify after idx ${i}`, ...texts[i]].join(' · '))
|
|
342
349
|
}
|
|
343
|
-
|
|
350
|
+
const title = waveTitle(position)
|
|
351
|
+
push(`wave:${position.index}`, topRule(title, width), { title })
|
|
344
352
|
for (const l of lanes.filter(l => l.position === position.index)) {
|
|
345
|
-
const { word, ids } = laneCells(overlay, l.lane, l.position, l.batch)
|
|
353
|
+
const { word, ids, tasks } = laneCells(overlay, l.lane, l.position, l.batch)
|
|
346
354
|
const cells = [
|
|
347
355
|
'│',
|
|
348
356
|
`b${l.batch}`.padEnd(batchCell),
|
|
@@ -359,7 +367,19 @@ function boardRows(plan, opts) {
|
|
|
359
367
|
if (ids) cells.push(`[${ids}]`)
|
|
360
368
|
if (l.after.length) cells.push(`←${l.after.join(',')}`)
|
|
361
369
|
const head = cells.join(' ')
|
|
362
|
-
push(`lane:${l.label}`, l.gist ? `${head}${TITLE_SEPARATOR}${l.gist}` : head.trimEnd()
|
|
370
|
+
push(`lane:${l.label}`, l.gist ? `${head}${TITLE_SEPARATOR}${l.gist}` : head.trimEnd(), {
|
|
371
|
+
batch: `b${l.batch}`,
|
|
372
|
+
label: l.label,
|
|
373
|
+
model: l.model,
|
|
374
|
+
tier: `T${l.tier}`,
|
|
375
|
+
effort: l.effort,
|
|
376
|
+
state: word,
|
|
377
|
+
gist: l.gist,
|
|
378
|
+
note: note || '',
|
|
379
|
+
ids: ids || '',
|
|
380
|
+
after: l.after,
|
|
381
|
+
tasks
|
|
382
|
+
})
|
|
363
383
|
}
|
|
364
384
|
push(`wave:${position.index}:end`, `└${'─'.repeat(width - 1)}`)
|
|
365
385
|
}
|
|
@@ -385,7 +405,7 @@ function boardRows(plan, opts) {
|
|
|
385
405
|
*
|
|
386
406
|
* @param {unknown} plan what `planWaves` emits, or a plan summary; any value draws
|
|
387
407
|
* @param {{columns?: number, state?: object|null, stateNote?: string, notes?: Record<string, string>}} [opts]
|
|
388
|
-
* @returns {{key: string, text: string}[]}
|
|
408
|
+
* @returns {{key: string, text: string, parts?: object}[]}
|
|
389
409
|
*/
|
|
390
410
|
export function drawPlanBoardRows(plan, opts = {}) {
|
|
391
411
|
return boardRows(plan, isObject(opts) ? opts : {})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@renzrollon/interlock",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.1",
|
|
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
|