@renzrollon/interlock 1.2.0 → 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,496 @@
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 }
149
+ const words = lane.map(task => taskWord(overlay, task.id, position, batch))
150
+ if (words.every(word => word === words[0])) {
151
+ return { word: words[0], ids: ids.length > 1 ? ids.join(', ') : null }
152
+ }
153
+ return { word: PER_TASK, ids: ids.map((id, i) => (words[i] ? `${id} ${words[i]}` : id)).join(', ') }
154
+ }
155
+
156
+ /**
157
+ * The overlay's text for each verify boundary (boundary `i` sits between
158
+ * position `i` and `i + 1`), plus what it could not place.
159
+ *
160
+ * Skips and unresolved checks are recorded by group, so they are placed by
161
+ * group: on the one boundary whose left wave carries that group, or, when two
162
+ * or more do, as an ambiguity on the first that says which boundaries the state
163
+ * does not tell apart. The cursor is not used to break the tie.
164
+ */
165
+ function verifyTexts(overlay, positions) {
166
+ const texts = positions.slice(0, -1).map(() => [])
167
+ const unplaced = []
168
+ if (!overlay || !overlay.positional) return { texts, unplaced }
169
+ const { cursor } = overlay
170
+ const after = overlay.halted ? NOT_REACHED : PENDING
171
+ if (cursor.wave !== null) {
172
+ texts.forEach((text, i) => {
173
+ if (i === cursor.wave && cursor.phase === 'verify') text.push(CURRENT)
174
+ else if (i >= cursor.wave) text.push(after)
175
+ })
176
+ }
177
+
178
+ const groups = []
179
+ const byGroup = new Map()
180
+ const entry = group => {
181
+ if (!byGroup.has(group)) {
182
+ byGroup.set(group, { skips: [], unresolved: [] })
183
+ groups.push(group)
184
+ }
185
+ return byGroup.get(group)
186
+ }
187
+ for (const skip of overlay.skips.filter(skip => !isPositioned(skip))) {
188
+ entry(skip.wave ?? null).skips.push(isText(skip.reason) ? skip.reason : 'no reason recorded')
189
+ }
190
+ for (const u of overlay.unresolved) {
191
+ entry(u.wave ?? null).unresolved.push(`unresolved after ${Number.isInteger(u.attempts) ? u.attempts : '?'} attempts`)
192
+ }
193
+
194
+ // A skip the overlay recorded with its wave position — the ship meter's,
195
+ // read off the verify step (draw-the-wave-board-in-the-meter-pane D6) — is
196
+ // placed by it: the position says which boundary, so no group is guessed.
197
+ const touched = new Set()
198
+ for (const { index, reason } of overlay.positionedSkips) {
199
+ if (index < texts.length) {
200
+ texts[index].push(`skipped: ${reason}`)
201
+ touched.add(index)
202
+ } else unplaced.push(` unplaced: skip at idx ${index} · no verify boundary follows that position · ${reason}`)
203
+ }
204
+ for (const group of groups) {
205
+ const { skips, unresolved } = byGroup.get(group)
206
+ const recorded = [...skips.map(reason => `skipped: ${reason}`), ...unresolved]
207
+ const boundaries = texts.map((_, i) => i).filter(i => positions[i].group === group)
208
+ if (boundaries.length === 1) {
209
+ texts[boundaries[0]].push(...recorded)
210
+ touched.add(boundaries[0])
211
+ continue
212
+ }
213
+ const counted = [
214
+ ...(skips.length ? [`${skips.length} skip${skips.length === 1 ? '' : 's'}`] : []),
215
+ ...(unresolved.length ? [`${unresolved.length} unresolved`] : [])
216
+ ].join(' and ')
217
+ const reasons = [...skips, ...unresolved]
218
+ if (boundaries.length === 0) {
219
+ unplaced.push(` unplaced: ${counted} for group ${group} · no boundary carries that group · ${reasons.join(' · ')}`)
220
+ continue
221
+ }
222
+ const [first, ...later] = boundaries
223
+ const named = boundaries.map(i => `idx ${i}`)
224
+ const which = `${named.slice(0, -1).join(', ')} or ${named.at(-1)}`
225
+ texts[first].push(`${counted} for group ${group} · state does not say ${which}`, ...reasons)
226
+ touched.add(first)
227
+ for (const i of later) {
228
+ texts[i].push(`group ${group} skips: see idx ${first}`)
229
+ touched.add(i)
230
+ }
231
+ }
232
+
233
+ // A check that passed leaves no record, so a reached boundary with nothing
234
+ // recorded says exactly that, never `passed`.
235
+ if (cursor.wave !== null) {
236
+ texts.forEach((text, i) => {
237
+ if (i < cursor.wave && !touched.has(i)) text.push('no skip recorded')
238
+ })
239
+ }
240
+ return { texts, unplaced }
241
+ }
242
+
243
+ /** Each failure whose id no planned lane carries, by the wave the state recorded. */
244
+ function unplacedFailures(overlay, planned) {
245
+ if (!overlay) return []
246
+ return overlay.failures
247
+ .filter(f => !planned.has(f.id))
248
+ .map(f => {
249
+ const where = `wave ${f.wave ?? '?'}${isText(f.waveKind) ? ` ${f.waveKind}` : ''}`
250
+ const error = isText(f.error) ? `error: ${f.error}` : 'no error recorded'
251
+ return ` unplaced: ${f.id} · ${where} · ${error}`
252
+ })
253
+ }
254
+
255
+ /** The overlay's part of the header (and of the Mermaid source comment). */
256
+ function overlayHeader(overlay, positions, stateNote, { short = false } = {}) {
257
+ if (!overlay) return `plan only${isText(stateNote) ? ` (${stateNote})` : ''}`
258
+ if (!overlay.positional) {
259
+ return short
260
+ ? 'overlay by task id only'
261
+ : `overlay by task id only: state has ${overlay.waveCount} waves, plan has ${positions.length}`
262
+ }
263
+ const run = `run ${overlay.runId ? overlay.runId.slice(0, 8) : '?'}`
264
+ if (short) return run
265
+ const { cursor } = overlay
266
+ const at =
267
+ cursor.wave === null
268
+ ? ' · cursor not recorded'
269
+ : ` · cursor idx ${cursor.wave}${cursor.phase === 'batch' && cursor.batch !== null ? ` b${cursor.batch}` : ''}` +
270
+ (cursor.phase === 'verify' ? ' · verify' : '')
271
+ return `${run}${at}${overlay.halted ? ' · halted' : ''}`
272
+ }
273
+
274
+ /** Every lane of every position, with what a row or a node draws of it. */
275
+ function lanesOf(positions, plan) {
276
+ const deferred = list(isObject(plan) ? plan.deferred : null).filter(r => isObject(r) && isText(r.id))
277
+ const out = []
278
+ for (const position of positions) {
279
+ position.batches.forEach((batch, b) => {
280
+ for (const lane of batch) {
281
+ const label = laneLabel(lane)
282
+ const title = laneTitle(lane)
283
+ const prefix = `${label}${TITLE_SEPARATOR}`
284
+ const tier = laneTier(lane)
285
+ out.push({
286
+ lane,
287
+ position: position.index,
288
+ batch: b,
289
+ label,
290
+ title,
291
+ gist: title.startsWith(prefix) ? title.slice(prefix.length) : '',
292
+ model: laneModel(lane),
293
+ tier: tier > 0 ? String(tier) : '?',
294
+ effort: laneEffort(lane) ?? 'inherit',
295
+ after: orderedAfter(lane, deferred)
296
+ })
297
+ }
298
+ })
299
+ }
300
+ return out
301
+ }
302
+
303
+ /** A top rule: its text, then `─` to exactly the width. */
304
+ function topRule(text, width) {
305
+ const head = `┌─ ${text} `
306
+ const length = [...head].length
307
+ return length >= width ? fitRow(head, width) : `${head}${'─'.repeat(width - length)}`
308
+ }
309
+
310
+ /**
311
+ * The plan as rows of `{ key, text }`: `header`, `wave:<i>`, `lane:<label>`,
312
+ * `wave:<i>:end`, `verify:<i>`, `unplaced:<n>`, `tail`.
313
+ */
314
+ function boardRows(plan, opts) {
315
+ const width = boardWidth(opts.columns)
316
+ if (width < LIMITS.waveBoardMinColumns) {
317
+ return [{ key: 'header', text: `wave board needs at least ${LIMITS.waveBoardMinColumns} columns; ${width} given` }]
318
+ }
319
+ const positions = positionsOf(plan)
320
+ const overlay = overlayOf(opts.state, positions)
321
+ const lanes = lanesOf(positions, plan)
322
+ const planned = new Set(lanes.flatMap(l => l.lane.map(task => task.id)))
323
+ const notes = new Map(Object.entries(isObject(opts.notes) ? opts.notes : {}).filter(([, text]) => isText(text)))
324
+ const mode = overlayHeader(overlay, positions, opts.stateNote)
325
+ const rows = []
326
+ const push = (key, text) => rows.push({ key, text: fitRow(text, width) })
327
+
328
+ push(
329
+ 'header',
330
+ positions.length === 0
331
+ ? `plan · holds no waves · ${mode}`
332
+ : `plan · ${plural(positions.length, 'wave')} · ${plural(planned.size, 'task')} · ${mode}`
333
+ )
334
+
335
+ const labelCell = Math.max(0, ...lanes.map(l => l.label.length))
336
+ const batchCell = Math.max(0, ...lanes.map(l => `b${l.batch}`.length))
337
+ const { texts, unplaced } = verifyTexts(overlay, positions)
338
+ for (const position of positions) {
339
+ if (position.index > 0) {
340
+ const i = position.index - 1
341
+ push(`verify:${i}`, [` verify after idx ${i}`, ...texts[i]].join(' · '))
342
+ }
343
+ push(`wave:${position.index}`, topRule(waveTitle(position), width))
344
+ for (const l of lanes.filter(l => l.position === position.index)) {
345
+ const { word, ids } = laneCells(overlay, l.lane, l.position, l.batch)
346
+ const cells = [
347
+ '│',
348
+ `b${l.batch}`.padEnd(batchCell),
349
+ l.label.padEnd(labelCell),
350
+ l.model.padEnd(MODEL_CELL),
351
+ `T${l.tier}`,
352
+ l.effort.padEnd(EFFORT_CELL)
353
+ ]
354
+ // A note is what a host observed of this lane, appended verbatim after the
355
+ // state word; the gist after it gives way first when the row is cut.
356
+ const note = notes.get(l.label)
357
+ if (overlay) cells.push(note ? `${word || ''} ${note}`.trimStart() : (word || '').padEnd(STATE_CELL))
358
+ else if (note) cells.push(note)
359
+ if (ids) cells.push(`[${ids}]`)
360
+ if (l.after.length) cells.push(`←${l.after.join(',')}`)
361
+ const head = cells.join(' ')
362
+ push(`lane:${l.label}`, l.gist ? `${head}${TITLE_SEPARATOR}${l.gist}` : head.trimEnd())
363
+ }
364
+ push(`wave:${position.index}:end`, `└${'─'.repeat(width - 1)}`)
365
+ }
366
+
367
+ const spoken = [...unplacedFailures(overlay, planned), ...unplaced]
368
+ spoken.forEach((text, n) => push(`unplaced:${n}`, text))
369
+ const halted = overlay && overlay.halted ? ` · halted${overlay.haltReason ? `: ${overlay.haltReason}` : ''}` : ''
370
+ push('tail', `${TAIL}${halted}`)
371
+ return rows
372
+ }
373
+
374
+ /**
375
+ * The wave board as keyed rows, for a drawer that keys what it draws — the ship
376
+ * meter's pane (draw-the-wave-board-in-the-meter-pane design D7, D10). Keys:
377
+ * `header`, `wave:<i>`, `lane:<label>` (the label its spawn, briefing and
378
+ * trajectory line carry), `wave:<i>:end`, `verify:<i>`, `unplaced:<n>` and
379
+ * `tail`. Below the published minimum width, the one spoken row, keyed
380
+ * `header`: a board always ends in `tail`, and the spoken line never does.
381
+ *
382
+ * `opts.notes` maps a lane label to text a host observed of that lane; it is
383
+ * appended verbatim after the lane's state word and cut with the row. A label
384
+ * no lane carries is drawn nowhere.
385
+ *
386
+ * @param {unknown} plan what `planWaves` emits, or a plan summary; any value draws
387
+ * @param {{columns?: number, state?: object|null, stateNote?: string, notes?: Record<string, string>}} [opts]
388
+ * @returns {{key: string, text: string}[]}
389
+ */
390
+ export function drawPlanBoardRows(plan, opts = {}) {
391
+ return boardRows(plan, isObject(opts) ? opts : {})
392
+ }
393
+
394
+ /**
395
+ * The wave board: one bordered block per wave position, one row per lane, a
396
+ * verify cell at each boundary and a tail, every line at most `opts.columns`
397
+ * code points. Below the published minimum width, one spoken line. The texts
398
+ * of `drawPlanBoardRows`, in order.
399
+ *
400
+ * @param {unknown} plan what `planWaves` emits; any value draws, a non-plan as no waves
401
+ * @param {{columns?: number, state?: object|null, stateNote?: string, notes?: Record<string, string>}} [opts]
402
+ * @returns {string[]}
403
+ */
404
+ export function drawPlanBoard(plan, opts = {}) {
405
+ return drawPlanBoardRows(plan, opts).map(row => row.text)
406
+ }
407
+
408
+ /** A Mermaid label, quoted: `"` is the one character a quoted label cannot hold. */
409
+ const label = text => `"${String(text).replace(/"/g, '#quot;')}"`
410
+
411
+ /** Node ids from lane labels, so the same plan renders to the same text. */
412
+ function laneIds(lanes) {
413
+ const used = new Map()
414
+ return lanes.map(l => {
415
+ const base = `L${l.label.replace(/\./g, '_').replace(/\+/g, 'p').replace(/[^A-Za-z0-9_]/g, '_')}`
416
+ const n = (used.get(base) ?? 0) + 1
417
+ used.set(base, n)
418
+ return n === 1 ? base : `${base}_${n}`
419
+ })
420
+ }
421
+
422
+ /**
423
+ * The plan as a Mermaid `flowchart LR`: a subgraph per wave position (and per
424
+ * batch of a multi-batch wave), a node per lane, dotted `dependsOn` edges from
425
+ * the plan's deferral records, hexagon verify gates and subroutine closing
426
+ * gates. Never cut, and never styled.
427
+ *
428
+ * @param {unknown} plan
429
+ * @param {{state?: object|null, stateNote?: string, source?: string}} [opts]
430
+ * @returns {string[]}
431
+ */
432
+ export function drawPlanMermaid(plan, opts = {}) {
433
+ const options = isObject(opts) ? opts : {}
434
+ const positions = positionsOf(plan)
435
+ const overlay = overlayOf(options.state, positions)
436
+ const lanes = lanesOf(positions, plan)
437
+ const ids = laneIds(lanes)
438
+ const source = isText(options.source) ? options.source : 'plan'
439
+ const out = ['flowchart LR', ` %% source: ${source} · ${overlayHeader(overlay, positions, options.stateNote, { short: true })}`]
440
+
441
+ const node = (l, i, indent) => {
442
+ const { word, ids: cell } = laneCells(overlay, l.lane, l.position, l.batch)
443
+ const state = word === PER_TASK ? ` · ${PER_TASK} [${cell}]` : word ? ` · ${word}` : ''
444
+ return `${indent}${ids[i]}[${label(`${l.title} · ${l.model} T${l.tier} ${l.effort}${state}`)}]`
445
+ }
446
+
447
+ for (const position of positions) {
448
+ out.push(` subgraph W${position.index}[${label(waveTitle(position))}]`)
449
+ const members = lanes.map((l, i) => ({ l, i })).filter(({ l }) => l.position === position.index)
450
+ if (position.batches.length === 1) {
451
+ for (const { l, i } of members) out.push(node(l, i, ' '))
452
+ } else {
453
+ position.batches.forEach((_, b) => {
454
+ out.push(` subgraph W${position.index}B${b}[${label(`b${b}`)}]`)
455
+ for (const { l, i } of members.filter(({ l }) => l.batch === b)) out.push(node(l, i, ' '))
456
+ out.push(' end')
457
+ })
458
+ out.push(` ${position.batches.map((_, b) => `W${position.index}B${b}`).join(' --> ')}`)
459
+ }
460
+ out.push(' end')
461
+ }
462
+
463
+ const { texts } = verifyTexts(overlay, positions)
464
+ const chain = []
465
+ for (const position of positions) {
466
+ if (position.index > 0) {
467
+ const i = position.index - 1
468
+ chain.push(`V${i}{{${label([`verify after idx ${i}`, ...texts[i]].join(' · '))}}}`)
469
+ }
470
+ chain.push(`W${position.index}`)
471
+ }
472
+ chain.push(`VF[[${label('verify-final')}]]`, `C[[${label('commit')}]]`, `X[[${label('close')}]]`)
473
+ out.push(` ${chain.join(' --> ')}`)
474
+
475
+ const laneOf = new Map()
476
+ lanes.forEach((l, i) => {
477
+ for (const task of l.lane) if (!laneOf.has(task.id)) laneOf.set(task.id, ids[i])
478
+ })
479
+ const seen = new Set()
480
+ const deferred = list(isObject(plan) ? plan.deferred : null).filter(r => isObject(r) && isText(r.id))
481
+ for (const record of deferred) {
482
+ for (const after of list(record.after).filter(isText)) {
483
+ const from = laneOf.get(after)
484
+ const to = laneOf.get(record.id)
485
+ let line
486
+ if (!to) line = ` %% dependsOn ${record.id} ← ${after}: ${record.id} is in no lane of this plan`
487
+ else if (!from) line = ` %% dependsOn ${record.id} ← ${after}: ${after} is in no lane of this plan`
488
+ else if (from === to) continue
489
+ else line = ` ${from} -. dependsOn .-> ${to}`
490
+ if (seen.has(line)) continue
491
+ seen.add(line)
492
+ out.push(line)
493
+ }
494
+ }
495
+ return out
496
+ }