@renzrollon/interlock 1.2.1 → 1.3.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 +55 -5
- package/bin/interlock +142 -14
- package/hooks/mod.mjs +835 -51
- package/hooks/preflight.mjs +168 -34
- package/lib/draw-plan.mjs +496 -0
- package/lib/draw-run.mjs +466 -0
- package/lib/lane.mjs +152 -0
- package/lib/limits.mjs +37 -2
- package/lib/meter-refusals.mjs +73 -0
- package/lib/plan-summary.mjs +119 -0
- package/lib/preflight-file.mjs +266 -0
- package/lib/resume-card.mjs +126 -8
- package/lib/run.mjs +43 -9
- package/lib/spec-meter.mjs +591 -0
- package/lib/waves.mjs +6 -107
- package/package.json +1 -1
- package/skills/review-artifacts/SKILL.md +3 -3
- package/skills/spec/SKILL.md +5 -5
package/lib/draw-run.mjs
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
// The handoff graph (draw-wave-plan-and-handoff-graph-from-the-cli, spec
|
|
2
|
+
// ship/diagrams): one ship run drawn from its trajectory, in sequence order, as
|
|
3
|
+
// lines and as a Mermaid `flowchart TD`.
|
|
4
|
+
//
|
|
5
|
+
// The trajectory sets the order, because it is the record the run guarantees
|
|
6
|
+
// reconstructable; the run's own wave state and manifest add what the
|
|
7
|
+
// trajectory never held — lane membership, handoff packets, briefing hashes —
|
|
8
|
+
// and only when they name the same run. A source that is absent, unreadable or
|
|
9
|
+
// another run's is named in the header and read for nothing.
|
|
10
|
+
//
|
|
11
|
+
// Two caveats are stated wherever they apply, never drawn around: implementer
|
|
12
|
+
// spawn times are the time the step was emitted (`wave-state next` logs every
|
|
13
|
+
// lane of a wave at once), not when the host started the agent; and a spawn
|
|
14
|
+
// with no recorded result reads `result: not recorded`, never as finished.
|
|
15
|
+
//
|
|
16
|
+
// Pure and Node-free, like `lib/draw-plan.mjs`: the CLI reads the files and
|
|
17
|
+
// hands over `{ value, read }`, because only it can tell an absent file from
|
|
18
|
+
// an unreadable one. The widths are `interlock limits`'s.
|
|
19
|
+
|
|
20
|
+
import { fitRow, laneLabel } from './lane.mjs'
|
|
21
|
+
import { LIMITS } from './limits.mjs'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The trajectory types this module draws — `RUN_LOG_TYPES` of `lib/run-log.mjs`,
|
|
25
|
+
* restated because that module reads files and cannot be imported Node-free. A
|
|
26
|
+
* type outside it is counted in the header and drawn as nothing.
|
|
27
|
+
*/
|
|
28
|
+
const DRAWN_TYPES = new Set([
|
|
29
|
+
'run-start',
|
|
30
|
+
'wave-action',
|
|
31
|
+
'cli-exit',
|
|
32
|
+
'agent-spawn',
|
|
33
|
+
'agent-result',
|
|
34
|
+
'verify-judgement',
|
|
35
|
+
'run-halt',
|
|
36
|
+
'run-complete',
|
|
37
|
+
'run-receipt'
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
/** The steps that run implementers, whose spawns are grouped beneath them. */
|
|
41
|
+
const IMPLEMENTER_STEPS = new Set(['run-batch', 'test-wave'])
|
|
42
|
+
|
|
43
|
+
const SPAWN_TIME_CAVEAT = 'spawn times are step-emission times, not host start times'
|
|
44
|
+
|
|
45
|
+
const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
46
|
+
const list = value => (Array.isArray(value) ? value : [])
|
|
47
|
+
const isText = value => typeof value === 'string' && value !== ''
|
|
48
|
+
const span = (first, last) => (first === last ? `[${first}]` : `[${first}-${last}]`)
|
|
49
|
+
|
|
50
|
+
/** A width the board draws at: a positive integer, else the published default. */
|
|
51
|
+
function boardWidth(columns) {
|
|
52
|
+
return Number.isInteger(columns) && columns > 0 ? columns : LIMITS.waveBoardDefaultColumns
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One joined source: `this run` only when it was read and names `runId`.
|
|
57
|
+
*
|
|
58
|
+
* @returns {{word: string, value: object|null}}
|
|
59
|
+
*/
|
|
60
|
+
function joinSource(source, runId) {
|
|
61
|
+
const read = isObject(source) ? source.read : 'absent'
|
|
62
|
+
if (read === 'unreadable') return { word: 'unreadable', value: null }
|
|
63
|
+
if (read !== 'ok' || !isObject(source.value)) return { word: 'absent', value: null }
|
|
64
|
+
const id = source.value.runId
|
|
65
|
+
if (isText(id) && id === runId) return { word: 'this run', value: source.value }
|
|
66
|
+
return { word: `another run ${isText(id) ? id : '(no run id)'}`, value: null }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Every task of one state wave, in batch and lane order. */
|
|
70
|
+
function waveTasks(wave) {
|
|
71
|
+
return list(isObject(wave) ? wave.batches : null)
|
|
72
|
+
.flatMap(batch => list(batch))
|
|
73
|
+
.flatMap(lane => list(lane))
|
|
74
|
+
.filter(task => isObject(task) && isText(task.id))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `exit <code> · <ms>ms`, or what was not recorded. */
|
|
78
|
+
function exitText(exit) {
|
|
79
|
+
if (!exit) return 'exit not recorded'
|
|
80
|
+
const code = Number.isInteger(exit.exitCode) ? `exit ${exit.exitCode}` : 'exit not recorded'
|
|
81
|
+
const ms = Number.isInteger(exit.durationMs) ? `${exit.durationMs}ms` : 'duration not recorded'
|
|
82
|
+
return `${code} · ${ms}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The run as a sequence of items — nodes and implementer groups — with the
|
|
87
|
+
* header, the caveat line and the lines nothing could place.
|
|
88
|
+
*/
|
|
89
|
+
function compose(run) {
|
|
90
|
+
const input = isObject(run) ? run : {}
|
|
91
|
+
const all = list(input.records).filter(isObject)
|
|
92
|
+
const unsequenced = all.filter(r => !Number.isInteger(r.seq)).length
|
|
93
|
+
const sequenced = all.filter(r => Number.isInteger(r.seq))
|
|
94
|
+
const unknown = sequenced.filter(r => !DRAWN_TYPES.has(r.type)).length
|
|
95
|
+
const torn = list(input.skipped).length
|
|
96
|
+
// Sequence order, file order on a tie.
|
|
97
|
+
const records = sequenced
|
|
98
|
+
.map((record, at) => ({ record, at }))
|
|
99
|
+
.filter(({ record }) => DRAWN_TYPES.has(record.type))
|
|
100
|
+
.sort((a, b) => a.record.seq - b.record.seq || a.at - b.at)
|
|
101
|
+
.map(({ record }) => record)
|
|
102
|
+
|
|
103
|
+
const runId = isText(input.runId) ? input.runId : (records.find(r => isText(r.runId)) || {}).runId || '(no run id)'
|
|
104
|
+
const change =
|
|
105
|
+
(records.find(r => r.type === 'run-start' && isText(r.change)) || records.find(r => isText(r.change)) || {})
|
|
106
|
+
.change || '(no change recorded)'
|
|
107
|
+
const state = joinSource(input.state, runId)
|
|
108
|
+
const manifest = joinSource(input.manifest, runId)
|
|
109
|
+
const stateWaves = state.value ? list(state.value.waves) : []
|
|
110
|
+
|
|
111
|
+
const items = []
|
|
112
|
+
const consumed = new Set()
|
|
113
|
+
const spawns = []
|
|
114
|
+
const steps = []
|
|
115
|
+
const receipts = []
|
|
116
|
+
let group = null
|
|
117
|
+
|
|
118
|
+
const nextOf = (from, stop, type) => {
|
|
119
|
+
for (let j = from + 1; j < records.length; j++) {
|
|
120
|
+
if (consumed.has(j)) continue
|
|
121
|
+
if (records[j].type === type) return j
|
|
122
|
+
if (stop.includes(records[j].type)) return -1
|
|
123
|
+
}
|
|
124
|
+
return -1
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
records.forEach((r, at) => {
|
|
128
|
+
if (consumed.has(at)) return
|
|
129
|
+
if (r.type !== 'agent-spawn' && r.type !== 'agent-result') group = null
|
|
130
|
+
switch (r.type) {
|
|
131
|
+
case 'wave-action': {
|
|
132
|
+
const node = {
|
|
133
|
+
kind: 'step',
|
|
134
|
+
first: r.seq,
|
|
135
|
+
last: r.seq,
|
|
136
|
+
action: isText(r.action) ? r.action : 'unknown action',
|
|
137
|
+
source: isText(r.source) ? r.source : 'source not recorded',
|
|
138
|
+
waveIndex: Number.isInteger(r.waveIndex) ? r.waveIndex : null,
|
|
139
|
+
exit: null,
|
|
140
|
+
haltReason: null,
|
|
141
|
+
packets: [],
|
|
142
|
+
notes: []
|
|
143
|
+
}
|
|
144
|
+
const exit = nextOf(at, ['wave-action'], 'cli-exit')
|
|
145
|
+
if (exit !== -1) {
|
|
146
|
+
consumed.add(exit)
|
|
147
|
+
node.exit = records[exit]
|
|
148
|
+
node.last = Math.max(node.last, records[exit].seq)
|
|
149
|
+
}
|
|
150
|
+
if (node.action === 'halt') {
|
|
151
|
+
const halt = nextOf(at, ['wave-action'], 'run-halt')
|
|
152
|
+
if (halt !== -1) {
|
|
153
|
+
consumed.add(halt)
|
|
154
|
+
node.haltReason = isText(records[halt].reason) ? records[halt].reason : null
|
|
155
|
+
node.last = Math.max(node.last, records[halt].seq)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
items.push(node)
|
|
159
|
+
steps.push(node)
|
|
160
|
+
if (IMPLEMENTER_STEPS.has(node.action)) group = { step: node, item: null }
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
case 'agent-spawn': {
|
|
164
|
+
const spawn = {
|
|
165
|
+
seq: r.seq,
|
|
166
|
+
label: isText(r.label) ? r.label : '(no label)',
|
|
167
|
+
model: isText(r.model) ? r.model : null,
|
|
168
|
+
kind: isText(r.kind) ? r.kind : 'other',
|
|
169
|
+
result: null
|
|
170
|
+
}
|
|
171
|
+
spawns.push(spawn)
|
|
172
|
+
if (spawn.kind === 'implementer' && group) {
|
|
173
|
+
if (!group.item) {
|
|
174
|
+
group.item = { kind: 'implementers', first: r.seq, last: r.seq, waveIndex: group.step.waveIndex, spawns: [] }
|
|
175
|
+
items.push(group.item)
|
|
176
|
+
}
|
|
177
|
+
group.item.spawns.push(spawn)
|
|
178
|
+
group.item.last = r.seq
|
|
179
|
+
} else {
|
|
180
|
+
group = null
|
|
181
|
+
items.push({ kind: 'spawn', first: r.seq, last: r.seq, spawn })
|
|
182
|
+
}
|
|
183
|
+
break
|
|
184
|
+
}
|
|
185
|
+
case 'agent-result': {
|
|
186
|
+
const label = isText(r.label) ? r.label : null
|
|
187
|
+
const spawn = spawns.find(s => s.label === label && s.result === null)
|
|
188
|
+
const result = `${isText(r.subtype) ? r.subtype : 'recorded'}${r.isError === true ? ' · error' : ''}`
|
|
189
|
+
if (spawn) spawn.result = result
|
|
190
|
+
else items.push({ kind: 'text', first: r.seq, last: r.seq, text: `result ${label ?? '(no label)'} · ${result} · no spawn to join` })
|
|
191
|
+
break
|
|
192
|
+
}
|
|
193
|
+
case 'cli-exit': {
|
|
194
|
+
const previous = items.at(-1)
|
|
195
|
+
if (previous && previous.kind === 'judgement' && !previous.exit) {
|
|
196
|
+
previous.exit = r
|
|
197
|
+
previous.last = r.seq
|
|
198
|
+
} else {
|
|
199
|
+
items.push({ kind: 'text', first: r.seq, last: r.seq, text: `cli ${isText(r.command) ? r.command : '(no command)'} · ${exitText(r)}` })
|
|
200
|
+
}
|
|
201
|
+
break
|
|
202
|
+
}
|
|
203
|
+
case 'verify-judgement':
|
|
204
|
+
items.push({
|
|
205
|
+
kind: 'judgement',
|
|
206
|
+
first: r.seq,
|
|
207
|
+
last: r.seq,
|
|
208
|
+
exit: null,
|
|
209
|
+
text:
|
|
210
|
+
`verify-judgement · ${isText(r.context) ? r.context : 'context not recorded'} · ` +
|
|
211
|
+
`${r.halt === true ? 'halt' : 'no halt'}${isText(r.reason) ? ` · ${r.reason}` : ''}`
|
|
212
|
+
})
|
|
213
|
+
break
|
|
214
|
+
case 'run-start':
|
|
215
|
+
items.push({
|
|
216
|
+
kind: 'text',
|
|
217
|
+
first: r.seq,
|
|
218
|
+
last: r.seq,
|
|
219
|
+
text: `run-start · ${isText(r.mode) ? r.mode : 'mode not recorded'}${r.strict === true ? ' · strict' : ''}`
|
|
220
|
+
})
|
|
221
|
+
break
|
|
222
|
+
case 'run-halt':
|
|
223
|
+
items.push({ kind: 'halt', first: r.seq, last: r.seq, text: `halt · ${isText(r.reason) ? r.reason : 'no reason recorded'}` })
|
|
224
|
+
break
|
|
225
|
+
case 'run-complete':
|
|
226
|
+
items.push({ kind: 'text', first: r.seq, last: r.seq, text: 'run-complete' })
|
|
227
|
+
break
|
|
228
|
+
case 'run-receipt': {
|
|
229
|
+
const outcome = r.halted === true ? 'halted' : r.halted === false ? 'completed' : 'outcome not recorded'
|
|
230
|
+
receipts.push({
|
|
231
|
+
kind: 'receipt',
|
|
232
|
+
first: r.seq,
|
|
233
|
+
last: r.seq,
|
|
234
|
+
text: `receipt · ${outcome} · ${r.committed === true ? 'committed' : 'not committed'}`
|
|
235
|
+
})
|
|
236
|
+
break
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// A halt with no run-halt line takes the state's recorded reason.
|
|
242
|
+
const stateHalt = state.value && isObject(state.value.halt) && isText(state.value.halt.reason) ? state.value.halt.reason : null
|
|
243
|
+
for (const step of steps) if (step.action === 'halt' && !step.haltReason) step.haltReason = stateHalt
|
|
244
|
+
|
|
245
|
+
// Packets: the previous position's tasks that hold one, under the step that
|
|
246
|
+
// first enters each later position — the join `previousWaveHandoffs` makes.
|
|
247
|
+
const entered = new Set()
|
|
248
|
+
const firstEntry = new Map()
|
|
249
|
+
for (const step of steps) {
|
|
250
|
+
if (step.waveIndex === null || entered.has(step.waveIndex)) continue
|
|
251
|
+
entered.add(step.waveIndex)
|
|
252
|
+
firstEntry.set(step.waveIndex, step)
|
|
253
|
+
if (step.waveIndex === 0 || !state.value) continue
|
|
254
|
+
const handoffs = isObject(state.value.handoffs) ? state.value.handoffs : {}
|
|
255
|
+
const audits = isObject(state.value.evidenceAudits) ? state.value.evidenceAudits : {}
|
|
256
|
+
for (const task of waveTasks(stateWaves[step.waveIndex - 1])) {
|
|
257
|
+
const packet = handoffs[task.id]
|
|
258
|
+
if (!isObject(packet)) continue
|
|
259
|
+
const audit = audits[task.id]
|
|
260
|
+
step.packets.push({
|
|
261
|
+
id: task.id,
|
|
262
|
+
status: isText(packet.status) ? packet.status : 'no status recorded',
|
|
263
|
+
verdict: isObject(audit) && isText(audit.verdict) ? audit.verdict : 'no audit recorded'
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Verify skips are recorded by group, so one lands on a step only when exactly
|
|
269
|
+
// one reached boundary carries its group.
|
|
270
|
+
const unplaced = []
|
|
271
|
+
if (state.value) {
|
|
272
|
+
const reached = new Set()
|
|
273
|
+
for (const step of steps) {
|
|
274
|
+
if (step.waveIndex === null) continue
|
|
275
|
+
if (step.action === 'verify') reached.add(step.waveIndex)
|
|
276
|
+
for (let i = 0; i < step.waveIndex; i++) reached.add(i)
|
|
277
|
+
}
|
|
278
|
+
const boundaryNode = i => steps.find(s => s.action === 'verify' && s.waveIndex === i) || firstEntry.get(i + 1)
|
|
279
|
+
const byGroup = new Map()
|
|
280
|
+
for (const skip of list(state.value.skippedVerifications).filter(isObject)) {
|
|
281
|
+
const g = skip.wave ?? null
|
|
282
|
+
if (!byGroup.has(g)) byGroup.set(g, [])
|
|
283
|
+
byGroup.get(g).push(isText(skip.reason) ? skip.reason : 'no reason recorded')
|
|
284
|
+
}
|
|
285
|
+
for (const [g, reasons] of byGroup) {
|
|
286
|
+
const boundaries = [...reached]
|
|
287
|
+
.filter(i => i < stateWaves.length - 1 && isObject(stateWaves[i]) && (stateWaves[i].group ?? null) === g)
|
|
288
|
+
.sort((a, b) => a - b)
|
|
289
|
+
const counted = `${reasons.length} skip${reasons.length === 1 ? '' : 's'} for group ${g}`
|
|
290
|
+
if (boundaries.length === 1 && boundaryNode(boundaries[0])) {
|
|
291
|
+
boundaryNode(boundaries[0]).notes.push(...reasons.map(reason => `skip (group ${g}): ${reason}`))
|
|
292
|
+
} else if (boundaries.length > 1 && boundaryNode(boundaries[0])) {
|
|
293
|
+
const named = boundaries.map(i => `idx ${i}`)
|
|
294
|
+
boundaryNode(boundaries[0]).notes.push(
|
|
295
|
+
`${counted} · state does not say ${named.slice(0, -1).join(', ')} or ${named.at(-1)}`,
|
|
296
|
+
...reasons
|
|
297
|
+
)
|
|
298
|
+
} else {
|
|
299
|
+
unplaced.push(`unplaced: ${counted} · no reached boundary carries that group · ${reasons.join(' · ')}`)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Lane ids and briefing hashes, from the run's own state and manifest.
|
|
305
|
+
const dispatched = manifest.value ? list(manifest.value.dispatched).filter(isObject) : null
|
|
306
|
+
const used = new Set()
|
|
307
|
+
for (const item of items) {
|
|
308
|
+
if (item.kind !== 'implementers') continue
|
|
309
|
+
const lanes = list(isObject(stateWaves[item.waveIndex]) ? stateWaves[item.waveIndex].batches : null)
|
|
310
|
+
.flatMap(batch => list(batch))
|
|
311
|
+
.map(lane => list(lane).filter(isObject))
|
|
312
|
+
.filter(lane => lane.length > 0)
|
|
313
|
+
for (const spawn of item.spawns) {
|
|
314
|
+
const lane = state.value ? lanes.find(l => laneLabel(l) === spawn.label) : null
|
|
315
|
+
spawn.ids = lane ? lane.map(task => task.id) : null
|
|
316
|
+
if (dispatched) {
|
|
317
|
+
const at = dispatched.findIndex((d, i) => !used.has(i) && d.label === spawn.label)
|
|
318
|
+
if (at !== -1) used.add(at)
|
|
319
|
+
spawn.sha = at !== -1 && isText(dispatched[at].sha) ? dispatched[at].sha.slice(0, 12) : null
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const notDrawn = [
|
|
325
|
+
...(unsequenced ? [`${unsequenced} unsequenced`] : []),
|
|
326
|
+
...(unknown ? [`${unknown} unknown type`] : []),
|
|
327
|
+
...(torn ? [`${torn} unreadable line${torn === 1 ? '' : 's'}`] : [])
|
|
328
|
+
]
|
|
329
|
+
// The change name is prose and goes last, so a narrow board cuts it before it
|
|
330
|
+
// cuts a count or a source word — the order a lane row is cut in.
|
|
331
|
+
const header =
|
|
332
|
+
`run ${runId} · ${records.length} record${records.length === 1 ? '' : 's'}` +
|
|
333
|
+
(notDrawn.length ? ` · not drawn: ${notDrawn.join(', ')}` : '') +
|
|
334
|
+
` · state.json: ${state.word} · run.json: ${manifest.word} · ${change}`
|
|
335
|
+
|
|
336
|
+
const unanswered = spawns.filter(s => s.result === null).length
|
|
337
|
+
const caveats = [
|
|
338
|
+
...(items.some(item => item.kind === 'implementers') ? [SPAWN_TIME_CAVEAT] : []),
|
|
339
|
+
...(unanswered ? [`${unanswered} of ${spawns.length} spawns: result not recorded`] : [])
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
header,
|
|
344
|
+
caveats: caveats.join(' · '),
|
|
345
|
+
items: [...items, ...receipts],
|
|
346
|
+
unplaced,
|
|
347
|
+
path: isText(input.path) ? input.path : runId,
|
|
348
|
+
sources: `state.json: ${state.word} · run.json: ${manifest.word}`,
|
|
349
|
+
manifestJoined: dispatched !== null
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** A node's text, without its sequence span. */
|
|
354
|
+
function nodeText(item) {
|
|
355
|
+
switch (item.kind) {
|
|
356
|
+
case 'step': {
|
|
357
|
+
const at = item.waveIndex === null ? '' : ` idx ${item.waveIndex}`
|
|
358
|
+
const reason = item.action === 'halt' && item.haltReason ? ` · ${item.haltReason}` : ''
|
|
359
|
+
return [`${item.source} → ${item.action}${at} · ${exitText(item.exit)}${reason}`, ...item.notes].join(' · ')
|
|
360
|
+
}
|
|
361
|
+
case 'spawn': {
|
|
362
|
+
const { spawn } = item
|
|
363
|
+
return `spawn ${spawn.label} · ${spawn.kind}${spawn.model ? ` ${spawn.model}` : ''} · result: ${spawn.result ?? 'not recorded'}`
|
|
364
|
+
}
|
|
365
|
+
case 'judgement':
|
|
366
|
+
return item.exit ? `${item.text} · ${exitText(item.exit)}` : item.text
|
|
367
|
+
case 'implementers':
|
|
368
|
+
return `idx ${item.waveIndex ?? '?'} · implementers ${span(item.first, item.last)}`
|
|
369
|
+
default:
|
|
370
|
+
return item.text
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** One implementer's row, without its sequence span. */
|
|
375
|
+
function spawnRow(spawn, manifestJoined) {
|
|
376
|
+
const cells = [`${spawn.label}${spawn.model ? ` ${spawn.model}` : ''}`, `result: ${spawn.result ?? 'not recorded'}`]
|
|
377
|
+
if (spawn.ids) cells.push(`[${spawn.ids.join(', ')}]`)
|
|
378
|
+
if (manifestJoined) cells.push(spawn.sha ? `sha ${spawn.sha}` : 'sha not recorded')
|
|
379
|
+
return cells.join(' · ')
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The handoff graph as lines: a header naming the run and its sources, the
|
|
384
|
+
* caveat line, then one node per ping, step and spawn in sequence order, the
|
|
385
|
+
* receipt last. Every line at most `opts.columns` code points; below the
|
|
386
|
+
* published minimum width, one spoken line.
|
|
387
|
+
*
|
|
388
|
+
* @param {{runId?: string, path?: string, records?: object[], skipped?: object[],
|
|
389
|
+
* state?: {value: object|null, read: string}, manifest?: {value: object|null, read: string}}} run
|
|
390
|
+
* @param {{columns?: number}} [opts]
|
|
391
|
+
* @returns {string[]}
|
|
392
|
+
*/
|
|
393
|
+
export function drawRunBoard(run, opts = {}) {
|
|
394
|
+
const width = boardWidth(isObject(opts) ? opts.columns : undefined)
|
|
395
|
+
if (width < LIMITS.waveBoardMinColumns) {
|
|
396
|
+
return [`handoff board needs at least ${LIMITS.waveBoardMinColumns} columns; ${width} given`]
|
|
397
|
+
}
|
|
398
|
+
const drawn = compose(run)
|
|
399
|
+
const lines = [drawn.header]
|
|
400
|
+
if (drawn.caveats) lines.push(drawn.caveats)
|
|
401
|
+
for (const item of drawn.items) {
|
|
402
|
+
if (item.kind === 'receipt') for (const text of drawn.unplaced) lines.push(` ${text}`)
|
|
403
|
+
if (item.kind === 'implementers') {
|
|
404
|
+
lines.push(` ${nodeText(item)}`)
|
|
405
|
+
for (const spawn of item.spawns) lines.push(` [${spawn.seq}] ${spawnRow(spawn, drawn.manifestJoined)}`)
|
|
406
|
+
continue
|
|
407
|
+
}
|
|
408
|
+
lines.push(`${span(item.first, item.last)} ${nodeText(item)}`)
|
|
409
|
+
// One line per packet, so the cut never removes one.
|
|
410
|
+
if (item.kind === 'step') for (const p of item.packets) lines.push(` ↳ ${p.id} ${p.status} · ${p.verdict}`)
|
|
411
|
+
}
|
|
412
|
+
if (!drawn.items.some(item => item.kind === 'receipt')) for (const text of drawn.unplaced) lines.push(` ${text}`)
|
|
413
|
+
return lines.map(line => fitRow(line, width))
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** A Mermaid label, quoted: `"` is the one character a quoted label cannot hold. */
|
|
417
|
+
const label = text => `"${String(text).replace(/"/g, '#quot;')}"`
|
|
418
|
+
|
|
419
|
+
/** A node in its shape: verify steps hexagons, the halt a stadium, the receipt a subroutine. */
|
|
420
|
+
function shaped(id, item, text) {
|
|
421
|
+
if (item.kind === 'step' && item.action === 'verify') return `${id}{{${label(text)}}}`
|
|
422
|
+
if ((item.kind === 'step' && item.action === 'halt') || item.kind === 'halt') return `${id}([${label(text)}])`
|
|
423
|
+
if (item.kind === 'receipt') return `${id}[[${label(text)}]]`
|
|
424
|
+
return `${id}[${label(text)}]`
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The handoff graph as a Mermaid `flowchart TD`: nodes `n<seq>`, implementer
|
|
429
|
+
* groups `I<seq>`, the edge into each step labelled with its source, and the
|
|
430
|
+
* edge into the first step of each later position carrying the packets. Never
|
|
431
|
+
* cut, and never styled.
|
|
432
|
+
*
|
|
433
|
+
* @param {object} run as for `drawRunBoard`
|
|
434
|
+
* @returns {string[]}
|
|
435
|
+
*/
|
|
436
|
+
export function drawRunMermaid(run) {
|
|
437
|
+
const drawn = compose(run)
|
|
438
|
+
const out = ['flowchart TD', ` %% source: ${drawn.path} · ${drawn.sources}`]
|
|
439
|
+
if (drawn.caveats) out.push(` %% ${drawn.caveats}`)
|
|
440
|
+
out.push(` %% ${drawn.header}`)
|
|
441
|
+
const chain = []
|
|
442
|
+
for (const item of drawn.items) {
|
|
443
|
+
if (item.kind === 'implementers') {
|
|
444
|
+
const id = `I${item.first}`
|
|
445
|
+
out.push(` subgraph ${id}[${label(nodeText(item))}]`)
|
|
446
|
+
for (const spawn of item.spawns) out.push(` n${spawn.seq}[${label(`[${spawn.seq}] ${spawnRow(spawn, drawn.manifestJoined)}`)}]`)
|
|
447
|
+
out.push(' end')
|
|
448
|
+
chain.push({ id, item })
|
|
449
|
+
continue
|
|
450
|
+
}
|
|
451
|
+
const id = `n${item.first}`
|
|
452
|
+
out.push(` ${shaped(id, item, `${span(item.first, item.last)} ${nodeText(item)}`)}`)
|
|
453
|
+
chain.push({ id, item })
|
|
454
|
+
}
|
|
455
|
+
for (const text of drawn.unplaced) out.push(` %% ${text}`)
|
|
456
|
+
for (let i = 1; i < chain.length; i++) {
|
|
457
|
+
const { id, item } = chain[i]
|
|
458
|
+
if (item.kind === 'step') {
|
|
459
|
+
const packets = item.packets.map(p => `${p.id} ${p.status}/${p.verdict}`)
|
|
460
|
+
out.push(` ${chain[i - 1].id} -->|${label([item.source, ...packets].join(' · '))}| ${id}`)
|
|
461
|
+
} else {
|
|
462
|
+
out.push(` ${chain[i - 1].id} --> ${id}`)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return out
|
|
466
|
+
}
|
package/lib/lane.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// The lane rule (draw-wave-plan-and-handoff-graph-from-the-cli design D3): the
|
|
2
|
+
// tier, label, title, model and effort a lane dispatches under, the cut that
|
|
3
|
+
// fits a drawn row to a width, and the one reader of a task's `dependsOn`.
|
|
4
|
+
//
|
|
5
|
+
// Moved out of `lib/waves.mjs` unchanged, because that module imports
|
|
6
|
+
// `node:crypto` and the ship meter's hooks module, which runs in the engine with
|
|
7
|
+
// no Node, needs the same names the planner gives a lane. `lib/waves.mjs`
|
|
8
|
+
// re-exports the lane functions it always exported, so its importers are
|
|
9
|
+
// unchanged.
|
|
10
|
+
//
|
|
11
|
+
// Pure and Node-free: `test/spine/draw-plan.test.mjs` walks this closure.
|
|
12
|
+
|
|
13
|
+
import { EFFORT, LANE_CAPS } from './limits.mjs'
|
|
14
|
+
|
|
15
|
+
/** The tier a lane dispatches on: the highest among its tasks. */
|
|
16
|
+
export function laneTier(lane) {
|
|
17
|
+
return lane.reduce((m, t) => (Number.isInteger(t.tier) && t.tier > m ? t.tier : m), 0)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The label a lane runs under — the name its agent, its trajectory line and its
|
|
22
|
+
* briefing file all carry.
|
|
23
|
+
*
|
|
24
|
+
* Stable across replays, so a resumed run cache-hits the lane it already ran.
|
|
25
|
+
* This derivation was written three times (the workflow script, the ACP driver,
|
|
26
|
+
* and `bin/interlock`'s own trajectory logger); it is stated here once because a
|
|
27
|
+
* trajectory line and the agent it describes must carry one name, not two.
|
|
28
|
+
*/
|
|
29
|
+
export function laneLabel(lane) {
|
|
30
|
+
return lane.length === 1 ? lane[0].id : `${lane[0].id}+${lane.length - 1}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Separates a spawn's label from the words its title adds. */
|
|
34
|
+
export const TITLE_SEPARATOR = ' · '
|
|
35
|
+
|
|
36
|
+
/** The longest title a lane displays under, ellipsis included. */
|
|
37
|
+
const TITLE_MAX = 48
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The words a lane's title is built from: the first six words of a task
|
|
41
|
+
* description, stripped of markdown emphasis and trailing punctuation; '' when
|
|
42
|
+
* there are none. A fixed point — the gist of a gist is itself — so a plan
|
|
43
|
+
* summary that stores it in place of the description titles every lane exactly
|
|
44
|
+
* as the plan does (draw-the-wave-board-in-the-meter-pane design D3).
|
|
45
|
+
*
|
|
46
|
+
* @param {unknown} description
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function titleGist(description) {
|
|
50
|
+
const text = typeof description === 'string' ? description : ''
|
|
51
|
+
const words = text.replace(/[`*_]/g, '').replace(/\s+/g, ' ').trim().split(' ').slice(0, 6).join(' ')
|
|
52
|
+
return words.replace(/[\s.,;:—-]+$/, '')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The name a lane is SHOWN under — its label, then the first words of its first
|
|
57
|
+
* task's description: `1.1+5 · Add the relaunch guard to…`.
|
|
58
|
+
*
|
|
59
|
+
* Display only. Everything that joins on a lane — its briefing file, worktree,
|
|
60
|
+
* trajectory line, record-batch fold — keys on `laneLabel`, because a key built
|
|
61
|
+
* from prose would change whenever a task was reworded. It is still a pure
|
|
62
|
+
* function of the lane, so a replay displays, and cache-hits, the same agent.
|
|
63
|
+
*/
|
|
64
|
+
export function laneTitle(lane) {
|
|
65
|
+
const label = laneLabel(lane)
|
|
66
|
+
const gist = titleGist(lane[0] && lane[0].description)
|
|
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()}…`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* One task's declared dependency edges, deduplicated, in the order written.
|
|
74
|
+
*
|
|
75
|
+
* THE ONLY READER of `task.dependsOn`, for the same reason `predictedPaths` is
|
|
76
|
+
* the only reader of `task.paths`: validation, layering and the replan path all
|
|
77
|
+
* ask the same question, and three readers each doing their own trimming is how
|
|
78
|
+
* one of them ends up matching an id the others do not. Here rather than in
|
|
79
|
+
* `lib/waves.mjs`, which imports it, so the Node-free plan summary can ask it
|
|
80
|
+
* too (draw-the-wave-board-in-the-meter-pane design D3).
|
|
81
|
+
*
|
|
82
|
+
* Entries are trimmed. A whitespace-only entry is dropped here and rejected by
|
|
83
|
+
* `validate`, which runs first on every path into the planner.
|
|
84
|
+
*/
|
|
85
|
+
export function dependsOnIds(task) {
|
|
86
|
+
const raw = task && Array.isArray(task.dependsOn) ? task.dependsOn : []
|
|
87
|
+
const out = []
|
|
88
|
+
for (const entry of raw) {
|
|
89
|
+
if (typeof entry !== 'string' || !entry.trim()) continue
|
|
90
|
+
const id = entry.trim()
|
|
91
|
+
if (!out.includes(id)) out.push(id)
|
|
92
|
+
}
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The model a lane dispatches on, derived from the lane's shape and hardest tier.
|
|
98
|
+
*
|
|
99
|
+
* A lane of one task dispatches on that task's clamped model — haiku or sonnet,
|
|
100
|
+
* or opus for a tier-5 task or a solo promotion. A lane of two or more tasks is
|
|
101
|
+
* the work agent the planner chose not to split: it dispatches on opus when its
|
|
102
|
+
* hardest tier is at or above `LANE_CAPS.opusMinTier`, or when every task already
|
|
103
|
+
* carries opus (solo promotion of the whole change). Otherwise it runs on
|
|
104
|
+
* sonnet — routine multi-task work no longer pays the flagship model by default.
|
|
105
|
+
* Nothing here rewrites `task.model`: a narrowed lane of one returns to the
|
|
106
|
+
* task's model.
|
|
107
|
+
*/
|
|
108
|
+
export function laneModel(lane) {
|
|
109
|
+
if (!Array.isArray(lane) || lane.length === 0) return 'sonnet'
|
|
110
|
+
if (lane.length === 1) {
|
|
111
|
+
const only = lane[0]
|
|
112
|
+
return (only && only.model) || 'sonnet'
|
|
113
|
+
}
|
|
114
|
+
// Solo promotions rewrite every task to opus after the clamp; an all-opus
|
|
115
|
+
// multi-task lane is that decision (or a pure tier-5 pack), not the floor.
|
|
116
|
+
if (lane.every(t => t && t.model === 'opus')) return 'opus'
|
|
117
|
+
return laneTier(lane) >= LANE_CAPS.opusMinTier ? 'opus' : 'sonnet'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The reasoning effort a lane dispatches at — a sibling of `laneModel`, derived
|
|
122
|
+
* from the same hardest-task tier, looked up in the table published by
|
|
123
|
+
* `lib/limits.mjs`. A lane is one agent, and that agent must be capable of the
|
|
124
|
+
* hardest thing in the lane, so the effort is the hardest task's, never the
|
|
125
|
+
* first task's — the one direction of this trade that is not survivable.
|
|
126
|
+
*
|
|
127
|
+
* Returns `null` (inherit the session default — do not force) for tiers 3–4 by
|
|
128
|
+
* policy and for an untiered lane (`laneTier` → 0) by fallback; the plan's
|
|
129
|
+
* effort report distinguishes the two. Effort is derived from tier alone. The
|
|
130
|
+
* model a lane dispatches on follows shape and the opus floor, so a multi-task
|
|
131
|
+
* lane of tier-1 or tier-2 tasks runs on sonnet at `low`.
|
|
132
|
+
*/
|
|
133
|
+
export function laneEffort(lane) {
|
|
134
|
+
const tier = laneTier(lane)
|
|
135
|
+
return EFFORT.byTier[tier] ?? null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A drawn row fitted to `columns` code points: unchanged when it fits; else its
|
|
140
|
+
* first `columns - 1` code points and `…`, so the result is exactly `columns`
|
|
141
|
+
* wide. Both renderers cut every row with it, so a plan board and a run board
|
|
142
|
+
* cut alike, and at the place the ship meter's pane cuts a line.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} text
|
|
145
|
+
* @param {number} columns
|
|
146
|
+
* @returns {string}
|
|
147
|
+
*/
|
|
148
|
+
export function fitRow(text, columns) {
|
|
149
|
+
const points = [...text]
|
|
150
|
+
if (points.length <= columns) return text
|
|
151
|
+
return `${points.slice(0, Math.max(0, columns - 1)).join('')}…`
|
|
152
|
+
}
|
package/lib/limits.mjs
CHANGED
|
@@ -185,7 +185,39 @@ export const LIMITS = {
|
|
|
185
185
|
* minute at most this stale, at a few hundred dispatches an hour, which is
|
|
186
186
|
* nothing beside the run's own traffic. Read by `lib/meter-quiet.mjs`.
|
|
187
187
|
*/
|
|
188
|
-
meterTickMs: 15 * 1000
|
|
188
|
+
meterTickMs: 15 * 1000,
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The most characters of a halt resume card the `/interlock-handoff` pane
|
|
192
|
+
* renders (show-preflight-and-interrupted-runs-at-session-start design D7).
|
|
193
|
+
* Not a choice: it is the `Markdown` element's own bound on Claude Code
|
|
194
|
+
* 2.1.286, which refuses a longer `text`. A card over it is cut with a line
|
|
195
|
+
* inside the rendered text naming the characters left out and the card's
|
|
196
|
+
* path, so the pane shows a head of the card instead of nothing. Read by
|
|
197
|
+
* `lib/preflight-file.mjs`, which the hooks module imports; restated nowhere
|
|
198
|
+
* in the module.
|
|
199
|
+
*/
|
|
200
|
+
handoffPaneChars: 10_000,
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The narrowest width, in columns, the wave board and the handoff board draw
|
|
204
|
+
* at (draw-wave-plan-and-handoff-graph-from-the-cli design D8). The fixed
|
|
205
|
+
* cells of a lane row — border, a two-digit batch, a five-character label,
|
|
206
|
+
* model, tier, effort and the longest state word — come to 41 columns, and
|
|
207
|
+
* seven more hold one ordered-after id. Below it a board would cut ids, so
|
|
208
|
+
* the renderer returns one spoken line instead. Read by `lib/draw-plan.mjs`
|
|
209
|
+
* and `lib/draw-run.mjs`.
|
|
210
|
+
*/
|
|
211
|
+
waveBoardMinColumns: 48,
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The width, in columns, a board is drawn at when neither `--columns` nor a
|
|
215
|
+
* terminal says (design D8). The widest uncut lane row of the real halted
|
|
216
|
+
* run's plan is 89 columns, so a piped board of a real plan keeps its gists.
|
|
217
|
+
* Read by both renderers, for a width they were not given, and by
|
|
218
|
+
* `bin/interlock`, which resolves the width before it draws.
|
|
219
|
+
*/
|
|
220
|
+
waveBoardDefaultColumns: 100
|
|
189
221
|
}
|
|
190
222
|
|
|
191
223
|
/**
|
|
@@ -537,7 +569,10 @@ export function formatLimits({ runtimeObserved } = {}) {
|
|
|
537
569
|
['resume card list rows (per list)', LIMITS.resumeCardListRows],
|
|
538
570
|
['launch ledger max age (ms)', LIMITS.launchLedgerMaxAgeMs],
|
|
539
571
|
['meter quiet after (ms)', LIMITS.meterQuietAfterMs],
|
|
540
|
-
['meter tick (ms)', LIMITS.meterTickMs]
|
|
572
|
+
['meter tick (ms)', LIMITS.meterTickMs],
|
|
573
|
+
['handoff pane chars', LIMITS.handoffPaneChars],
|
|
574
|
+
['wave board min width (columns)', LIMITS.waveBoardMinColumns],
|
|
575
|
+
['wave board default width (columns)', LIMITS.waveBoardDefaultColumns]
|
|
541
576
|
]
|
|
542
577
|
const evalRows = [
|
|
543
578
|
['eval smoke cost ceiling (per PR)', `$${EVAL_CAPS.smokeCostUsd}`],
|