@renzrollon/interlock 1.2.1 → 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.
@@ -0,0 +1,516 @@
1
+ // The wave board and the Mermaid plan (draw-wave-plan-and-handoff-graph-from-the-cli,
2
+ // spec ship/diagrams): a plan drawn as lines, with an optional overlay from the
3
+ // wave state.
4
+ //
5
+ // Every word on the board is one field of one record. Waves are keyed by
6
+ // position, never by group: dependency layering emits a later layer under the
7
+ // same section number, so a board keyed by group would fold two waves into one.
8
+ // Lanes are named by `lib/lane.mjs`, the rule their spawn titles, briefing files
9
+ // and trajectory lines are named by. Where the state does not force an
10
+ // attribution — a skip recorded by group over two waves of that group, a
11
+ // failure id no lane carries — the board says so rather than guess.
12
+ //
13
+ // Pure and Node-free: no file, no environment, no terminal, no clock. The CLI
14
+ // reads the plan and the state and resolves the width; the ship meter's hooks
15
+ // module can import this file because its closure is `lib/lane.mjs` and
16
+ // `lib/limits.mjs` only (`test/spine/draw-plan.test.mjs` walks it). The widths
17
+ // are `interlock limits`'s.
18
+
19
+ import { TITLE_SEPARATOR, fitRow, laneEffort, laneLabel, laneModel, laneTier, laneTitle } from './lane.mjs'
20
+ import { LIMITS } from './limits.mjs'
21
+
22
+ /** The words a lane's state cell may read, each the state's own record. */
23
+ export const STATE_WORDS = Object.freeze(['ok', 'failed', 'current', 'pending', 'not reached', 'not recorded'])
24
+
25
+ const [OK, FAILED, CURRENT, PENDING, NOT_REACHED, NOT_RECORDED] = STATE_WORDS
26
+ const PER_TASK = 'per task'
27
+ const TAIL = 'then: verify-final → commit → close'
28
+
29
+ // Cell widths, so a board's columns align: the longest model name, the longest
30
+ // effort word (`inherit`) and the longest state word (`not recorded`).
31
+ const MODEL_CELL = 6
32
+ const EFFORT_CELL = 7
33
+ const STATE_CELL = 12
34
+
35
+ const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
36
+ const list = value => (Array.isArray(value) ? value : [])
37
+ const isText = value => typeof value === 'string' && value !== ''
38
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : /(ch|sh|s|x)$/.test(word) ? 'es' : 's'}`
39
+
40
+ /** A width the board draws at: a positive integer, else the published default. */
41
+ function boardWidth(columns) {
42
+ return Number.isInteger(columns) && columns > 0 ? columns : LIMITS.waveBoardDefaultColumns
43
+ }
44
+
45
+ /** A lane as drawn: an array of task objects, never empty. */
46
+ function lanesOfBatch(batch) {
47
+ return list(batch)
48
+ .map(lane => list(lane).filter(isObject))
49
+ .filter(lane => lane.length > 0)
50
+ }
51
+
52
+ /**
53
+ * The plan's positions: every wave holding a batch, in plan order, then the
54
+ * test wave — the waves `createRunState` keeps, so position `i` here is
55
+ * `state.waves[i]` there.
56
+ */
57
+ function positionsOf(plan) {
58
+ if (!isObject(plan)) return []
59
+ const waves = list(plan.waves).filter(isObject).map(wave => ({ wave, test: false }))
60
+ if (isObject(plan.testWave)) waves.push({ wave: plan.testWave, test: true })
61
+ return waves
62
+ .map(({ wave, test }) => ({
63
+ test,
64
+ group: wave.group ?? null,
65
+ // A plan written before waves carried a kind is an implementation plan.
66
+ kind: test ? 'test' : wave.kind === 'test' ? 'test' : 'impl',
67
+ red: wave.red === true,
68
+ batches: list(wave.batches).map(lanesOfBatch).filter(batch => batch.length > 0)
69
+ }))
70
+ .filter(position => position.batches.length > 0)
71
+ .map((position, index) => ({ ...position, index }))
72
+ }
73
+
74
+ /** The text a wave's top rule carries. */
75
+ function waveTitle(position) {
76
+ const batches = plural(position.batches.length, 'batch')
77
+ if (position.test) return `test wave · idx ${position.index} · test · ${batches}`
78
+ const group = position.group === null ? '?' : position.group
79
+ return (
80
+ `wave ${position.index + 1} · idx ${position.index} · group ${group} · ${position.kind} · ${batches}` +
81
+ (position.red ? ' · RED' : '')
82
+ )
83
+ }
84
+
85
+ /**
86
+ * The ids a lane was ordered after: the union of the `after` ids of the plan's
87
+ * own deferral records for the lane's tasks, minus the lane's own ids, in
88
+ * record order. Never recomputed from a task's `dependsOn`.
89
+ */
90
+ function orderedAfter(lane, deferred) {
91
+ const own = new Set(lane.map(task => task.id))
92
+ const out = []
93
+ for (const record of deferred) {
94
+ if (!own.has(record.id)) continue
95
+ for (const id of list(record.after)) if (isText(id) && !own.has(id) && !out.includes(id)) out.push(id)
96
+ }
97
+ return out
98
+ }
99
+
100
+ /** A skip that carries the wave position its verify step ran at. */
101
+ const isPositioned = skip => Number.isInteger(skip.waveIndex) && skip.waveIndex >= 0
102
+
103
+ /** What the overlay can say, read once from the state. */
104
+ function overlayOf(state, positions) {
105
+ if (!isObject(state)) return null
106
+ const stateWaves = list(state.waves)
107
+ const cursor = isObject(state.cursor) ? state.cursor : {}
108
+ const halt = isObject(state.halt) ? state.halt : null
109
+ return {
110
+ runId: isText(state.runId) ? state.runId : null,
111
+ waveCount: stateWaves.length,
112
+ // Applied by position only when the state walks the plan's own positions.
113
+ positional: stateWaves.length === positions.length,
114
+ completed: new Set(list(state.completed).filter(isText)),
115
+ failures: list(state.failures).filter(f => isObject(f) && isText(f.id)),
116
+ skips: list(state.skippedVerifications).filter(isObject),
117
+ positionedSkips: list(state.skippedVerifications)
118
+ .filter(skip => isObject(skip) && isPositioned(skip))
119
+ .map(skip => ({ index: skip.waveIndex, reason: isText(skip.reason) ? skip.reason : 'no reason recorded' })),
120
+ unresolved: list(state.unresolved).filter(isObject),
121
+ cursor: {
122
+ wave: Number.isInteger(cursor.waveIndex) ? cursor.waveIndex : null,
123
+ batch: Number.isInteger(cursor.batchIndex) ? cursor.batchIndex : null,
124
+ phase: typeof cursor.phase === 'string' ? cursor.phase : null
125
+ },
126
+ halted: halt !== null,
127
+ haltReason: halt && isText(halt.reason) ? halt.reason : null
128
+ }
129
+ }
130
+
131
+ /** The word the state records for one task at `position`, `batch`; '' for none. */
132
+ function taskWord(overlay, id, position, batch) {
133
+ if (overlay.failures.some(f => f.id === id)) return FAILED
134
+ if (overlay.completed.has(id)) return OK
135
+ const { cursor } = overlay
136
+ if (!overlay.positional || cursor.wave === null) return ''
137
+ const inBatch = cursor.phase === 'batch'
138
+ if (position < cursor.wave || (position === cursor.wave && (!inBatch || (cursor.batch !== null && batch < cursor.batch)))) {
139
+ return NOT_RECORDED
140
+ }
141
+ if (position === cursor.wave && inBatch && batch === cursor.batch) return CURRENT
142
+ return overlay.halted ? NOT_REACHED : PENDING
143
+ }
144
+
145
+ /** A lane's state cell and ids cell: one word when its tasks agree, else per task. */
146
+ function laneCells(overlay, lane, position, batch) {
147
+ const ids = lane.map(task => task.id)
148
+ if (!overlay) return { word: null, ids: ids.length > 1 ? ids.join(', ') : null, tasks: null }
149
+ const words = lane.map(task => taskWord(overlay, task.id, position, batch))
150
+ const tasks = ids.map((id, i) => ({ id, word: words[i] }))
151
+ if (words.every(word => word === words[0])) {
152
+ return { word: words[0], ids: ids.length > 1 ? ids.join(', ') : null, tasks }
153
+ }
154
+ return { word: PER_TASK, ids: ids.map((id, i) => (words[i] ? `${id} ${words[i]}` : id)).join(', '), tasks }
155
+ }
156
+
157
+ /**
158
+ * The overlay's text for each verify boundary (boundary `i` sits between
159
+ * position `i` and `i + 1`), plus what it could not place.
160
+ *
161
+ * Skips and unresolved checks are recorded by group, so they are placed by
162
+ * group: on the one boundary whose left wave carries that group, or, when two
163
+ * or more do, as an ambiguity on the first that says which boundaries the state
164
+ * does not tell apart. The cursor is not used to break the tie.
165
+ */
166
+ function verifyTexts(overlay, positions) {
167
+ const texts = positions.slice(0, -1).map(() => [])
168
+ const unplaced = []
169
+ if (!overlay || !overlay.positional) return { texts, unplaced }
170
+ const { cursor } = overlay
171
+ const after = overlay.halted ? NOT_REACHED : PENDING
172
+ if (cursor.wave !== null) {
173
+ texts.forEach((text, i) => {
174
+ if (i === cursor.wave && cursor.phase === 'verify') text.push(CURRENT)
175
+ else if (i >= cursor.wave) text.push(after)
176
+ })
177
+ }
178
+
179
+ const groups = []
180
+ const byGroup = new Map()
181
+ const entry = group => {
182
+ if (!byGroup.has(group)) {
183
+ byGroup.set(group, { skips: [], unresolved: [] })
184
+ groups.push(group)
185
+ }
186
+ return byGroup.get(group)
187
+ }
188
+ for (const skip of overlay.skips.filter(skip => !isPositioned(skip))) {
189
+ entry(skip.wave ?? null).skips.push(isText(skip.reason) ? skip.reason : 'no reason recorded')
190
+ }
191
+ for (const u of overlay.unresolved) {
192
+ entry(u.wave ?? null).unresolved.push(`unresolved after ${Number.isInteger(u.attempts) ? u.attempts : '?'} attempts`)
193
+ }
194
+
195
+ // A skip the overlay recorded with its wave position — the ship meter's,
196
+ // read off the verify step (draw-the-wave-board-in-the-meter-pane D6) — is
197
+ // placed by it: the position says which boundary, so no group is guessed.
198
+ const touched = new Set()
199
+ for (const { index, reason } of overlay.positionedSkips) {
200
+ if (index < texts.length) {
201
+ texts[index].push(`skipped: ${reason}`)
202
+ touched.add(index)
203
+ } else unplaced.push(` unplaced: skip at idx ${index} · no verify boundary follows that position · ${reason}`)
204
+ }
205
+ for (const group of groups) {
206
+ const { skips, unresolved } = byGroup.get(group)
207
+ const recorded = [...skips.map(reason => `skipped: ${reason}`), ...unresolved]
208
+ const boundaries = texts.map((_, i) => i).filter(i => positions[i].group === group)
209
+ if (boundaries.length === 1) {
210
+ texts[boundaries[0]].push(...recorded)
211
+ touched.add(boundaries[0])
212
+ continue
213
+ }
214
+ const counted = [
215
+ ...(skips.length ? [`${skips.length} skip${skips.length === 1 ? '' : 's'}`] : []),
216
+ ...(unresolved.length ? [`${unresolved.length} unresolved`] : [])
217
+ ].join(' and ')
218
+ const reasons = [...skips, ...unresolved]
219
+ if (boundaries.length === 0) {
220
+ unplaced.push(` unplaced: ${counted} for group ${group} · no boundary carries that group · ${reasons.join(' · ')}`)
221
+ continue
222
+ }
223
+ const [first, ...later] = boundaries
224
+ const named = boundaries.map(i => `idx ${i}`)
225
+ const which = `${named.slice(0, -1).join(', ')} or ${named.at(-1)}`
226
+ texts[first].push(`${counted} for group ${group} · state does not say ${which}`, ...reasons)
227
+ touched.add(first)
228
+ for (const i of later) {
229
+ texts[i].push(`group ${group} skips: see idx ${first}`)
230
+ touched.add(i)
231
+ }
232
+ }
233
+
234
+ // A check that passed leaves no record, so a reached boundary with nothing
235
+ // recorded says exactly that, never `passed`.
236
+ if (cursor.wave !== null) {
237
+ texts.forEach((text, i) => {
238
+ if (i < cursor.wave && !touched.has(i)) text.push('no skip recorded')
239
+ })
240
+ }
241
+ return { texts, unplaced }
242
+ }
243
+
244
+ /** Each failure whose id no planned lane carries, by the wave the state recorded. */
245
+ function unplacedFailures(overlay, planned) {
246
+ if (!overlay) return []
247
+ return overlay.failures
248
+ .filter(f => !planned.has(f.id))
249
+ .map(f => {
250
+ const where = `wave ${f.wave ?? '?'}${isText(f.waveKind) ? ` ${f.waveKind}` : ''}`
251
+ const error = isText(f.error) ? `error: ${f.error}` : 'no error recorded'
252
+ return ` unplaced: ${f.id} · ${where} · ${error}`
253
+ })
254
+ }
255
+
256
+ /** The overlay's part of the header (and of the Mermaid source comment). */
257
+ function overlayHeader(overlay, positions, stateNote, { short = false } = {}) {
258
+ if (!overlay) return `plan only${isText(stateNote) ? ` (${stateNote})` : ''}`
259
+ if (!overlay.positional) {
260
+ return short
261
+ ? 'overlay by task id only'
262
+ : `overlay by task id only: state has ${overlay.waveCount} waves, plan has ${positions.length}`
263
+ }
264
+ const run = `run ${overlay.runId ? overlay.runId.slice(0, 8) : '?'}`
265
+ if (short) return run
266
+ const { cursor } = overlay
267
+ const at =
268
+ cursor.wave === null
269
+ ? ' · cursor not recorded'
270
+ : ` · cursor idx ${cursor.wave}${cursor.phase === 'batch' && cursor.batch !== null ? ` b${cursor.batch}` : ''}` +
271
+ (cursor.phase === 'verify' ? ' · verify' : '')
272
+ return `${run}${at}${overlay.halted ? ' · halted' : ''}`
273
+ }
274
+
275
+ /** Every lane of every position, with what a row or a node draws of it. */
276
+ function lanesOf(positions, plan) {
277
+ const deferred = list(isObject(plan) ? plan.deferred : null).filter(r => isObject(r) && isText(r.id))
278
+ const out = []
279
+ for (const position of positions) {
280
+ position.batches.forEach((batch, b) => {
281
+ for (const lane of batch) {
282
+ const label = laneLabel(lane)
283
+ const title = laneTitle(lane)
284
+ const prefix = `${label}${TITLE_SEPARATOR}`
285
+ const tier = laneTier(lane)
286
+ out.push({
287
+ lane,
288
+ position: position.index,
289
+ batch: b,
290
+ label,
291
+ title,
292
+ gist: title.startsWith(prefix) ? title.slice(prefix.length) : '',
293
+ model: laneModel(lane),
294
+ tier: tier > 0 ? String(tier) : '?',
295
+ effort: laneEffort(lane) ?? 'inherit',
296
+ after: orderedAfter(lane, deferred)
297
+ })
298
+ }
299
+ })
300
+ }
301
+ return out
302
+ }
303
+
304
+ /** A top rule: its text, then `─` to exactly the width. */
305
+ function topRule(text, width) {
306
+ const head = `┌─ ${text} `
307
+ const length = [...head].length
308
+ return length >= width ? fitRow(head, width) : `${head}${'─'.repeat(width - length)}`
309
+ }
310
+
311
+ /**
312
+ * The plan as rows of `{ key, text }`: `header`, `wave:<i>`, `lane:<label>`,
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.
316
+ */
317
+ function boardRows(plan, opts) {
318
+ const width = boardWidth(opts.columns)
319
+ if (width < LIMITS.waveBoardMinColumns) {
320
+ return [{ key: 'header', text: `wave board needs at least ${LIMITS.waveBoardMinColumns} columns; ${width} given` }]
321
+ }
322
+ const positions = positionsOf(plan)
323
+ const overlay = overlayOf(opts.state, positions)
324
+ const lanes = lanesOf(positions, plan)
325
+ const planned = new Set(lanes.flatMap(l => l.lane.map(task => task.id)))
326
+ const notes = new Map(Object.entries(isObject(opts.notes) ? opts.notes : {}).filter(([, text]) => isText(text)))
327
+ const mode = overlayHeader(overlay, positions, opts.stateNote)
328
+ const rows = []
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
+ }
334
+
335
+ push(
336
+ 'header',
337
+ positions.length === 0
338
+ ? `plan · holds no waves · ${mode}`
339
+ : `plan · ${plural(positions.length, 'wave')} · ${plural(planned.size, 'task')} · ${mode}`
340
+ )
341
+
342
+ const labelCell = Math.max(0, ...lanes.map(l => l.label.length))
343
+ const batchCell = Math.max(0, ...lanes.map(l => `b${l.batch}`.length))
344
+ const { texts, unplaced } = verifyTexts(overlay, positions)
345
+ for (const position of positions) {
346
+ if (position.index > 0) {
347
+ const i = position.index - 1
348
+ push(`verify:${i}`, [` verify after idx ${i}`, ...texts[i]].join(' · '))
349
+ }
350
+ const title = waveTitle(position)
351
+ push(`wave:${position.index}`, topRule(title, width), { title })
352
+ for (const l of lanes.filter(l => l.position === position.index)) {
353
+ const { word, ids, tasks } = laneCells(overlay, l.lane, l.position, l.batch)
354
+ const cells = [
355
+ '│',
356
+ `b${l.batch}`.padEnd(batchCell),
357
+ l.label.padEnd(labelCell),
358
+ l.model.padEnd(MODEL_CELL),
359
+ `T${l.tier}`,
360
+ l.effort.padEnd(EFFORT_CELL)
361
+ ]
362
+ // A note is what a host observed of this lane, appended verbatim after the
363
+ // state word; the gist after it gives way first when the row is cut.
364
+ const note = notes.get(l.label)
365
+ if (overlay) cells.push(note ? `${word || ''} ${note}`.trimStart() : (word || '').padEnd(STATE_CELL))
366
+ else if (note) cells.push(note)
367
+ if (ids) cells.push(`[${ids}]`)
368
+ if (l.after.length) cells.push(`←${l.after.join(',')}`)
369
+ const head = cells.join(' ')
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
+ })
383
+ }
384
+ push(`wave:${position.index}:end`, `└${'─'.repeat(width - 1)}`)
385
+ }
386
+
387
+ const spoken = [...unplacedFailures(overlay, planned), ...unplaced]
388
+ spoken.forEach((text, n) => push(`unplaced:${n}`, text))
389
+ const halted = overlay && overlay.halted ? ` · halted${overlay.haltReason ? `: ${overlay.haltReason}` : ''}` : ''
390
+ push('tail', `${TAIL}${halted}`)
391
+ return rows
392
+ }
393
+
394
+ /**
395
+ * The wave board as keyed rows, for a drawer that keys what it draws — the ship
396
+ * meter's pane (draw-the-wave-board-in-the-meter-pane design D7, D10). Keys:
397
+ * `header`, `wave:<i>`, `lane:<label>` (the label its spawn, briefing and
398
+ * trajectory line carry), `wave:<i>:end`, `verify:<i>`, `unplaced:<n>` and
399
+ * `tail`. Below the published minimum width, the one spoken row, keyed
400
+ * `header`: a board always ends in `tail`, and the spoken line never does.
401
+ *
402
+ * `opts.notes` maps a lane label to text a host observed of that lane; it is
403
+ * appended verbatim after the lane's state word and cut with the row. A label
404
+ * no lane carries is drawn nowhere.
405
+ *
406
+ * @param {unknown} plan what `planWaves` emits, or a plan summary; any value draws
407
+ * @param {{columns?: number, state?: object|null, stateNote?: string, notes?: Record<string, string>}} [opts]
408
+ * @returns {{key: string, text: string, parts?: object}[]}
409
+ */
410
+ export function drawPlanBoardRows(plan, opts = {}) {
411
+ return boardRows(plan, isObject(opts) ? opts : {})
412
+ }
413
+
414
+ /**
415
+ * The wave board: one bordered block per wave position, one row per lane, a
416
+ * verify cell at each boundary and a tail, every line at most `opts.columns`
417
+ * code points. Below the published minimum width, one spoken line. The texts
418
+ * of `drawPlanBoardRows`, in order.
419
+ *
420
+ * @param {unknown} plan what `planWaves` emits; any value draws, a non-plan as no waves
421
+ * @param {{columns?: number, state?: object|null, stateNote?: string, notes?: Record<string, string>}} [opts]
422
+ * @returns {string[]}
423
+ */
424
+ export function drawPlanBoard(plan, opts = {}) {
425
+ return drawPlanBoardRows(plan, opts).map(row => row.text)
426
+ }
427
+
428
+ /** A Mermaid label, quoted: `"` is the one character a quoted label cannot hold. */
429
+ const label = text => `"${String(text).replace(/"/g, '#quot;')}"`
430
+
431
+ /** Node ids from lane labels, so the same plan renders to the same text. */
432
+ function laneIds(lanes) {
433
+ const used = new Map()
434
+ return lanes.map(l => {
435
+ const base = `L${l.label.replace(/\./g, '_').replace(/\+/g, 'p').replace(/[^A-Za-z0-9_]/g, '_')}`
436
+ const n = (used.get(base) ?? 0) + 1
437
+ used.set(base, n)
438
+ return n === 1 ? base : `${base}_${n}`
439
+ })
440
+ }
441
+
442
+ /**
443
+ * The plan as a Mermaid `flowchart LR`: a subgraph per wave position (and per
444
+ * batch of a multi-batch wave), a node per lane, dotted `dependsOn` edges from
445
+ * the plan's deferral records, hexagon verify gates and subroutine closing
446
+ * gates. Never cut, and never styled.
447
+ *
448
+ * @param {unknown} plan
449
+ * @param {{state?: object|null, stateNote?: string, source?: string}} [opts]
450
+ * @returns {string[]}
451
+ */
452
+ export function drawPlanMermaid(plan, opts = {}) {
453
+ const options = isObject(opts) ? opts : {}
454
+ const positions = positionsOf(plan)
455
+ const overlay = overlayOf(options.state, positions)
456
+ const lanes = lanesOf(positions, plan)
457
+ const ids = laneIds(lanes)
458
+ const source = isText(options.source) ? options.source : 'plan'
459
+ const out = ['flowchart LR', ` %% source: ${source} · ${overlayHeader(overlay, positions, options.stateNote, { short: true })}`]
460
+
461
+ const node = (l, i, indent) => {
462
+ const { word, ids: cell } = laneCells(overlay, l.lane, l.position, l.batch)
463
+ const state = word === PER_TASK ? ` · ${PER_TASK} [${cell}]` : word ? ` · ${word}` : ''
464
+ return `${indent}${ids[i]}[${label(`${l.title} · ${l.model} T${l.tier} ${l.effort}${state}`)}]`
465
+ }
466
+
467
+ for (const position of positions) {
468
+ out.push(` subgraph W${position.index}[${label(waveTitle(position))}]`)
469
+ const members = lanes.map((l, i) => ({ l, i })).filter(({ l }) => l.position === position.index)
470
+ if (position.batches.length === 1) {
471
+ for (const { l, i } of members) out.push(node(l, i, ' '))
472
+ } else {
473
+ position.batches.forEach((_, b) => {
474
+ out.push(` subgraph W${position.index}B${b}[${label(`b${b}`)}]`)
475
+ for (const { l, i } of members.filter(({ l }) => l.batch === b)) out.push(node(l, i, ' '))
476
+ out.push(' end')
477
+ })
478
+ out.push(` ${position.batches.map((_, b) => `W${position.index}B${b}`).join(' --> ')}`)
479
+ }
480
+ out.push(' end')
481
+ }
482
+
483
+ const { texts } = verifyTexts(overlay, positions)
484
+ const chain = []
485
+ for (const position of positions) {
486
+ if (position.index > 0) {
487
+ const i = position.index - 1
488
+ chain.push(`V${i}{{${label([`verify after idx ${i}`, ...texts[i]].join(' · '))}}}`)
489
+ }
490
+ chain.push(`W${position.index}`)
491
+ }
492
+ chain.push(`VF[[${label('verify-final')}]]`, `C[[${label('commit')}]]`, `X[[${label('close')}]]`)
493
+ out.push(` ${chain.join(' --> ')}`)
494
+
495
+ const laneOf = new Map()
496
+ lanes.forEach((l, i) => {
497
+ for (const task of l.lane) if (!laneOf.has(task.id)) laneOf.set(task.id, ids[i])
498
+ })
499
+ const seen = new Set()
500
+ const deferred = list(isObject(plan) ? plan.deferred : null).filter(r => isObject(r) && isText(r.id))
501
+ for (const record of deferred) {
502
+ for (const after of list(record.after).filter(isText)) {
503
+ const from = laneOf.get(after)
504
+ const to = laneOf.get(record.id)
505
+ let line
506
+ if (!to) line = ` %% dependsOn ${record.id} ← ${after}: ${record.id} is in no lane of this plan`
507
+ else if (!from) line = ` %% dependsOn ${record.id} ← ${after}: ${after} is in no lane of this plan`
508
+ else if (from === to) continue
509
+ else line = ` ${from} -. dependsOn .-> ${to}`
510
+ if (seen.has(line)) continue
511
+ seen.add(line)
512
+ out.push(line)
513
+ }
514
+ }
515
+ return out
516
+ }