plumbbob 0.13.1 → 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,7 @@
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
7
  import { expandStepList, formatStepList, hasStep, normalizeStepList, parseStepList, readBuildArgs, } from "./steplist.js";
8
8
  /**
9
9
  * The lines of a named `## Section`, from its heading to the next `## ` (or
@@ -106,15 +106,19 @@ export function parseBuildOrder(intent) {
106
106
  return expandStepList(list, (n) => planned.has(n));
107
107
  }
108
108
  /**
109
- * The steps in build order: the numbers `order` lists first, in that sequence,
110
- * 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.
111
112
  *
112
- * A listed number takes the first step in document order that carries it, and
113
- * a repeat of the number is ignored, so a doubled entry cannot double a step; a
114
- * number no step carries is dropped. An empty order is the identity, which is
115
- * what a plan with no `## Build order` section reads as. The steps themselves
116
- * never change, only the sequence, so `n`, `line`, and `done` stay what
117
- * `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.
118
122
  */
119
123
  export function orderSteps(steps, order) {
120
124
  const taken = new Set();
@@ -126,7 +130,11 @@ export function orderSteps(steps, order) {
126
130
  ordered.push(step);
127
131
  }
128
132
  }
129
- 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)];
130
138
  }
131
139
  /**
132
140
  * The plan's steps in build order: `parseSteps` merged with `parseBuildOrder`.
@@ -159,10 +167,14 @@ export function remainingOrder(steps, order, exclude) {
159
167
  return byOrder.some((n, i) => n !== byDocument[i]) ? byOrder : [];
160
168
  }
161
169
  /**
162
- * The build order as a phrase: the next `cap` step numbers in sequence, the
163
- * rest folded to a count (`and 2 more`), the way a long detail degrades on
164
- * every one-liner; '' for an empty order. With no cap, the whole sequence,
165
- * 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)).
166
178
  */
167
179
  export function buildOrderPhrase(order, cap) {
168
180
  if (order.length === 0) {
@@ -170,7 +182,7 @@ export function buildOrderPhrase(order, cap) {
170
182
  }
171
183
  const shown = cap === undefined ? order : order.slice(0, cap);
172
184
  const rest = order.length - shown.length;
173
- return `build order ${shown.join(', ')}${rest > 0 ? ` (and ${rest} more)` : ''}`;
185
+ return `order: ${shown.join(', ')}${rest > 0 ? ` (and ${rest} more)` : ''}`;
174
186
  }
175
187
  /**
176
188
  * Write `order` as the one numeric line under `## Build order`: in place of
@@ -351,35 +363,56 @@ export function rangeStanding(plan, ordered, step) {
351
363
  };
352
364
  }
353
365
  /** Whether a range has landed: the plan holds a step it names, and every one it holds is done. */
354
- function rangeLanded(range, ordered) {
366
+ export function rangeLanded(range, ordered) {
355
367
  const held = ordered.filter((s) => hasStep(range.steps, s.n));
356
368
  return held.length > 0 && held.every((s) => s.done);
357
369
  }
358
370
  /**
359
- * The ranges block the dashboard renders, and the move it suggests: the range
360
- * holding the next undone step and the command that runs what is left of it.
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.
361
374
  *
362
- * Null for a plan without ranges, so the block vanishes. The move is null when
363
- * the next step sits in no range, its range is flagged, or the step is still
364
- * unplanned, which leaves the dashboard's own step move in charge.
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.
365
380
  */
366
- function rangesView(intent, ordered, next) {
381
+ function rangesView(intent, ordered, next, waiting) {
367
382
  const plan = parseBuildRanges(intent);
368
383
  const standing = rangeStanding(plan, ordered, next?.n ?? null);
369
384
  if (standing.count === 0) {
370
- return { view: null, move: null };
385
+ return { view: null, move: null, waitingMove: null };
371
386
  }
372
387
  const own = (item) => standing.drift.filter((d) => d.item === item && d.kind !== 'malformed');
373
- const rows = plan.ranges.map((range) => ({
374
- range,
375
- landed: rangeLanded(range, ordered),
376
- next: range === standing.holding,
377
- drift: own(range.item).map((d) => d.words),
378
- }));
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
+ });
379
392
  const view = { count: standing.count, rows, plan: standing.drift.filter((d) => d.item === null || d.kind === 'malformed').map((d) => d.words) };
380
- const { holding, command } = standing;
381
- const move = holding === null || command === null || next?.planned !== true ? null : `build range ${holding.item} of ${standing.count} — \`${command}\``;
382
- return { view, move };
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})`;
383
416
  }
384
417
  /**
385
418
  * What is left of a range as the steps of a command: a landing range's last
@@ -454,15 +487,20 @@ function sharedDrift(range, ranges, ordered) {
454
487
  }
455
488
  /**
456
489
  * A range the build order cuts short: an undone step it does not name, placed
457
- * between two of its own, where a run of the range halts.
490
+ * between its first step and its last undone one, where a run of the range
491
+ * halts.
458
492
  *
459
- * An outside step ahead of the range's first step builds first and cuts
460
- * nothing, and the card names its range before this one.
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.
461
499
  */
462
500
  function insideDrift(range, ordered) {
463
501
  const first = ordered.findIndex((s) => hasStep(range.steps, s.n));
464
- const last = ordered.findLastIndex((s) => hasStep(range.steps, s.n));
465
- const inside = first === -1 ? [] : ordered.slice(first + 1, last).filter((s) => !s.done && !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));
466
504
  if (inside.length === 0) {
467
505
  return [];
468
506
  }
@@ -477,6 +515,243 @@ function stepPhrase(items) {
477
515
  const one = items.length === 1 && /^\d+$/.test(items[0] ?? '');
478
516
  return { words: `${one ? 'step' : 'steps'} ${items.join(', ')}`, be: one ? 'is' : 'are' };
479
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
+ }
480
755
  /**
481
756
  * Flip step N's `[ ]` checkbox to `[x]` within the `## Steps` section.
482
757
  *
@@ -712,9 +987,13 @@ export function orient(input) {
712
987
  const seamParse = target === undefined ? null : parseStepSeam(input.intent, target.n);
713
988
  const phase = input.spiking ? 'SPIKE' : input.inFlight !== null ? 'BUILD' : 'DESIGN';
714
989
  // At the boundary with nothing asked for, the move names the range holding
715
- // the next step; a spike, a step in flight, or a request keeps its own.
716
- const ranges = rangesView(input.intent, ordered, nextUndone);
717
- const atBoundary = !input.spiking && input.inFlight === null && requested === null;
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;
718
997
  return {
719
998
  title: parseTitle(input.intent),
720
999
  phase,
@@ -723,7 +1002,9 @@ export function orient(input) {
723
1002
  parked: parks.open,
724
1003
  parkedTotal: parks.total,
725
1004
  openQuestions: parseOpenQuestions(input.intent),
726
- next: (atBoundary ? ranges.move : null) ?? nextMove(input.spiking, ordered, input.inFlight, parks.open, requested),
1005
+ next: (idle ? ranges.waitingMove : null) ??
1006
+ (atBoundary ? ranges.move : null) ??
1007
+ nextMove(input.spiking, ordered, input.inFlight, parks.open, requested),
727
1008
  nextUndone: nextUndone?.n ?? null,
728
1009
  buildOrder: remainingOrder(steps, order, null),
729
1010
  ranges: ranges.view,
@@ -773,19 +1054,24 @@ function classifyRecapRow(name, value) {
773
1054
  * `── recap · step N of M ──` header, the (subset of) five measuring rows
774
1055
  * present, and the free-text diff row. Null when no header is found.
775
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
+ *
776
1061
  * A row absent from the block is simply missing from `rows`: a vanished row
777
1062
  * the fold treats exactly like one that never applied, never like a failure.
778
1063
  * An indented `- ` or `→ ` line attaches to the row above it, which is how a
779
1064
  * row carrying two or more items, or a pointer at its evidence, survives the
780
1065
  * round trip through the file.
781
1066
  */
782
- export function parseRecap(detail) {
1067
+ export function parseRecap(detail, scope = 'step') {
783
1068
  const lines = detail.split('\n');
784
- 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()));
785
1071
  if (headerIdx === -1) {
786
1072
  return null;
787
1073
  }
788
- 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] ?? '');
789
1075
  if (header === null) {
790
1076
  return null;
791
1077
  }
@@ -824,7 +1110,7 @@ export function parseRecap(detail) {
824
1110
  }
825
1111
  }
826
1112
  }
827
- return { step: Number(header[1]), total: Number(header[2]), rows, diff };
1113
+ return { n: Number(header[1]), total: Number(header[2]), rows, diff };
828
1114
  }
829
1115
  const PLUMB = { glyph: '●', state: 'Plumb' };
830
1116
  const A_HAIR_OFF = { glyph: '◐', state: 'A hair off' };
@@ -842,8 +1128,13 @@ const NOT_STANDING = { glyph: '✗', state: 'Not standing' };
842
1128
  * carries the stray; at the boundary the tree is clean, the row has vanished,
843
1129
  * and the stat the checkpoint bumped carries it instead. Both name it
844
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.
845
1136
  */
846
- export function foldVerdict(rows, stats) {
1137
+ export function foldVerdict(rows, stats, scope = 'step') {
847
1138
  for (const name of RECAP_ROW_NAMES) {
848
1139
  if (rows[name]?.verdict === 'drift') {
849
1140
  return { ladder: NOT_STANDING, worst: `${name} drifted` };
@@ -871,7 +1162,8 @@ export function foldVerdict(rows, stats) {
871
1162
  return { ladder: A_HAIR_OFF, worst: `${stats.redChecks} red run${stats.redChecks === 1 ? '' : 's'} before green` };
872
1163
  }
873
1164
  if (stats.reverts > 0) {
874
- 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}` };
875
1167
  }
876
1168
  if (stats.outOfBand > 0) {
877
1169
  return { ladder: A_HAIR_OFF, worst: `${stats.outOfBand} commit${stats.outOfBand === 1 ? '' : 's'} outside the ledger` };
@@ -898,15 +1190,20 @@ const CHECK_SUMMARY = '.check/summary.json';
898
1190
  * Red expands where green collapses: one failing slot is named in the row and
899
1191
  * its raw output is the `→` pointer, and two or more collapse to a count with
900
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.
901
1197
  */
902
- export function summaryCheckRow(summary) {
1198
+ export function summaryCheckRow(summary, landings = 0) {
903
1199
  const ran = summary.checks.filter((c) => c.skipped !== true);
904
1200
  const skipped = summary.checks.filter((c) => c.skipped === true).map((c) => c.name);
905
1201
  const named = skipped.length === 0 ? '' : ` · without ${skipped.join(', ')}`;
906
1202
  const counted = skipped.length === 0 ? '' : ` · without ${skipped.length} others`;
907
1203
  const fit = (long, short) => (long.length <= RECAP_VALUE_BUDGET ? long : short);
908
1204
  if (summary.ok) {
909
- 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}`;
910
1207
  return { verdict: 'true', word: 'green', evidence: fit(`green: ${size}${named}`, `green: ${size}${counted}`) };
911
1208
  }
912
1209
  const failing = ran.filter((c) => !c.ok);
@@ -1023,8 +1320,16 @@ function elapsedSpent(input) {
1023
1320
  return null;
1024
1321
  }
1025
1322
  const landed = input.landedAt === undefined ? Number.NaN : Date.parse(input.landedAt);
1026
- const ms = (Number.isFinite(landed) ? landed : input.now) - started;
1027
- 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) {
1028
1333
  return null;
1029
1334
  }
1030
1335
  if (ms < 60_000) {
@@ -1065,12 +1370,20 @@ function collapseRow(name, row, constraints) {
1065
1370
  * column 14. A row's `- ` items and its `→ ` pointer follow it on indented
1066
1371
  * continuation lines.
1067
1372
  *
1068
- * The step's identity is not in here: the `**Readout**:` label above the fence
1069
- * carries it, so it renders once. Empty when no row survived, so the caller can
1070
- * 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.
1071
1378
  */
1072
1379
  export function recapLines(rows, extras) {
1073
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
+ }
1074
1387
  for (const name of RECAP_ROW_NAMES) {
1075
1388
  const row = rows[name];
1076
1389
  if (row === undefined) {
@@ -1093,6 +1406,230 @@ export function recapLines(rows, extras) {
1093
1406
  }
1094
1407
  return body;
1095
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
+ }
1096
1633
  // --- The detail file's prose: the Summary that opens a decision turn and the
1097
1634
  // recommendation that closes it (docs/presentation.md). The model writes both
1098
1635
  // into `.plumbbob/detail.md` and handoff renders them, so the whole turn is one
@@ -1311,16 +1848,22 @@ export function formatOrientation(o) {
1311
1848
  * row per range with its command as the plan wrote it and its model, `✓` for
1312
1849
  * a range landed and `▸` with the `← next` tail for the one holding the next
1313
1850
  * step. An undone range carries its stop beneath it, since the stops ahead are
1314
- * what the human steers by, and a landed one folds to its row. A range's drift
1315
- * rides beneath it, and the plan's own rows close the block.
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.
1316
1857
  */
1317
1858
  function formatRanges(view) {
1318
1859
  const landed = view.rows.filter((r) => r.landed).length;
1860
+ const waits = view.rows.some((r) => r.waiting);
1319
1861
  const rows = view.rows.flatMap((r) => {
1320
1862
  const marker = r.landed ? '✓' : r.next ? '▸' : ' ';
1321
1863
  const model = r.range.model === null ? '' : ` · ${r.range.model}`;
1322
- const head = ` ${marker} ${r.range.item} ${r.range.command}${model}${r.next ? ' ← next' : ''}`;
1323
- const stop = r.landed ? [] : [` stop: ${r.range.stop}`];
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}`];
1324
1867
  return [head, ...stop, ...r.drift.map((words) => ` ⚠ needs a re-cut: ${words}`)];
1325
1868
  });
1326
1869
  return [` ranges ${landed} of ${view.count} done`, ...rows, ...view.plan.map((words) => ` ⚠ ${words}`)].join('\n');