@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.
- package/.claude-plugin/plugin.json +1 -2
- package/CHANGELOG.md +64 -5
- package/bin/interlock +142 -14
- package/hooks/mod.mjs +1013 -85
- package/hooks/preflight.mjs +168 -34
- package/lib/draw-plan.mjs +516 -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 +2 -2
- package/skills/review-artifacts/SKILL.md +3 -3
- package/skills/spec/SKILL.md +5 -5
- /package/{.claude-plugin → assets}/icon.png +0 -0
|
@@ -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
|
+
}
|