plumbbob 0.13.0 → 0.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +75 -54
- package/dist/cli-core.js +29 -9
- package/dist/lib/buildlog.js +77 -0
- package/dist/lib/git.js +53 -0
- package/dist/lib/latch.js +46 -5
- package/dist/lib/notice.js +3 -2
- package/dist/lib/orient.js +878 -61
- package/dist/lib/sidecar.js +164 -3
- package/dist/lib/steplist.js +128 -0
- package/dist/verbs/build.js +108 -14
- package/dist/verbs/checkpoint.js +41 -13
- package/dist/verbs/handoff.js +508 -42
- package/dist/verbs/harvest.js +224 -0
- package/dist/verbs/order.js +15 -11
- package/dist/verbs/recover.js +31 -1
- package/dist/verbs/relay.js +15 -2
- package/dist/verbs/status.js +64 -17
- package/dist/verbs/turn.js +170 -58
- package/package.json +1 -1
- package/skills/build/SKILL.md +85 -34
- package/skills/finish/SKILL.md +18 -4
- package/skills/harvest/SKILL.md +54 -11
- package/skills/order/SKILL.md +20 -10
- package/skills/plan/SKILL.md +77 -17
- package/skills/refine/SKILL.md +24 -3
- package/skills/run/SKILL.md +256 -0
- package/skills/status/SKILL.md +10 -3
- package/skills/step/SKILL.md +25 -3
- package/skills/verify/SKILL.md +6 -2
- package/templates/intent.md +30 -8
package/dist/lib/orient.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// one suggested next move. Pure and best-effort by design: it takes raw file
|
|
4
4
|
// contents (no fs), and a malformed document degrades to fewer fields rather
|
|
5
5
|
// than throwing.
|
|
6
|
-
import { parseStepSeam, scopeDrift } from "./intent.js";
|
|
6
|
+
import { isArtifactPath, parseStepSeam, scopeDrift } from "./intent.js";
|
|
7
|
+
import { expandStepList, formatStepList, hasStep, normalizeStepList, parseStepList, readBuildArgs, } from "./steplist.js";
|
|
7
8
|
/**
|
|
8
9
|
* The lines of a named `## Section`, from its heading to the next `## ` (or
|
|
9
10
|
* EOF), with the offset of the first line so a caller can name an absolute
|
|
@@ -74,44 +75,50 @@ export function parseSteps(intent) {
|
|
|
74
75
|
return { n: s.n, done: s.done, title: s.title, planned: /done when/i.test(block), doneWhen, model, line: offset + s.idx + 1 };
|
|
75
76
|
});
|
|
76
77
|
}
|
|
77
|
-
// A build-order line: step numbers and
|
|
78
|
-
//
|
|
79
|
-
|
|
78
|
+
// A build-order line: step numbers and spans (`2-4`) joined by commas, with or
|
|
79
|
+
// without a space after each, and nothing else. A trailing comma is tolerated,
|
|
80
|
+
// since a hand edit leaves one behind.
|
|
81
|
+
const BUILD_ORDER_LINE = /^\d+(?:-\d+)?(?:\s*,\s*\d+(?:-\d+)?)*\s*,?$/;
|
|
80
82
|
/**
|
|
81
83
|
* The build order declared under `## Build order`: the step numbers on the
|
|
82
|
-
* first line that is nothing but numbers and commas, in the sequence
|
|
84
|
+
* first line that is nothing but numbers, spans, and commas, in the sequence
|
|
85
|
+
* written, each span spelled out in place (`1, 6, 2-4` reads 1, 6, 2, 3, 4).
|
|
83
86
|
*
|
|
84
87
|
* The line is the plan's own statement of what to build next once the
|
|
85
88
|
* numbering stops carrying it (a refined plan appends a step 7 that has to
|
|
86
89
|
* land before step 5), and `plumbbob order` is the verb that writes it. The
|
|
87
90
|
* template's guidance, a comment, and an absent section all read as `[]`,
|
|
88
|
-
* which every picker takes to mean document order
|
|
89
|
-
*
|
|
90
|
-
* `recover` wants the raw read to
|
|
91
|
+
* which every picker takes to mean document order, and so does a line holding
|
|
92
|
+
* a span that runs backward. Numbers come back verbatim, repeats and unknowns
|
|
93
|
+
* included: `orderSteps` does the cleaning, and `recover` wants the raw read to
|
|
94
|
+
* say which number the plan lacks. A span stops at the first number past the
|
|
95
|
+
* plan, which it keeps for `recover` to name.
|
|
91
96
|
*/
|
|
92
97
|
export function parseBuildOrder(intent) {
|
|
93
98
|
const line = sectionLines(intent, '## Build order')
|
|
94
99
|
.lines.map((l) => l.trim())
|
|
95
100
|
.find((l) => BUILD_ORDER_LINE.test(l));
|
|
96
|
-
|
|
101
|
+
const list = line === undefined ? null : parseStepList(line.replace(/\s+/g, '').replace(/,$/, ''));
|
|
102
|
+
if (list === null) {
|
|
97
103
|
return [];
|
|
98
104
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
.map((n) => n.trim())
|
|
102
|
-
.filter((n) => n.length > 0)
|
|
103
|
-
.map(Number);
|
|
105
|
+
const planned = new Set(parseSteps(intent).map((s) => s.n));
|
|
106
|
+
return expandStepList(list, (n) => planned.has(n));
|
|
104
107
|
}
|
|
105
108
|
/**
|
|
106
|
-
* The steps in build order: the
|
|
107
|
-
* then
|
|
109
|
+
* The steps in build order: the landed steps `order` leaves out, then the
|
|
110
|
+
* numbers it lists, in that sequence, then the undone steps it leaves out,
|
|
111
|
+
* each left-out group in document order.
|
|
108
112
|
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
113
|
+
* `plumbbob order` lists undone steps only, so a landed step the line leaves
|
|
114
|
+
* out landed before the line was written, and it reads as built ahead of the
|
|
115
|
+
* line rather than after it; the undone sequence is the same either way. A
|
|
116
|
+
* listed number takes the first step in document order that carries it, and a
|
|
117
|
+
* repeat of the number is ignored, so a doubled entry cannot double a step; a
|
|
118
|
+
* number no step carries is dropped. An order that names no step, an empty one
|
|
119
|
+
* included, is the identity, which is what a plan with no `## Build order`
|
|
120
|
+
* section reads as. The steps themselves never change, only the sequence, so
|
|
121
|
+
* `n`, `line`, and `done` stay what `parseSteps` read.
|
|
115
122
|
*/
|
|
116
123
|
export function orderSteps(steps, order) {
|
|
117
124
|
const taken = new Set();
|
|
@@ -123,7 +130,11 @@ export function orderSteps(steps, order) {
|
|
|
123
130
|
ordered.push(step);
|
|
124
131
|
}
|
|
125
132
|
}
|
|
126
|
-
|
|
133
|
+
const rest = steps.filter((s) => !taken.has(s));
|
|
134
|
+
if (ordered.length === 0) {
|
|
135
|
+
return rest;
|
|
136
|
+
}
|
|
137
|
+
return [...rest.filter((s) => s.done), ...ordered, ...rest.filter((s) => !s.done)];
|
|
127
138
|
}
|
|
128
139
|
/**
|
|
129
140
|
* The plan's steps in build order: `parseSteps` merged with `parseBuildOrder`.
|
|
@@ -156,10 +167,14 @@ export function remainingOrder(steps, order, exclude) {
|
|
|
156
167
|
return byOrder.some((n, i) => n !== byDocument[i]) ? byOrder : [];
|
|
157
168
|
}
|
|
158
169
|
/**
|
|
159
|
-
* The build order as
|
|
160
|
-
* rest folded to a count (`and 2 more`), the way
|
|
161
|
-
* every one-liner; '' for an empty order. With no
|
|
162
|
-
*
|
|
170
|
+
* The build order as the note's labeled field: `order:`, then the next `cap`
|
|
171
|
+
* step numbers in sequence, the rest folded to a count (`and 2 more`), the way
|
|
172
|
+
* a long detail degrades on every one-liner; '' for an empty order. With no
|
|
173
|
+
* cap, the whole sequence.
|
|
174
|
+
*
|
|
175
|
+
* A label, where the phrase once read `build order 7, 8, 5`, because the
|
|
176
|
+
* note's other segments read as labeled fields and a label is how the eye
|
|
177
|
+
* finds one in a line of three (D108 (order-label)).
|
|
163
178
|
*/
|
|
164
179
|
export function buildOrderPhrase(order, cap) {
|
|
165
180
|
if (order.length === 0) {
|
|
@@ -167,7 +182,7 @@ export function buildOrderPhrase(order, cap) {
|
|
|
167
182
|
}
|
|
168
183
|
const shown = cap === undefined ? order : order.slice(0, cap);
|
|
169
184
|
const rest = order.length - shown.length;
|
|
170
|
-
return `
|
|
185
|
+
return `order: ${shown.join(', ')}${rest > 0 ? ` (and ${rest} more)` : ''}`;
|
|
171
186
|
}
|
|
172
187
|
/**
|
|
173
188
|
* Write `order` as the one numeric line under `## Build order`: in place of
|
|
@@ -234,6 +249,509 @@ export function clearBuildOrder(intent) {
|
|
|
234
249
|
}
|
|
235
250
|
return lines.join('\n');
|
|
236
251
|
}
|
|
252
|
+
// A range's opener: the item's number, the command in backticks, a comma, and
|
|
253
|
+
// the stop's text from `**stop:**` to the end of the line.
|
|
254
|
+
const RANGE_OPENER = /^`([^`]*)`\s*,?\s*\*\*stop:\*\*\s*(.*)$/i;
|
|
255
|
+
// A `<...>` fill-in the template ships, still waiting for its words.
|
|
256
|
+
const FILL_IN = /<[^<>]+>/;
|
|
257
|
+
/**
|
|
258
|
+
* Parse the ranges under `## Build ranges`: one numbered item per range, the
|
|
259
|
+
* command in backticks, then `**stop:**` and the stop's text, then an optional
|
|
260
|
+
* `- model:` sub-line.
|
|
261
|
+
*
|
|
262
|
+
* The command's steps go through the turn hook's own reader, so the card can
|
|
263
|
+
* never suggest a command the hook reads another way. A paragraph before the
|
|
264
|
+
* list and any sub-line but the model are the reader's. An item still holding
|
|
265
|
+
* a `<...>` fill-in in its command or stop is scaffold, the template's
|
|
266
|
+
* placeholder waiting for words, and reads as neither a range nor a report; any
|
|
267
|
+
* other item that fails the grammar is reported by its number and never
|
|
268
|
+
* thrown. A plan without the section, a build's record from before ranges
|
|
269
|
+
* existed under another heading, and a section holding only the placeholder
|
|
270
|
+
* all read as no ranges.
|
|
271
|
+
*/
|
|
272
|
+
export function parseBuildRanges(intent) {
|
|
273
|
+
const { offset, lines } = sectionLines(intent, '## Build ranges');
|
|
274
|
+
const ranges = [];
|
|
275
|
+
const malformed = [];
|
|
276
|
+
const openers = lines.flatMap((l, idx) => {
|
|
277
|
+
const m = /^(\d+)\.\s+(.*)$/.exec(l);
|
|
278
|
+
return m === null ? [] : [{ item: Number(m[1]), text: m[2] ?? '', idx }];
|
|
279
|
+
});
|
|
280
|
+
openers.forEach((o, i) => {
|
|
281
|
+
const block = lines.slice(o.idx + 1, openers[i + 1]?.idx ?? lines.length);
|
|
282
|
+
const text = [o.text, ...wrappedLines(block)].join(' ').trim();
|
|
283
|
+
if (FILL_IN.test(text)) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const range = readRange(o.item, text, block, offset + o.idx + 1);
|
|
287
|
+
if (range === null) {
|
|
288
|
+
malformed.push(o.item);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
ranges.push(range);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
return { ranges, malformed };
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* The lines an item's opener wraps onto: each line after it, trimmed, up to the
|
|
298
|
+
* first blank line, sub-line, or heading.
|
|
299
|
+
*/
|
|
300
|
+
function wrappedLines(block) {
|
|
301
|
+
const end = block.findIndex((l) => l.trim() === '' || /^\s*[-*+]\s/.test(l) || l.startsWith('#'));
|
|
302
|
+
return block.slice(0, end === -1 ? block.length : end).map((l) => l.trim());
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* One item read as a range, or null when it is not one: a command other than
|
|
306
|
+
* `/plumbbob:build` with steps and nothing after them, a bare command naming
|
|
307
|
+
* more than one step, or a stop with no text.
|
|
308
|
+
*/
|
|
309
|
+
function readRange(item, text, block, line) {
|
|
310
|
+
const m = RANGE_OPENER.exec(text);
|
|
311
|
+
const args = /^\/plumbbob:build\s+([\d,\s-]+)$/.exec((m?.[1] ?? '').trim())?.[1];
|
|
312
|
+
const stop = (m?.[2] ?? '').trim();
|
|
313
|
+
if (args === undefined || stop === '') {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
const read = readBuildArgs(args);
|
|
317
|
+
const single = read.steps?.length === 1 && read.steps[0]?.from === read.steps[0]?.to;
|
|
318
|
+
if (read.steps === null || (!read.range && !single)) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
const model = /^\s*-\s*model:\s*(.+)$/im.exec(block.join('\n'))?.[1]?.trim() ?? null;
|
|
322
|
+
return {
|
|
323
|
+
item,
|
|
324
|
+
command: (m?.[1] ?? '').trim(),
|
|
325
|
+
steps: read.steps,
|
|
326
|
+
bare: !read.range,
|
|
327
|
+
stop,
|
|
328
|
+
model: model === null || FILL_IN.test(model) ? null : model,
|
|
329
|
+
line,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Where the build stands in its ranges, read from `step`: the range holding
|
|
334
|
+
* it, the command that runs what is left of that range, the range its landing
|
|
335
|
+
* completed, and every range the plan has outgrown.
|
|
336
|
+
*
|
|
337
|
+
* Nothing is stored. A range is landed once every step it names is done, and
|
|
338
|
+
* what is left of it is its undone steps from `step` on, in build order, so a
|
|
339
|
+
* step pause names what is left once the step in flight lands. A landing
|
|
340
|
+
* range down to one step writes it `9-9`, which lands it, where a bare range
|
|
341
|
+
* stays bare. `ordered` is the plan's steps in build order, landed ones
|
|
342
|
+
* included. With no range that parses, no step sits outside one, so a fresh
|
|
343
|
+
* build's placeholder flags nothing.
|
|
344
|
+
*/
|
|
345
|
+
export function rangeStanding(plan, ordered, step) {
|
|
346
|
+
const drift = rangeDrift(plan, ordered);
|
|
347
|
+
const count = plan.ranges.length + plan.malformed.length;
|
|
348
|
+
const at = ordered.findIndex((s) => s.n === step);
|
|
349
|
+
const holding = plan.ranges.find((r) => step !== null && hasStep(r.steps, step));
|
|
350
|
+
if (holding === undefined) {
|
|
351
|
+
const outside = step !== null && plan.ranges.length > 0 && ordered[at]?.done === false;
|
|
352
|
+
return { count, holding: null, command: null, flag: outside ? noRangeWords([step]) : null, completed: null, drift };
|
|
353
|
+
}
|
|
354
|
+
const flag = drift.find((d) => d.item === holding.item && d.kind !== 'malformed')?.words ?? null;
|
|
355
|
+
const left = at === -1 ? [] : ordered.slice(at).filter((s) => !s.done && hasStep(holding.steps, s.n));
|
|
356
|
+
return {
|
|
357
|
+
count,
|
|
358
|
+
holding,
|
|
359
|
+
command: flag === null && left.length > 0 ? `/plumbbob:build ${leftExpression(holding, left)}` : null,
|
|
360
|
+
flag,
|
|
361
|
+
completed: ordered[at]?.done === true && rangeLanded(holding, ordered) ? holding : null,
|
|
362
|
+
drift,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/** Whether a range has landed: the plan holds a step it names, and every one it holds is done. */
|
|
366
|
+
export function rangeLanded(range, ordered) {
|
|
367
|
+
const held = ordered.filter((s) => hasStep(range.steps, s.n));
|
|
368
|
+
return held.length > 0 && held.every((s) => s.done);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* The ranges block the dashboard renders, and the moves it suggests: the run
|
|
372
|
+
* of the range holding the next undone step, with what is left of it, and the
|
|
373
|
+
* stop card of the landed range `waiting` names.
|
|
374
|
+
*
|
|
375
|
+
* Null for a plan without ranges, so the block vanishes. The run's move is
|
|
376
|
+
* null when the next step sits in no range, its range is flagged, or the step
|
|
377
|
+
* is still unplanned, which leaves the dashboard's own step move in charge.
|
|
378
|
+
* The card's move is null unless `waiting` names a range that parses and
|
|
379
|
+
* reads as landed, so a range re-cut to take a new step stops waiting.
|
|
380
|
+
*/
|
|
381
|
+
function rangesView(intent, ordered, next, waiting) {
|
|
382
|
+
const plan = parseBuildRanges(intent);
|
|
383
|
+
const standing = rangeStanding(plan, ordered, next?.n ?? null);
|
|
384
|
+
if (standing.count === 0) {
|
|
385
|
+
return { view: null, move: null, waitingMove: null };
|
|
386
|
+
}
|
|
387
|
+
const own = (item) => standing.drift.filter((d) => d.item === item && d.kind !== 'malformed');
|
|
388
|
+
const rows = plan.ranges.map((range) => {
|
|
389
|
+
const landed = rangeLanded(range, ordered);
|
|
390
|
+
return { range, landed, next: range === standing.holding, waiting: landed && range.item === waiting, drift: own(range.item).map((d) => d.words) };
|
|
391
|
+
});
|
|
392
|
+
const view = { count: standing.count, rows, plan: standing.drift.filter((d) => d.item === null || d.kind === 'malformed').map((d) => d.words) };
|
|
393
|
+
const run = resolveRun({ intent, inFlight: null, spiking: false, item: null });
|
|
394
|
+
const card = rows.find((r) => r.waiting);
|
|
395
|
+
return {
|
|
396
|
+
view,
|
|
397
|
+
move: !run.ok || next?.planned !== true ? null : runMove(run),
|
|
398
|
+
waitingMove: card === undefined ? null : stopCardMove(card.range.item, standing.count, stopFollowUp(intent)),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* The dashboard's pointer while a stop card waits: the card, and in its
|
|
403
|
+
* bracket the reply that records it from any session, the next range's run or
|
|
404
|
+
* the finish on the plan's last range, or the way back to the card when the
|
|
405
|
+
* next run would refuse. `looks good` is never named, since it records only
|
|
406
|
+
* with the card in the conversation.
|
|
407
|
+
*/
|
|
408
|
+
function stopCardMove(item, count, follow) {
|
|
409
|
+
const card = `reply to range ${item} of ${count}'s stop card`;
|
|
410
|
+
if (follow === null) {
|
|
411
|
+
return `${card} (\`show the stop\` brings it back)`;
|
|
412
|
+
}
|
|
413
|
+
return follow === 'finish'
|
|
414
|
+
? `${card} (\`/plumbbob:finish\` records it, then writes the report)`
|
|
415
|
+
: `${card} (\`/plumbbob:run\` records it, then runs range ${follow.range.item} of ${follow.count})`;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* What is left of a range as the steps of a command: a landing range's last
|
|
419
|
+
* step written `9-9` so the command lands it, else the steps in build order
|
|
420
|
+
* with each climbing run folded to a span, a bare range's lone number included.
|
|
421
|
+
*/
|
|
422
|
+
function leftExpression(range, left) {
|
|
423
|
+
const ns = left.map((s) => s.n);
|
|
424
|
+
return !range.bare && ns.length === 1 ? `${ns[0]}-${ns[0]}` : formatStepList(ns);
|
|
425
|
+
}
|
|
426
|
+
/** The words for steps no range names: `no range names step 12`, or `steps 11, 12`. */
|
|
427
|
+
function noRangeWords(steps) {
|
|
428
|
+
return `no range names ${stepPhrase(steps.map(String)).words}`;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Every report on the ranges, in the order the dashboard lists them: each
|
|
432
|
+
* range's own (a step the plan lacks, a step another range also names, a step
|
|
433
|
+
* from outside it the build order puts among its own), then the undone steps
|
|
434
|
+
* no range names, then the items that do not parse.
|
|
435
|
+
*
|
|
436
|
+
* A step the build order puts inside a range, and a step no range names,
|
|
437
|
+
* count only while undone, since a landed step cannot cut a run short. The
|
|
438
|
+
* coverage check runs only once a real range parses.
|
|
439
|
+
*/
|
|
440
|
+
function rangeDrift(plan, ordered) {
|
|
441
|
+
const own = plan.ranges.flatMap((r) => [
|
|
442
|
+
...missingDrift(r, ordered),
|
|
443
|
+
...sharedDrift(r, plan.ranges, ordered),
|
|
444
|
+
...insideDrift(r, ordered),
|
|
445
|
+
]);
|
|
446
|
+
const outside = ordered.filter((s) => !s.done && !plan.ranges.some((r) => hasStep(r.steps, s.n))).map((s) => s.n);
|
|
447
|
+
const uncovered = plan.ranges.length > 0 && outside.length > 0 ? [{ kind: 'uncovered', item: null, words: noRangeWords(outside) }] : [];
|
|
448
|
+
const malformed = plan.malformed.map((item) => ({ kind: 'malformed', item, words: `item ${item} under Build ranges does not parse` }));
|
|
449
|
+
return [...own, ...uncovered, ...malformed];
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* A range naming steps the plan lacks, reported as the spans it names past
|
|
453
|
+
* the plan's steps, so a span of any width is read without being counted out.
|
|
454
|
+
*/
|
|
455
|
+
function missingDrift(range, ordered) {
|
|
456
|
+
const held = ordered.map((s) => s.n).sort((a, b) => a - b);
|
|
457
|
+
const gaps = [];
|
|
458
|
+
for (const span of range.steps) {
|
|
459
|
+
let from = span.from;
|
|
460
|
+
for (const n of held.filter((h) => h >= span.from && h <= span.to)) {
|
|
461
|
+
if (n > from) {
|
|
462
|
+
gaps.push({ from, to: n - 1 });
|
|
463
|
+
}
|
|
464
|
+
from = Math.max(from, n + 1);
|
|
465
|
+
}
|
|
466
|
+
if (from <= span.to) {
|
|
467
|
+
gaps.push({ from, to: span.to });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (gaps.length === 0) {
|
|
471
|
+
return [];
|
|
472
|
+
}
|
|
473
|
+
const { words, be } = stepPhrase(normalizeStepList(gaps).split(','));
|
|
474
|
+
return [{ kind: 'missing', item: range.item, words: `${words} ${be} not in the plan` }];
|
|
475
|
+
}
|
|
476
|
+
/** A range sharing steps with another range, one report for each other range. */
|
|
477
|
+
function sharedDrift(range, ranges, ordered) {
|
|
478
|
+
const mine = ordered.filter((s) => hasStep(range.steps, s.n));
|
|
479
|
+
return ranges.flatMap((other) => {
|
|
480
|
+
const both = other === range ? [] : mine.filter((s) => hasStep(other.steps, s.n)).map((s) => String(s.n));
|
|
481
|
+
if (both.length === 0) {
|
|
482
|
+
return [];
|
|
483
|
+
}
|
|
484
|
+
const { words, be } = stepPhrase(both);
|
|
485
|
+
return [{ kind: 'shared', item: range.item, words: `${words} ${be} also in range ${other.item}` }];
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* A range the build order cuts short: an undone step it does not name, placed
|
|
490
|
+
* between its first step and its last undone one, where a run of the range
|
|
491
|
+
* halts.
|
|
492
|
+
*
|
|
493
|
+
* The span opens at the range's first step, landed or not, so a range a run
|
|
494
|
+
* halted inside stays flagged until the step that halted it lands. It ends at
|
|
495
|
+
* the range's last undone step, since a step placed after every one of them
|
|
496
|
+
* cuts nothing short, and a range with none left reports nothing. An outside
|
|
497
|
+
* step ahead of the range's first step builds first and cuts nothing, and the
|
|
498
|
+
* card names its range before this one.
|
|
499
|
+
*/
|
|
500
|
+
function insideDrift(range, ordered) {
|
|
501
|
+
const first = ordered.findIndex((s) => hasStep(range.steps, s.n));
|
|
502
|
+
const last = ordered.findLastIndex((s) => !s.done && hasStep(range.steps, s.n));
|
|
503
|
+
const inside = last === -1 ? [] : ordered.slice(first + 1, last).filter((s) => !s.done && !hasStep(range.steps, s.n));
|
|
504
|
+
if (inside.length === 0) {
|
|
505
|
+
return [];
|
|
506
|
+
}
|
|
507
|
+
const words = stepPhrase(inside.map((s) => String(s.n))).words;
|
|
508
|
+
return [{ kind: 'inside', item: range.item, words: `the build order puts ${words} inside it` }];
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Steps as a phrase, with the verb that agrees: `step 12` and `is`, or
|
|
512
|
+
* `steps 11, 12` and `are`. Each item is a number or a span as it is typed.
|
|
513
|
+
*/
|
|
514
|
+
function stepPhrase(items) {
|
|
515
|
+
const one = items.length === 1 && /^\d+$/.test(items[0] ?? '');
|
|
516
|
+
return { words: `${one ? 'step' : 'steps'} ${items.join(', ')}`, be: one ? 'is' : 'are' };
|
|
517
|
+
}
|
|
518
|
+
// --- The run: what `/plumbbob:run` names. The turn hook reads it to mint the
|
|
519
|
+
// run's grant, `status --run` to print the run header, and the card to name the
|
|
520
|
+
// run beneath Next Up, so all three read one answer from one plan. ---
|
|
521
|
+
/**
|
|
522
|
+
* The range a run's arguments name, read from the one token after the verb:
|
|
523
|
+
* a range number names that range, and anything else, nothing included, names
|
|
524
|
+
* the range holding the next step.
|
|
525
|
+
*
|
|
526
|
+
* `--auto` beside the verb adds nothing, so it is passed over. Numbers joined
|
|
527
|
+
* by a dash or a comma are a span or a list of ranges (`2-4`, `2, 3`), a form
|
|
528
|
+
* no run defines, and read as null, so nothing is minted or resolved for them.
|
|
529
|
+
* Trailing sentence punctuation is the sentence's, and so is a comma after the
|
|
530
|
+
* last number (`/plumbbob:run 3, then review`), as it is after a build's.
|
|
531
|
+
*/
|
|
532
|
+
export function readRunArgs(text) {
|
|
533
|
+
const words = [];
|
|
534
|
+
for (const token of text.split(/\s+/)) {
|
|
535
|
+
const word = token.replace(/[.;:!?]+$/, '');
|
|
536
|
+
if (word === '' || word === '--auto') {
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (!/^[\d,-]+$/.test(word)) {
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
words.push(word);
|
|
543
|
+
}
|
|
544
|
+
const run = words.join(' ').replace(/,+$/, '');
|
|
545
|
+
if (/[,-]/.test(run)) {
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
return { item: run === '' ? null : Number(run.split(' ')[0]) };
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* The range a run names and what is left of it, or the refusal that says why
|
|
552
|
+
* it names nothing it can walk.
|
|
553
|
+
*
|
|
554
|
+
* A step in flight decides the run: with no number typed, the run names the
|
|
555
|
+
* range holding that step, or with none in flight the range holding the next
|
|
556
|
+
* undone step in build order; a number names that range, and refuses when the
|
|
557
|
+
* range does not hold the step in flight. What is left counts the step in
|
|
558
|
+
* flight as undone and leads with it, since a run that started elsewhere would
|
|
559
|
+
* rewrite the step markers and sweep its half-built diff into another step's
|
|
560
|
+
* commit. A spike open refuses every run, and nothing left refuses before the
|
|
561
|
+
* ranges are read. The ranges are read through `rangeStanding`, so the run
|
|
562
|
+
* and the card name the same range and the same drift.
|
|
563
|
+
*/
|
|
564
|
+
export function resolveRun(input) {
|
|
565
|
+
if (input.spiking) {
|
|
566
|
+
return { ok: false, kind: 'spike' };
|
|
567
|
+
}
|
|
568
|
+
const ordered = parseOrderedSteps(input.intent);
|
|
569
|
+
const anchor = input.inFlight ?? ordered.find((s) => !s.done)?.n ?? null;
|
|
570
|
+
if (anchor === null) {
|
|
571
|
+
return { ok: false, kind: 'finished' };
|
|
572
|
+
}
|
|
573
|
+
const plan = parseBuildRanges(input.intent);
|
|
574
|
+
const standing = rangeStanding(plan, ordered, anchor);
|
|
575
|
+
const { count } = standing;
|
|
576
|
+
if (count === 0) {
|
|
577
|
+
return { ok: false, kind: 'no-ranges', step: anchor };
|
|
578
|
+
}
|
|
579
|
+
if (input.item === null) {
|
|
580
|
+
const { holding } = standing;
|
|
581
|
+
// With no range that parses, no step sits outside one, so the words are
|
|
582
|
+
// the first item's that does not.
|
|
583
|
+
const words = standing.flag ?? standing.drift.find((d) => d.kind === 'malformed')?.words ?? noRangeWords([anchor]);
|
|
584
|
+
return holding === null
|
|
585
|
+
? { ok: false, kind: 'uncovered', step: anchor, words }
|
|
586
|
+
: walkRun(holding, standing, ordered, input.inFlight);
|
|
587
|
+
}
|
|
588
|
+
const item = input.item;
|
|
589
|
+
const range = plan.ranges.find((r) => r.item === item);
|
|
590
|
+
if (range === undefined) {
|
|
591
|
+
const malformed = standing.drift.find((d) => d.kind === 'malformed' && d.item === item);
|
|
592
|
+
return malformed === undefined
|
|
593
|
+
? { ok: false, kind: 'missing', item, instead: resolveRun({ ...input, item: null }) }
|
|
594
|
+
: { ok: false, kind: 'flagged', item, count, words: malformed.words };
|
|
595
|
+
}
|
|
596
|
+
if (rangeLanded(range, ordered)) {
|
|
597
|
+
return { ok: false, kind: 'landed', item, count, instead: resolveRun({ ...input, item: null }) };
|
|
598
|
+
}
|
|
599
|
+
if (input.inFlight !== null && !hasStep(range.steps, input.inFlight)) {
|
|
600
|
+
return { ok: false, kind: 'in-flight', step: input.inFlight, holding: standing.holding?.item ?? null, count };
|
|
601
|
+
}
|
|
602
|
+
return walkRun(range, standing, ordered, input.inFlight);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* A range as a run walks it: what is left of it, led by the step in flight,
|
|
606
|
+
* and the grant the hook mints; or the flagged refusal when the range's own
|
|
607
|
+
* drift names it.
|
|
608
|
+
*/
|
|
609
|
+
function walkRun(range, standing, ordered, inFlight) {
|
|
610
|
+
const flag = standing.drift.find((d) => d.item === range.item && d.kind !== 'malformed');
|
|
611
|
+
if (flag !== undefined) {
|
|
612
|
+
return { ok: false, kind: 'flagged', item: range.item, count: standing.count, words: flag.words };
|
|
613
|
+
}
|
|
614
|
+
const undone = ordered.filter((s) => hasStep(range.steps, s.n) && (!s.done || s.n === inFlight)).map((s) => s.n);
|
|
615
|
+
const left = inFlight !== null && undone.includes(inFlight) ? [inFlight, ...undone.filter((n) => n !== inFlight)] : undone;
|
|
616
|
+
return {
|
|
617
|
+
ok: true,
|
|
618
|
+
range,
|
|
619
|
+
count: standing.count,
|
|
620
|
+
left,
|
|
621
|
+
steps: formatStepList(left),
|
|
622
|
+
grant: range.bare ? null : `steps ${normalizeStepList(left.map((n) => ({ from: n, to: n })))}`,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* The run a skill invocation's raw arguments name, read as the turn hook reads
|
|
627
|
+
* them: `readRunArgs`, then `resolveRun`. A span or a list of ranges names no
|
|
628
|
+
* run, and refuses with the run a bare `/plumbbob:run` names in its place.
|
|
629
|
+
*/
|
|
630
|
+
export function resolveRunArgs(text, build) {
|
|
631
|
+
const args = readRunArgs(text);
|
|
632
|
+
if (args === null) {
|
|
633
|
+
return { ok: false, kind: 'span', instead: resolveRun({ ...build, item: null }) };
|
|
634
|
+
}
|
|
635
|
+
return resolveRun({ ...build, item: args.item });
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* What is left of a run, as its bracket and its header write it: `steps 6-9`,
|
|
639
|
+
* `steps 20,5-9`, or `step 9` for one step.
|
|
640
|
+
*/
|
|
641
|
+
export function runSteps(run) {
|
|
642
|
+
return `${run.left.length === 1 ? 'step' : 'steps'} ${run.steps}`;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* What the card's bracket says the run lands: what is left of it, and for a
|
|
646
|
+
* bare range that it builds the step to its pause (`step 10 to its pause`).
|
|
647
|
+
*/
|
|
648
|
+
export function runBracket(run) {
|
|
649
|
+
return run.range.bare ? `${runSteps(run)} to its pause` : runSteps(run);
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* The move after a stop, the one the stop card's second row, the dashboard's
|
|
653
|
+
* waiting pointer, and `plumbbob build`'s refusal all name: the run a bare
|
|
654
|
+
* `/plumbbob:run` names, `'finish'` once no step is left, or null when the
|
|
655
|
+
* next run would refuse, where a reply records the card and nothing more.
|
|
656
|
+
*/
|
|
657
|
+
export function stopFollowUp(intent) {
|
|
658
|
+
const run = resolveRun({ intent, inFlight: null, spiking: false, item: null });
|
|
659
|
+
if (run.ok) {
|
|
660
|
+
return run;
|
|
661
|
+
}
|
|
662
|
+
return run.kind === 'finished' ? 'finish' : null;
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* The pointer naming a run: the range and its count, the run's command, and
|
|
666
|
+
* the bracket, with a note beside the steps when one is given.
|
|
667
|
+
*/
|
|
668
|
+
export function runMove(run, note) {
|
|
669
|
+
return `run range ${run.range.item} of ${run.count} — \`/plumbbob:run\` (${runBracket(run)}${note === undefined ? '' : `; ${note}`})`;
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* The dashboard's pointer for a run that names nothing it can walk, in the
|
|
673
|
+
* dashboard's own register: the refusal and the move that unblocks it. Null
|
|
674
|
+
* for nothing left, whose move is the dashboard's own ending, which a run adds
|
|
675
|
+
* nothing to.
|
|
676
|
+
*/
|
|
677
|
+
export function runRefusalMove(refusal) {
|
|
678
|
+
const recut = 're-cut the ranges — `/plumbbob:refine ranges`';
|
|
679
|
+
switch (refusal.kind) {
|
|
680
|
+
case 'spike':
|
|
681
|
+
return 'close the spike — `plumbbob spike done`';
|
|
682
|
+
case 'finished':
|
|
683
|
+
return null;
|
|
684
|
+
case 'no-ranges':
|
|
685
|
+
return `cut the ranges — \`/plumbbob:refine ranges\` (the plan declares none; \`/plumbbob:build\` builds step ${refusal.step} alone)`;
|
|
686
|
+
case 'uncovered':
|
|
687
|
+
return `${recut} (${refusal.words})`;
|
|
688
|
+
case 'flagged':
|
|
689
|
+
return `${recut} (range ${refusal.item} of ${refusal.count} needs a re-cut: ${refusal.words})`;
|
|
690
|
+
case 'landed':
|
|
691
|
+
return insteadMove(refusal.instead, `range ${refusal.item} of ${refusal.count} has landed`);
|
|
692
|
+
case 'missing':
|
|
693
|
+
return insteadMove(refusal.instead, `the plan has no range ${refusal.item}`);
|
|
694
|
+
case 'span':
|
|
695
|
+
return insteadMove(refusal.instead, 'a run names one range');
|
|
696
|
+
case 'in-flight': {
|
|
697
|
+
const holds = refusal.holding === null ? 'no range names it' : `a run names range ${refusal.holding} of ${refusal.count}, which holds it`;
|
|
698
|
+
return `finish step ${refusal.step} — \`/plumbbob:verify\` (or \`/plumbbob:abandon\` it; ${holds})`;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* The pointer at the run a bare `/plumbbob:run` names, carrying why the typed
|
|
704
|
+
* one was refused, or that run's own refusal when it names nothing either.
|
|
705
|
+
*/
|
|
706
|
+
function insteadMove(instead, note) {
|
|
707
|
+
return instead.ok ? runMove(instead, note) : runRefusalMove(instead);
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* The three-line header `status --run` prints above the dashboard: the range,
|
|
711
|
+
* what is left of it, and where the run ends; its stop; and the grant the
|
|
712
|
+
* hook minted, held against the one the run would mint.
|
|
713
|
+
*
|
|
714
|
+
* `grant` is the GRANT file's content in its normal form, or null when none
|
|
715
|
+
* stands. The grant line is the one the run skill trusts over the two above
|
|
716
|
+
* it, since the hook read the plan the human typed against, so it says when
|
|
717
|
+
* the two differ rather than choosing between them.
|
|
718
|
+
*/
|
|
719
|
+
export function formatRunHeader(run, grant) {
|
|
720
|
+
const last = run.left.at(-1) ?? run.range.steps[0]?.from;
|
|
721
|
+
const ends = run.range.bare ? `ends at step ${last}'s pause` : `ends at step ${last}'s boundary`;
|
|
722
|
+
return [
|
|
723
|
+
`run range ${run.range.item} of ${run.count} · ${runSteps(run)} · ${ends}`,
|
|
724
|
+
` stop: ${run.range.stop}`,
|
|
725
|
+
` grant: ${grantWords(run, grant)}`,
|
|
726
|
+
].join('\n');
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* The grant line's value: the grant as the hook minted it, `none` with its
|
|
730
|
+
* reason, or the grant marked unlike the run.
|
|
731
|
+
*/
|
|
732
|
+
function grantWords(run, grant) {
|
|
733
|
+
if (grant === null) {
|
|
734
|
+
return run.grant === null ? 'none, as a bare range builds to its pause' : 'none';
|
|
735
|
+
}
|
|
736
|
+
return grant === run.grant ? grant : `${grant}, unlike the run`;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* The dashboard `status --run` prints for a run: beneath the run header, with
|
|
740
|
+
* the run's first step marked `← requested` and the pointer naming the run;
|
|
741
|
+
* or, for a run that names nothing it can walk, the dashboard as it renders
|
|
742
|
+
* without the flag, its pointer carrying the refusal. Nothing left keeps the
|
|
743
|
+
* dashboard's own pointer, which names the stop card while one waits on the
|
|
744
|
+
* plan's last range, its bracket the finish that records it.
|
|
745
|
+
*/
|
|
746
|
+
export function formatRunDashboard(input, run, grant) {
|
|
747
|
+
if (!run.ok) {
|
|
748
|
+
const plain = orient({ ...input, requested: null });
|
|
749
|
+
return formatOrientation({ ...plain, next: runRefusalMove(run) ?? plain.next });
|
|
750
|
+
}
|
|
751
|
+
const first = run.left[0] ?? null;
|
|
752
|
+
const repointed = orient({ ...input, requested: first === null ? null : [{ from: first, to: first }] });
|
|
753
|
+
return `${formatRunHeader(run, grant)}\n\n${formatOrientation({ ...repointed, next: runMove(run) })}`;
|
|
754
|
+
}
|
|
237
755
|
/**
|
|
238
756
|
* Flip step N's `[ ]` checkbox to `[x]` within the `## Steps` section.
|
|
239
757
|
*
|
|
@@ -365,27 +883,29 @@ export function lastLedgerSha(checkpoints) {
|
|
|
365
883
|
return last;
|
|
366
884
|
}
|
|
367
885
|
/**
|
|
368
|
-
* The
|
|
369
|
-
*
|
|
886
|
+
* The steps an explicit skill invocation asks for, out of the raw argument
|
|
887
|
+
* text `status --invoked` receives, read the way the turn hook reads a typed
|
|
888
|
+
* range.
|
|
370
889
|
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
* dashboard's own suggestion in charge. Null in
|
|
375
|
-
* never substitutes the invocation degrades to
|
|
890
|
+
* A step number names one step, and a range or a list names the steps it
|
|
891
|
+
* holds (`orient` starts it at the first undone one in build order); `--auto`
|
|
892
|
+
* alone, a range that does not parse, and free text parse to null, meaning "no
|
|
893
|
+
* explicit ask", which leaves the dashboard's own suggestion in charge. Null in
|
|
894
|
+
* means null out, so a host that never substitutes the invocation degrades to
|
|
895
|
+
* today's rendering.
|
|
376
896
|
*/
|
|
377
897
|
export function parseRequestedStep(raw) {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
return null;
|
|
898
|
+
return raw === null ? null : readBuildArgs(raw.trim()).steps;
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* The step a requested list starts at: its first undone step in build order,
|
|
902
|
+
* however the list was written; its first step in build order when every one
|
|
903
|
+
* has landed; or its first number written when the plan holds none of them, so
|
|
904
|
+
* the dashboard reports that number missing. Null for an empty list.
|
|
905
|
+
*/
|
|
906
|
+
export function rangeStart(ordered, list) {
|
|
907
|
+
const named = ordered.filter((s) => hasStep(list, s.n));
|
|
908
|
+
return (named.find((s) => !s.done) ?? named[0])?.n ?? list[0]?.from ?? null;
|
|
389
909
|
}
|
|
390
910
|
/**
|
|
391
911
|
* The single primary next move the dashboard suggests.
|
|
@@ -454,15 +974,26 @@ export function orient(input) {
|
|
|
454
974
|
const order = parseBuildOrder(input.intent);
|
|
455
975
|
const ordered = orderSteps(steps, order);
|
|
456
976
|
const parks = parseParkTotals(input.buildLog);
|
|
977
|
+
// The step an explicit ask points at: the number typed, or where a typed
|
|
978
|
+
// range starts in build order.
|
|
979
|
+
const requested = input.requested === null ? null : rangeStart(ordered, input.requested);
|
|
457
980
|
// The target the dashboard details: the explicitly requested step when it
|
|
458
981
|
// exists in the plan, else the next undone. A requested number that names no
|
|
459
982
|
// planned step carries nothing here; the next-move line reports the mismatch
|
|
460
983
|
// while the rest of the dashboard renders as usual.
|
|
461
|
-
const requestedStep =
|
|
984
|
+
const requestedStep = requested === null ? undefined : steps.find((s) => s.n === requested);
|
|
462
985
|
const nextUndone = ordered.find((s) => !s.done);
|
|
463
986
|
const target = requestedStep ?? nextUndone;
|
|
464
987
|
const seamParse = target === undefined ? null : parseStepSeam(input.intent, target.n);
|
|
465
988
|
const phase = input.spiking ? 'SPIKE' : input.inFlight !== null ? 'BUILD' : 'DESIGN';
|
|
989
|
+
// At the boundary with nothing asked for, the move names the range holding
|
|
990
|
+
// the next step; a spike, a step in flight, or a request keeps its own. A
|
|
991
|
+
// stop card waiting for its reply outranks the run and a request, since a
|
|
992
|
+
// step entered now would write over the range's review; a spike and a step
|
|
993
|
+
// in flight still keep theirs.
|
|
994
|
+
const ranges = rangesView(input.intent, ordered, nextUndone, input.waiting);
|
|
995
|
+
const idle = !input.spiking && input.inFlight === null;
|
|
996
|
+
const atBoundary = idle && requested === null;
|
|
466
997
|
return {
|
|
467
998
|
title: parseTitle(input.intent),
|
|
468
999
|
phase,
|
|
@@ -471,9 +1002,12 @@ export function orient(input) {
|
|
|
471
1002
|
parked: parks.open,
|
|
472
1003
|
parkedTotal: parks.total,
|
|
473
1004
|
openQuestions: parseOpenQuestions(input.intent),
|
|
474
|
-
next:
|
|
1005
|
+
next: (idle ? ranges.waitingMove : null) ??
|
|
1006
|
+
(atBoundary ? ranges.move : null) ??
|
|
1007
|
+
nextMove(input.spiking, ordered, input.inFlight, parks.open, requested),
|
|
475
1008
|
nextUndone: nextUndone?.n ?? null,
|
|
476
1009
|
buildOrder: remainingOrder(steps, order, null),
|
|
1010
|
+
ranges: ranges.view,
|
|
477
1011
|
requested: requestedStep?.n ?? null,
|
|
478
1012
|
nextDoneWhen: target?.doneWhen ?? null,
|
|
479
1013
|
nextSeam: seamParse !== null && seamParse.ok ? seamParse.seam : [],
|
|
@@ -520,19 +1054,24 @@ function classifyRecapRow(name, value) {
|
|
|
520
1054
|
* `── recap · step N of M ──` header, the (subset of) five measuring rows
|
|
521
1055
|
* present, and the free-text diff row. Null when no header is found.
|
|
522
1056
|
*
|
|
1057
|
+
* A range's detail opens its rows under `── recap · range N of M ──` instead,
|
|
1058
|
+
* and only the `range` scope reads that header, so a step pause never takes a
|
|
1059
|
+
* stop's judgment rows for its own, and the stop card never takes a step's.
|
|
1060
|
+
*
|
|
523
1061
|
* A row absent from the block is simply missing from `rows`: a vanished row
|
|
524
1062
|
* the fold treats exactly like one that never applied, never like a failure.
|
|
525
1063
|
* An indented `- ` or `→ ` line attaches to the row above it, which is how a
|
|
526
1064
|
* row carrying two or more items, or a pointer at its evidence, survives the
|
|
527
1065
|
* round trip through the file.
|
|
528
1066
|
*/
|
|
529
|
-
export function parseRecap(detail) {
|
|
1067
|
+
export function parseRecap(detail, scope = 'step') {
|
|
530
1068
|
const lines = detail.split('\n');
|
|
531
|
-
const
|
|
1069
|
+
const opener = new RegExp(`^──\\s*recap\\s*·\\s*${scope}\\s+\\d+\\s+of\\s+\\d+\\s*──$`);
|
|
1070
|
+
const headerIdx = lines.findIndex((l) => opener.test(l.trim()));
|
|
532
1071
|
if (headerIdx === -1) {
|
|
533
1072
|
return null;
|
|
534
1073
|
}
|
|
535
|
-
const header =
|
|
1074
|
+
const header = new RegExp(`${scope}\\s+(\\d+)\\s+of\\s+(\\d+)`).exec(lines[headerIdx] ?? '');
|
|
536
1075
|
if (header === null) {
|
|
537
1076
|
return null;
|
|
538
1077
|
}
|
|
@@ -571,7 +1110,7 @@ export function parseRecap(detail) {
|
|
|
571
1110
|
}
|
|
572
1111
|
}
|
|
573
1112
|
}
|
|
574
|
-
return {
|
|
1113
|
+
return { n: Number(header[1]), total: Number(header[2]), rows, diff };
|
|
575
1114
|
}
|
|
576
1115
|
const PLUMB = { glyph: '●', state: 'Plumb' };
|
|
577
1116
|
const A_HAIR_OFF = { glyph: '◐', state: 'A hair off' };
|
|
@@ -589,8 +1128,13 @@ const NOT_STANDING = { glyph: '✗', state: 'Not standing' };
|
|
|
589
1128
|
* carries the stray; at the boundary the tree is clean, the row has vanished,
|
|
590
1129
|
* and the stat the checkpoint bumped carries it instead. Both name it
|
|
591
1130
|
* `seam strayed`, so the rung does not change between the two.
|
|
1131
|
+
*
|
|
1132
|
+
* A stop card folds a range the same way, its stats summed across the steps,
|
|
1133
|
+
* and only the revert's words change with the scope (`1 revert in the
|
|
1134
|
+
* range`), since the other advisories already read across whatever they
|
|
1135
|
+
* count.
|
|
592
1136
|
*/
|
|
593
|
-
export function foldVerdict(rows, stats) {
|
|
1137
|
+
export function foldVerdict(rows, stats, scope = 'step') {
|
|
594
1138
|
for (const name of RECAP_ROW_NAMES) {
|
|
595
1139
|
if (rows[name]?.verdict === 'drift') {
|
|
596
1140
|
return { ladder: NOT_STANDING, worst: `${name} drifted` };
|
|
@@ -618,7 +1162,8 @@ export function foldVerdict(rows, stats) {
|
|
|
618
1162
|
return { ladder: A_HAIR_OFF, worst: `${stats.redChecks} red run${stats.redChecks === 1 ? '' : 's'} before green` };
|
|
619
1163
|
}
|
|
620
1164
|
if (stats.reverts > 0) {
|
|
621
|
-
|
|
1165
|
+
const where = scope === 'range' ? 'in the range' : 'on this step';
|
|
1166
|
+
return { ladder: A_HAIR_OFF, worst: `${stats.reverts} revert${stats.reverts === 1 ? '' : 's'} ${where}` };
|
|
622
1167
|
}
|
|
623
1168
|
if (stats.outOfBand > 0) {
|
|
624
1169
|
return { ladder: A_HAIR_OFF, worst: `${stats.outOfBand} commit${stats.outOfBand === 1 ? '' : 's'} outside the ledger` };
|
|
@@ -645,15 +1190,20 @@ const CHECK_SUMMARY = '.check/summary.json';
|
|
|
645
1190
|
* Red expands where green collapses: one failing slot is named in the row and
|
|
646
1191
|
* its raw output is the `→` pointer, and two or more collapse to a count with
|
|
647
1192
|
* one `- ` line per slot.
|
|
1193
|
+
*
|
|
1194
|
+
* A stop card passes the range's `landings`, and a green row counts them
|
|
1195
|
+
* behind the gate's size: every landing passed the gate, since the checkpoint
|
|
1196
|
+
* refuses on red.
|
|
648
1197
|
*/
|
|
649
|
-
export function summaryCheckRow(summary) {
|
|
1198
|
+
export function summaryCheckRow(summary, landings = 0) {
|
|
650
1199
|
const ran = summary.checks.filter((c) => c.skipped !== true);
|
|
651
1200
|
const skipped = summary.checks.filter((c) => c.skipped === true).map((c) => c.name);
|
|
652
1201
|
const named = skipped.length === 0 ? '' : ` · without ${skipped.join(', ')}`;
|
|
653
1202
|
const counted = skipped.length === 0 ? '' : ` · without ${skipped.length} others`;
|
|
654
1203
|
const fit = (long, short) => (long.length <= RECAP_VALUE_BUDGET ? long : short);
|
|
655
1204
|
if (summary.ok) {
|
|
656
|
-
const
|
|
1205
|
+
const landed = landings === 0 ? '' : `, ${landings} landing${landings === 1 ? '' : 's'}`;
|
|
1206
|
+
const size = `${ran.length} of ${summary.checks.length} checks${landed}`;
|
|
657
1207
|
return { verdict: 'true', word: 'green', evidence: fit(`green: ${size}${named}`, `green: ${size}${counted}`) };
|
|
658
1208
|
}
|
|
659
1209
|
const failing = ran.filter((c) => !c.ok);
|
|
@@ -770,8 +1320,16 @@ function elapsedSpent(input) {
|
|
|
770
1320
|
return null;
|
|
771
1321
|
}
|
|
772
1322
|
const landed = input.landedAt === undefined ? Number.NaN : Date.parse(input.landedAt);
|
|
773
|
-
|
|
774
|
-
|
|
1323
|
+
return durationWords((Number.isFinite(landed) ? landed : input.now) - started);
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* A span of time as the `spent` row says it: seconds under a minute, minutes
|
|
1327
|
+
* under two hours, then hours and minutes. Null for a span that runs
|
|
1328
|
+
* backwards or does not parse, since a negative age is worse than a missing
|
|
1329
|
+
* one.
|
|
1330
|
+
*/
|
|
1331
|
+
function durationWords(ms) {
|
|
1332
|
+
if (!Number.isFinite(ms) || ms < 0) {
|
|
775
1333
|
return null;
|
|
776
1334
|
}
|
|
777
1335
|
if (ms < 60_000) {
|
|
@@ -812,12 +1370,20 @@ function collapseRow(name, row, constraints) {
|
|
|
812
1370
|
* column 14. A row's `- ` items and its `→ ` pointer follow it on indented
|
|
813
1371
|
* continuation lines.
|
|
814
1372
|
*
|
|
815
|
-
* The step's identity is not in here: the
|
|
816
|
-
*
|
|
817
|
-
*
|
|
1373
|
+
* The step's identity is not in here: the Now line above the card carries it,
|
|
1374
|
+
* so it renders once. A stop card's range has no Now line, and its `steps`
|
|
1375
|
+
* row opens the fence in that place. Empty when no row survived, so the caller
|
|
1376
|
+
* can drop the label and the fence together rather than emit an empty
|
|
1377
|
+
* instrument.
|
|
818
1378
|
*/
|
|
819
1379
|
export function recapLines(rows, extras) {
|
|
820
1380
|
const body = [];
|
|
1381
|
+
if (extras.steps !== undefined) {
|
|
1382
|
+
body.push(`${'steps'.padEnd(13)}${extras.steps.evidence}`);
|
|
1383
|
+
for (const item of extras.steps.items) {
|
|
1384
|
+
body.push(`${RECAP_INDENT}- ${item}`);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
821
1387
|
for (const name of RECAP_ROW_NAMES) {
|
|
822
1388
|
const row = rows[name];
|
|
823
1389
|
if (row === undefined) {
|
|
@@ -840,6 +1406,230 @@ export function recapLines(rows, extras) {
|
|
|
840
1406
|
}
|
|
841
1407
|
return body;
|
|
842
1408
|
}
|
|
1409
|
+
/** The short form a sha takes on the `steps` row, a handle for `git show`. */
|
|
1410
|
+
const SHORT_SHA = 7;
|
|
1411
|
+
/**
|
|
1412
|
+
* Measure a landed range for the stop card's readout: its `steps` row, the
|
|
1413
|
+
* `check` and `seam` rows the CLI owns, the `diff` and `spent` rows, and the
|
|
1414
|
+
* stats the Verdict folds.
|
|
1415
|
+
*
|
|
1416
|
+
* Each step is held to its own commit and its own seam, never the range's
|
|
1417
|
+
* union, so a step that wandered into a neighbor's seam still wandered. A
|
|
1418
|
+
* step the ledger holds no commit for reads `no checkpoint` on the `steps` row
|
|
1419
|
+
* and adds nothing else. A step landed by hand, with a checkpoint but no
|
|
1420
|
+
* `build` entry, adds its commit to every row and no time to `spent`; its
|
|
1421
|
+
* counters still count, so a red run its checkpoint's gate refused reads the
|
|
1422
|
+
* same on the `spent` row and in the Verdict.
|
|
1423
|
+
*/
|
|
1424
|
+
export function measureRange(input) {
|
|
1425
|
+
const held = parseOrderedSteps(input.intent).filter((s) => hasStep(input.range.steps, s.n));
|
|
1426
|
+
const shas = stepShas(input.checkpoints);
|
|
1427
|
+
const landed = held.flatMap((s) => {
|
|
1428
|
+
const sha = shas.get(s.n);
|
|
1429
|
+
return sha === undefined ? [] : [{ n: s.n, files: input.commits[sha] ?? [] }];
|
|
1430
|
+
});
|
|
1431
|
+
const rows = {};
|
|
1432
|
+
if (input.summary !== null) {
|
|
1433
|
+
rows.check = summaryCheckRow(input.summary, landed.length);
|
|
1434
|
+
}
|
|
1435
|
+
const seam = rangeSeamRow(input.intent, landed, held.length);
|
|
1436
|
+
if (seam !== null) {
|
|
1437
|
+
rows.seam = seam;
|
|
1438
|
+
}
|
|
1439
|
+
const stats = held.map((s) => input.stats[String(s.n)] ?? {});
|
|
1440
|
+
const sum = (key) => stats.reduce((total, s) => total + (s[key] ?? 0), 0);
|
|
1441
|
+
const recorded = ledgerShas(input.checkpoints);
|
|
1442
|
+
return {
|
|
1443
|
+
steps: stepsRow(held, shas),
|
|
1444
|
+
rows,
|
|
1445
|
+
diff: rangeDiffValue(landed.flatMap((l) => l.files)),
|
|
1446
|
+
spent: rangeSpentValue(stats),
|
|
1447
|
+
stats: {
|
|
1448
|
+
driftWarnings: sum('driftWarnings'),
|
|
1449
|
+
redChecks: sum('redChecks'),
|
|
1450
|
+
reverts: sum('reverts'),
|
|
1451
|
+
outOfBand: input.between.filter((sha) => !recorded.has(sha)).length,
|
|
1452
|
+
},
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* The ledger span a range landed across, for the out-of-band count: from the
|
|
1457
|
+
* line before the range's first landing to the range's last. Null when no
|
|
1458
|
+
* step of the range is on the ledger, or nothing precedes its first landing.
|
|
1459
|
+
*
|
|
1460
|
+
* The caller asks git for the first-parent commits in `from..to`, plumbbob's
|
|
1461
|
+
* plan commits left out, and hands them to `measureRange` as `between`; the
|
|
1462
|
+
* ledger's own commits drop out there, so what is left is the range's
|
|
1463
|
+
* commits outside the ledger.
|
|
1464
|
+
*/
|
|
1465
|
+
export function rangeSpan(checkpoints, range) {
|
|
1466
|
+
const lines = ledgerLines(checkpoints);
|
|
1467
|
+
const inRange = (line) => line.step !== null && hasStep(range.steps, line.step);
|
|
1468
|
+
const first = lines.findIndex(inRange);
|
|
1469
|
+
const last = lines.findLastIndex(inRange);
|
|
1470
|
+
const from = lines[first - 1]?.sha;
|
|
1471
|
+
const to = lines[last]?.sha;
|
|
1472
|
+
return first < 1 || from === undefined || to === undefined ? null : { from, to };
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* The ledger's lines of every kind, in the order they were written.
|
|
1476
|
+
*/
|
|
1477
|
+
function ledgerLines(checkpoints) {
|
|
1478
|
+
return checkpoints.split('\n').flatMap((line) => {
|
|
1479
|
+
const m = /^(?:baseline|plan|step\s+(\d+))\s+(\S+)/.exec(line.trim());
|
|
1480
|
+
return m === null ? [] : [{ step: m[1] === undefined ? null : Number(m[1]), sha: m[2] ?? '' }];
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* Each step's checkpoint sha, the last line for a step landed twice (a revert,
|
|
1485
|
+
* then a landing again), since that is the commit on the branch now.
|
|
1486
|
+
*/
|
|
1487
|
+
export function stepShas(checkpoints) {
|
|
1488
|
+
const shas = new Map();
|
|
1489
|
+
for (const line of ledgerLines(checkpoints)) {
|
|
1490
|
+
if (line.step !== null) {
|
|
1491
|
+
shas.set(line.step, line.sha);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
return shas;
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* Every sha the ledger records, of any kind: the commits plumbbob made, so
|
|
1498
|
+
* none of them counts as out-of-band.
|
|
1499
|
+
*/
|
|
1500
|
+
function ledgerShas(checkpoints) {
|
|
1501
|
+
return new Set(ledgerLines(checkpoints).map((line) => line.sha));
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* The `steps` row: how many of the range's steps landed, then each step and
|
|
1505
|
+
* its checkpoint sha in build order, packed onto continuation lines by width
|
|
1506
|
+
* rather than one per step, since each is a handle and not an item to judge.
|
|
1507
|
+
*/
|
|
1508
|
+
function stepsRow(held, shas) {
|
|
1509
|
+
const done = held.filter((s) => s.done).length;
|
|
1510
|
+
const handles = held.map((s) => {
|
|
1511
|
+
const sha = shas.get(s.n);
|
|
1512
|
+
return `${s.n} ${sha === undefined ? 'no checkpoint' : sha.slice(0, SHORT_SHA)}`;
|
|
1513
|
+
});
|
|
1514
|
+
return { evidence: `${done} of ${held.length} landed`, items: packed(handles, RECAP_VALUE_BUDGET - 2) };
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Pack items onto lines joined by ` · `, a new line when the next would
|
|
1518
|
+
* overrun `width`.
|
|
1519
|
+
*/
|
|
1520
|
+
function packed(items, width) {
|
|
1521
|
+
const lines = [];
|
|
1522
|
+
for (const item of items) {
|
|
1523
|
+
const last = lines.at(-1);
|
|
1524
|
+
if (last !== undefined && `${last} · ${item}`.length <= width) {
|
|
1525
|
+
lines[lines.length - 1] = `${last} · ${item}`;
|
|
1526
|
+
}
|
|
1527
|
+
else {
|
|
1528
|
+
lines.push(item);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
return lines;
|
|
1532
|
+
}
|
|
1533
|
+
/**
|
|
1534
|
+
* The range's `seam` row: each landed step's commit against that step's own
|
|
1535
|
+
* seam. Null when no step had both a commit and a seam to measure.
|
|
1536
|
+
*
|
|
1537
|
+
* Green counts the steps measured out of the range's steps. One strayed step
|
|
1538
|
+
* is named in the row with its paths as the evidence, the way a step's pause
|
|
1539
|
+
* names them; two or more break onto continuation lines, one per step. It is
|
|
1540
|
+
* an advisory, as at a pause: a stray in a landed commit asks the same
|
|
1541
|
+
* question of the plan.
|
|
1542
|
+
*/
|
|
1543
|
+
function rangeSeamRow(intent, landed, steps) {
|
|
1544
|
+
const measured = landed.flatMap(({ n, files }) => {
|
|
1545
|
+
const seam = parseStepSeam(intent, n);
|
|
1546
|
+
return seam.ok ? [{ n, strays: scopeDrift(files.map((f) => f.path), seam.seam) }] : [];
|
|
1547
|
+
});
|
|
1548
|
+
if (measured.length === 0) {
|
|
1549
|
+
return null;
|
|
1550
|
+
}
|
|
1551
|
+
const strayed = measured.filter((m) => m.strays.length > 0);
|
|
1552
|
+
const only = strayed[0];
|
|
1553
|
+
if (only === undefined) {
|
|
1554
|
+
return { verdict: 'true', word: 'held', evidence: `held: ${measured.length} of ${steps} steps, no strays` };
|
|
1555
|
+
}
|
|
1556
|
+
if (strayed.length === 1) {
|
|
1557
|
+
const evidence = `strayed: ${pathCount(only.strays.length)} outside step ${only.n}'s seam`;
|
|
1558
|
+
const path = only.strays[0];
|
|
1559
|
+
return only.strays.length === 1 && path !== undefined
|
|
1560
|
+
? { verdict: 'advisory', word: 'strayed', evidence, pointer: path }
|
|
1561
|
+
: { verdict: 'advisory', word: 'strayed', evidence, items: only.strays };
|
|
1562
|
+
}
|
|
1563
|
+
const total = strayed.reduce((sum, m) => sum + m.strays.length, 0);
|
|
1564
|
+
return {
|
|
1565
|
+
verdict: 'advisory',
|
|
1566
|
+
word: 'strayed',
|
|
1567
|
+
evidence: `strayed: ${pathCount(total)} outside ${strayed.length} steps' seams`,
|
|
1568
|
+
items: strayed.map((m) => strayedStep(m.n, m.strays)),
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
/** A count of paths: `1 path`, `3 paths`. */
|
|
1572
|
+
function pathCount(n) {
|
|
1573
|
+
return `${n} path${n === 1 ? '' : 's'}`;
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* One strayed step's continuation line, `step 8: src/x.ts, src/y.ts`, fitted
|
|
1577
|
+
* to the line by count rather than cut: the first path and how many others,
|
|
1578
|
+
* then the count alone.
|
|
1579
|
+
*/
|
|
1580
|
+
function strayedStep(n, strays) {
|
|
1581
|
+
const width = RECAP_VALUE_BUDGET - 2;
|
|
1582
|
+
const whole = `step ${n}: ${strays.join(', ')}`;
|
|
1583
|
+
if (whole.length <= width) {
|
|
1584
|
+
return whole;
|
|
1585
|
+
}
|
|
1586
|
+
const others = strays.length - 1;
|
|
1587
|
+
const first = `step ${n}: ${strays[0] ?? ''} and ${others} other${others === 1 ? '' : 's'}`;
|
|
1588
|
+
return first.length <= width ? first : `step ${n}: ${pathCount(strays.length)}`;
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* The range's `diff` row: the commits summed, a file counted once however
|
|
1592
|
+
* many commits touched it, plumbbob's own records left out as at a pause.
|
|
1593
|
+
* Information that never folds, and at a stop it never vanishes.
|
|
1594
|
+
*/
|
|
1595
|
+
function rangeDiffValue(files) {
|
|
1596
|
+
const work = files.filter((f) => !isArtifactPath(f.path));
|
|
1597
|
+
const counts = { ...countDiff(work), files: new Set(work.map((f) => f.path)).size };
|
|
1598
|
+
return diffRowValue(counts, false) ?? '+0 -0 across 0 files';
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* The range's `spent` row: from the first step's `startedAt` to the last
|
|
1602
|
+
* step's `landedAt`, then the red runs summed across the steps, or `green
|
|
1603
|
+
* first run at every landing`, then any drift warnings. Null when nothing is
|
|
1604
|
+
* countable.
|
|
1605
|
+
*
|
|
1606
|
+
* The clock reads only the steps `build` entered, since a step landed by hand
|
|
1607
|
+
* has no `startedAt` to count from. No gate clause rides here, because only
|
|
1608
|
+
* the last of the range's gate runs is on disk, and no turn count, because the
|
|
1609
|
+
* ledger counts a step's turns from the tick that entered it.
|
|
1610
|
+
*/
|
|
1611
|
+
function rangeSpentValue(stats) {
|
|
1612
|
+
const entered = stats.filter((s) => s.startedAt !== undefined);
|
|
1613
|
+
const started = Math.min(...entered.map((s) => Date.parse(s.startedAt ?? '')).filter(Number.isFinite));
|
|
1614
|
+
const landed = Math.max(...entered.map((s) => Date.parse(s.landedAt ?? '')).filter(Number.isFinite));
|
|
1615
|
+
const elapsed = Number.isFinite(started) && Number.isFinite(landed) ? durationWords(landed - started) : null;
|
|
1616
|
+
const red = stats.reduce((sum, s) => sum + (s.redChecks ?? 0), 0);
|
|
1617
|
+
const drift = stats.reduce((sum, s) => sum + (s.driftWarnings ?? 0), 0);
|
|
1618
|
+
const parts = [];
|
|
1619
|
+
if (elapsed !== null) {
|
|
1620
|
+
parts.push(elapsed);
|
|
1621
|
+
}
|
|
1622
|
+
if (red > 0) {
|
|
1623
|
+
parts.push(`${red} red run${red === 1 ? '' : 's'}`);
|
|
1624
|
+
}
|
|
1625
|
+
else if (elapsed !== null) {
|
|
1626
|
+
parts.push('green first run at every landing');
|
|
1627
|
+
}
|
|
1628
|
+
if (drift > 0) {
|
|
1629
|
+
parts.push(`${drift} drift warning${drift === 1 ? '' : 's'}`);
|
|
1630
|
+
}
|
|
1631
|
+
return parts.length === 0 ? null : parts.join(' · ');
|
|
1632
|
+
}
|
|
843
1633
|
// --- The detail file's prose: the Summary that opens a decision turn and the
|
|
844
1634
|
// recommendation that closes it (docs/presentation.md). The model writes both
|
|
845
1635
|
// into `.plumbbob/detail.md` and handoff renders them, so the whole turn is one
|
|
@@ -1024,6 +1814,7 @@ export function formatOrientation(o) {
|
|
|
1024
1814
|
return detail.length > 0 ? [head, ...detail].join('\n') : head;
|
|
1025
1815
|
});
|
|
1026
1816
|
const stepsBlock = o.steps.length === 0 ? ' (no steps planned yet)' : ` steps ${doneCount}/${o.steps.length} done\n${stepLines.join('\n')}`;
|
|
1817
|
+
const rangesBlock = o.ranges === null ? [] : [formatRanges(o.ranges), ''];
|
|
1027
1818
|
const cp = o.lastCheckpoint;
|
|
1028
1819
|
const cpLine = cp === null ? 'last checkpoint none yet' : `last checkpoint step ${cp.n} · ${cp.sha.slice(0, 7)}`;
|
|
1029
1820
|
// A neutral reconciliation note, only when there is something to reconcile:
|
|
@@ -1043,6 +1834,7 @@ export function formatOrientation(o) {
|
|
|
1043
1834
|
'',
|
|
1044
1835
|
stepsBlock,
|
|
1045
1836
|
'',
|
|
1837
|
+
...rangesBlock,
|
|
1046
1838
|
cpLine,
|
|
1047
1839
|
...receipts,
|
|
1048
1840
|
...orderRow,
|
|
@@ -1051,3 +1843,28 @@ export function formatOrientation(o) {
|
|
|
1051
1843
|
`next → ${o.next}`,
|
|
1052
1844
|
].join('\n');
|
|
1053
1845
|
}
|
|
1846
|
+
/**
|
|
1847
|
+
* The dashboard's ranges block, in the step list's own grammar: a count, then a
|
|
1848
|
+
* row per range with its command as the plan wrote it and its model, `✓` for
|
|
1849
|
+
* a range landed and `▸` with the `← next` tail for the one holding the next
|
|
1850
|
+
* step. An undone range carries its stop beneath it, since the stops ahead are
|
|
1851
|
+
* what the human steers by, and a landed one folds to its row, because its
|
|
1852
|
+
* call was made. A landed range whose stop card waits is the exception: its
|
|
1853
|
+
* call is still to make, so it keeps its stop and takes the
|
|
1854
|
+
* `← stop card waiting` tail, and the `▸` row drops `← next`, so the block
|
|
1855
|
+
* keeps one arrow. A range's drift rides beneath it, and the plan's own rows
|
|
1856
|
+
* close the block.
|
|
1857
|
+
*/
|
|
1858
|
+
function formatRanges(view) {
|
|
1859
|
+
const landed = view.rows.filter((r) => r.landed).length;
|
|
1860
|
+
const waits = view.rows.some((r) => r.waiting);
|
|
1861
|
+
const rows = view.rows.flatMap((r) => {
|
|
1862
|
+
const marker = r.landed ? '✓' : r.next ? '▸' : ' ';
|
|
1863
|
+
const model = r.range.model === null ? '' : ` · ${r.range.model}`;
|
|
1864
|
+
const tail = r.waiting ? ' ← stop card waiting' : r.next && !waits ? ' ← next' : '';
|
|
1865
|
+
const head = ` ${marker} ${r.range.item} ${r.range.command}${model}${tail}`;
|
|
1866
|
+
const stop = r.landed && !r.waiting ? [] : [` stop: ${r.range.stop}`];
|
|
1867
|
+
return [head, ...stop, ...r.drift.map((words) => ` ⚠ needs a re-cut: ${words}`)];
|
|
1868
|
+
});
|
|
1869
|
+
return [` ranges ${landed} of ${view.count} done`, ...rows, ...view.plan.map((words) => ` ⚠ ${words}`)].join('\n');
|
|
1870
|
+
}
|