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.
@@ -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 commas, nothing else. A trailing comma
78
- // is tolerated, since a hand edit leaves one behind.
79
- const BUILD_ORDER_LINE = /^\d+(?:\s*,\s*\d+)*\s*,?$/;
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 written.
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. Numbers come back
89
- * verbatim, repeats and unknowns included: `orderSteps` does the cleaning, and
90
- * `recover` wants the raw read to say which number the plan lacks.
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
- if (line === undefined) {
101
+ const list = line === undefined ? null : parseStepList(line.replace(/\s+/g, '').replace(/,$/, ''));
102
+ if (list === null) {
97
103
  return [];
98
104
  }
99
- return line
100
- .split(',')
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 numbers `order` lists first, in that sequence,
107
- * then every step it leaves out, in document order.
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
- * A listed number takes the first step in document order that carries it, and
110
- * a repeat of the number is ignored, so a doubled entry cannot double a step; a
111
- * number no step carries is dropped. An empty order is the identity, which is
112
- * what a plan with no `## Build order` section reads as. The steps themselves
113
- * never change, only the sequence, so `n`, `line`, and `done` stay what
114
- * `parseSteps` read.
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
- return [...ordered, ...steps.filter((s) => !taken.has(s))];
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 a phrase: the next `cap` step numbers in sequence, the
160
- * rest folded to a count (`and 2 more`), the way a long detail degrades on
161
- * every one-liner; '' for an empty order. With no cap, the whole sequence,
162
- * which is the dashboard's row.
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 `build order ${shown.join(', ')}${rest > 0 ? ` (and ${rest} more)` : ''}`;
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 step number an explicit skill invocation asks for, out of the raw
369
- * argument text `status --invoked` receives.
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
- * The first whitespace-separated token shaped like `22` or `22-24` names the
372
- * step (a range asks to start at its first number); flags such as `--auto` and
373
- * anything else parse to null, meaning "no explicit ask", which leaves the
374
- * dashboard's own suggestion in charge. Null in means null out, so a host that
375
- * never substitutes the invocation degrades to today's rendering.
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
- if (raw === null) {
379
- return null;
380
- }
381
- for (const token of raw.trim().split(/\s+/)) {
382
- const m = /^(\d+)(?:-\d+)?$/.exec(token);
383
- if (m) {
384
- const n = Number(m[1]);
385
- return n >= 1 ? n : null;
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 = input.requested === null ? undefined : steps.find((s) => s.n === input.requested);
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: nextMove(input.spiking, ordered, input.inFlight, parks.open, input.requested),
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 headerIdx = lines.findIndex((l) => /^──\s*recap\s*·\s*step\s+\d+\s+of\s+\d+\s*──$/.test(l.trim()));
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 = /step\s+(\d+)\s+of\s+(\d+)/.exec(lines[headerIdx] ?? '');
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 { step: Number(header[1]), total: Number(header[2]), rows, diff };
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
- return { ladder: A_HAIR_OFF, worst: `${stats.reverts} revert${stats.reverts === 1 ? '' : 's'} on this step` };
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 size = `${ran.length} of ${summary.checks.length} checks`;
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
- const ms = (Number.isFinite(landed) ? landed : input.now) - started;
774
- if (ms < 0) {
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 `**Readout**:` label above the fence
816
- * carries it, so it renders once. Empty when no row survived, so the caller can
817
- * drop the label and the fence together rather than emit an empty instrument.
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
+ }