@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.
@@ -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}`],