plumbbob 0.13.2 → 0.13.4

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "plumbbob",
3
3
  "displayName": "PlumbBob",
4
- "version": "0.13.2",
4
+ "version": "0.13.4",
5
5
  "description": "Attention-first build process — driver skills + a CLI that keep you the decider: guidance on the work, a latch on the record.",
6
6
  "author": { "name": "Rob McLarty", "email": "hello@robmclarty.com", "url": "https://robmclarty.com" },
7
7
  "homepage": "https://github.com/robmclarty/plumbbob#readme",
package/README.md CHANGED
@@ -49,7 +49,7 @@ PlumbBob itself, running its own loop.
49
49
  ## The loop in one picture
50
50
 
51
51
  <p align="center">
52
- <img src="docs/media/loop-one-step.svg" alt="One step of the loop: /plumbbob:build implements step 1 and stops at the pause, the human says looks good, the step is checkpointed" width="760">
52
+ <img src="site/media/loop-one-step.svg" alt="One step of the loop, drawn as a wall going up course by course: on the scaffold a person reads a plumb line at the corner, and down the wall a mechanical arm holds the next stone and waits" width="760">
53
53
  </p>
54
54
 
55
55
  Everything the model must stay true to lives in one file, the build's `intent.md`
@@ -72,7 +72,8 @@ a decision the plan left open or after a stretch of steps worth reviewing.
72
72
  `/plumbbob:build` is the same loop a step at a time, stopping at every step's pause, for
73
73
  a plan without ranges or a stretch you want to walk slowly. Nothing is locked and nothing
74
74
  refuses you: the loop does the labor, pulls up to the line, and waits for you to advance
75
- it. **You are the clock.**
75
+ it. **You are the clock.** [`docs/runs.md`](docs/runs.md) walks one plan through its runs
76
+ and then drives it by hand, the two side by side.
76
77
 
77
78
  ## What a prompt can't replicate
78
79
 
@@ -101,7 +102,7 @@ payoff is [measured, not asserted](#why-not-just), not just claimed. **Guidance
101
102
  work, a latch on the record.**
102
103
 
103
104
  <p align="center">
104
- <img src="docs/media/build-record-in-pr.svg" alt="A pull request's file list with the build folder beside the source" width="760">
105
+ <img src="site/media/build-record.svg" alt="The record in the wall, drawn: the scaffold is down and stacked on the ground, a bench mark is cut into the end stone of every course, and in the open cornerstone lie the rolled plan, tied with the red line, and the ledger, laid into the wall to stay" width="760">
105
106
  </p>
106
107
 
107
108
  ## Install
@@ -183,7 +184,7 @@ In Claude Code, inside any git repo with a clean tree:
183
184
  **Recommendation**: Approve it, refilling on read. The gate is green and the seam held, and a bucket that refills when it is read needs no timer for the route to clean up.
184
185
  ````
185
186
 
186
- ![The build pause as Claude Code renders it](docs/media/pause.svg)
187
+ ![The pause, drawn: a mechanical hand has set its stone on the line and hovers open and still, while a plumb bob in front comes to rest](site/media/pause.svg)
187
188
 
188
189
  (The check gate is [checkride](https://www.npmjs.com/package/checkride), one
189
190
  run across the tools your repo already configures: types, lint, tests, dead
@@ -203,7 +204,7 @@ In Claude Code, inside any git repo with a clean tree:
203
204
  · range 2 of 2: /plumbbob:run (steps 2-3)
204
205
  ```
205
206
 
206
- ![The boundary after looks good](docs/media/checkpoint-boundary.svg)
207
+ ![The boundary, drawn: a bench mark freshly cut into the newest course of a stone wall, older marks below, and the red line raised one course over stones not yet laid](site/media/checkpoint-boundary.svg)
207
208
 
208
209
  Ask instead (`expand 2`, or any question) and it shows more of what is there and
209
210
  changes nothing. The answer closes on one line pointing back at the step, not on the
@@ -360,14 +361,16 @@ that matters.
360
361
  > under [`docs/evals/`](docs/evals/): the route the latch owns (*no checkpoint over a red
361
362
  > check under pressure*) went from **2/5 prose-only to 5/5 latched** on the first sweep
362
363
  > ([2026-07-11](docs/evals/2026-07-11.md)), every sweep since has held it and closed the
363
- > two gaps that one surfaced, and the latest ([2026-09-17](docs/evals/2026-09-17.md))
364
- > scores the shape of the turn itself.
364
+ > two gaps that one surfaced, [2026-09-17](docs/evals/2026-09-17.md) began scoring the
365
+ > shape of the turn itself, and the latest ([2026-10-19](docs/evals/2026-10-19.md)) holds a
366
+ > run to its stop card, 5/5.
365
367
 
366
368
  ## Documentation
367
369
 
368
370
  Each doc answers one question (in rough reading order for a new user):
369
371
 
370
372
  - *What does a session actually look like?* → [`docs/happy-path.md`](docs/happy-path.md): one goal walked end to end; read this first.
373
+ - *How do I drive a plan with runs, and what do they replace?* → [`docs/runs.md`](docs/runs.md): ranges, the build order, and the stop card's harvest, walked through three runs and set beside the manual build, harvest, and order moves.
371
374
  - *What do the words mean?* → [`docs/glossary.md`](docs/glossary.md): every term the loop mints, one line each, with where to read more.
372
375
  - *Show me the artifacts it leaves behind.* → [`examples/`](examples/): that same session's finished build folder, file by file.
373
376
  - *Should I / can I / what about…?* → [`docs/faq.md`](docs/faq.md): the adoption questions, answered straight.
@@ -356,7 +356,7 @@ export function rangeStanding(plan, ordered, step) {
356
356
  return {
357
357
  count,
358
358
  holding,
359
- command: flag === null && left.length > 0 ? `/plumbbob:build ${leftExpression(holding, left)}` : null,
359
+ command: flag === null && left.length > 0 ? `/plumbbob:build ${leftExpression(holding, left.map((s) => s.n))}` : null,
360
360
  flag,
361
361
  completed: ordered[at]?.done === true && rangeLanded(holding, ordered) ? holding : null,
362
362
  drift,
@@ -420,8 +420,7 @@ function stopCardMove(item, count, follow) {
420
420
  * with each climbing run folded to a span, a bare range's lone number included.
421
421
  */
422
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);
423
+ return !range.bare && left.length === 1 ? `${left[0]}-${left[0]}` : formatStepList(left);
425
424
  }
426
425
  /** The words for steps no range names: `no range names step 12`, or `steps 11, 12`. */
427
426
  function noRangeWords(steps) {
@@ -515,6 +514,344 @@ function stepPhrase(items) {
515
514
  const one = items.length === 1 && /^\d+$/.test(items[0] ?? '');
516
515
  return { words: `${one ? 'step' : 'steps'} ${items.join(', ')}`, be: one ? 'is' : 'are' };
517
516
  }
517
+ // The most ways of placing one tangle's ranges the re-cut tries, counted as
518
+ // the product of each range's stretches. A step moved out of a range splits it
519
+ // in two, so one moved step makes two and three make eight, and an order that
520
+ // scatters past this is a re-cut for the human anyway.
521
+ const ORDER_CAP = 64;
522
+ /**
523
+ * Re-cut the plan's ranges to follow its build order, and write each command
524
+ * the re-cut changed into its item.
525
+ *
526
+ * The plan comes back as it was, with nothing re-cut, when the order settles
527
+ * nothing, and when an item's opener does not hold its command where the write
528
+ * can find it, so a re-cut is written whole or not at all.
529
+ */
530
+ export function recutPlan(intent) {
531
+ const plan = parseBuildRanges(intent);
532
+ const recut = recutRanges(plan, parseOrderedSteps(intent));
533
+ const written = recut.length === 0 ? null : writeRecut(intent, plan.ranges, recut);
534
+ return written === null ? { intent, recut: [] } : { intent: written, recut };
535
+ }
536
+ /**
537
+ * The ranges re-cut so that each holds one stretch of the build order, with
538
+ * every undone step in one wherever the order settles which.
539
+ *
540
+ * Only an open range moves. A landed range takes no step, since its call was
541
+ * made; a bare range keeps its one step and takes no other, since it builds
542
+ * that step to its pause; and every range keeps a step of its own, so no stop
543
+ * is left with nothing to stop on. Where the order interleaves the steps of two
544
+ * or more ranges, they are re-cut by the fewest steps moved between them, and
545
+ * left as written when two re-cuts tie or the one would split a range's longest
546
+ * stretch. A step no range names then joins one by where the order puts it, the
547
+ * way a moved step does: the range around it, else the one it builds ahead of,
548
+ * else the one it builds after. Nothing is re-cut when an item does not parse, an
549
+ * open range names a step the plan lacks, or two open ranges name one undone
550
+ * step, since which range holds what is the human's call there.
551
+ */
552
+ function recutRanges(plan, ordered) {
553
+ const open = plan.ranges.filter((r) => !rangeLanded(r, ordered));
554
+ const owners = ownership(open, ordered);
555
+ if (owners === null || plan.malformed.length > 0 || open.some((r) => missingDrift(r, ordered).length > 0)) {
556
+ return [];
557
+ }
558
+ const undone = ordered.filter((s) => !s.done).map((s) => s.n);
559
+ const placed = new Map(owners);
560
+ const unsettled = new Set();
561
+ for (const tangle of tangles(undone.filter((n) => owners.has(n)), owners)) {
562
+ const moved = fewestMoves(tangle, owners);
563
+ if (moved === null) {
564
+ tangle.tangled.forEach((r) => unsettled.add(r));
565
+ }
566
+ moved?.forEach((range, n) => placed.set(n, range));
567
+ }
568
+ placeUnnamed(undone, placed, unsettled);
569
+ return open.flatMap((range) => {
570
+ const was = ordered.filter((s) => hasStep(range.steps, s.n)).map((s) => s.n);
571
+ const now = ordered.filter((s) => (s.done ? hasStep(range.steps, s.n) : placed.get(s.n) === range)).map((s) => s.n);
572
+ return was.join() === now.join() ? [] : [{ item: range.item, steps: now }];
573
+ });
574
+ }
575
+ /**
576
+ * Each undone step's open range, or null when two open ranges name one undone
577
+ * step, which no sequence can settle.
578
+ */
579
+ function ownership(open, ordered) {
580
+ const owners = new Map();
581
+ for (const step of ordered.filter((s) => !s.done)) {
582
+ const named = open.filter((r) => hasStep(r.steps, step.n));
583
+ if (named.length > 1) {
584
+ return null;
585
+ }
586
+ if (named[0] !== undefined) {
587
+ owners.set(step.n, named[0]);
588
+ }
589
+ }
590
+ return owners;
591
+ }
592
+ /**
593
+ * The owned steps' tangles. A range's span runs from its first step in build
594
+ * order to its last, and ranges whose spans overlap are tangled; a range alone
595
+ * in its span is already one stretch. Each tangle carries the range on either
596
+ * side of it as well, since a step at the tangle's edge may join one, and two
597
+ * tangles that share such a range are one.
598
+ */
599
+ function tangles(owned, owners) {
600
+ const cores = spanGroups(owned, owners);
601
+ const bounds = [];
602
+ cores.forEach((core, i) => {
603
+ const last = bounds.at(-1);
604
+ if (core.ranges.length < 2) {
605
+ return;
606
+ }
607
+ if (last !== undefined && i - 1 <= last.hi) {
608
+ last.hi = Math.min(i + 1, cores.length - 1);
609
+ }
610
+ else {
611
+ bounds.push({ lo: Math.max(i - 1, 0), hi: Math.min(i + 1, cores.length - 1) });
612
+ }
613
+ });
614
+ return bounds.map(({ lo, hi }) => {
615
+ const run = cores.slice(lo, hi + 1);
616
+ return {
617
+ tangled: run.flatMap((c) => (c.ranges.length > 1 ? c.ranges : [])),
618
+ ranges: run.flatMap((c) => c.ranges),
619
+ steps: owned.slice(run[0]?.first ?? 0, (run.at(-1)?.last ?? -1) + 1),
620
+ };
621
+ });
622
+ }
623
+ /**
624
+ * The owned steps grouped by span, in build order: ranges whose spans overlap
625
+ * share a group, and each group carries where its first step and its last sit.
626
+ */
627
+ function spanGroups(owned, owners) {
628
+ const spans = new Map();
629
+ owned.forEach((n, i) => {
630
+ const range = owners.get(n);
631
+ if (range !== undefined) {
632
+ spans.set(range, { first: spans.get(range)?.first ?? i, last: i });
633
+ }
634
+ });
635
+ const groups = [];
636
+ for (const [range, span] of [...spans].sort((a, b) => a[1].first - b[1].first)) {
637
+ const group = groups.at(-1);
638
+ if (group !== undefined && span.first < group.last) {
639
+ group.ranges.push(range);
640
+ group.last = Math.max(group.last, span.last);
641
+ }
642
+ else {
643
+ groups.push({ ranges: [range], first: span.first, last: span.last });
644
+ }
645
+ }
646
+ return groups;
647
+ }
648
+ /**
649
+ * The tangle re-cut by the fewest steps moved between its ranges, as each
650
+ * step's range once re-cut; null when no cut keeps every range to its rules,
651
+ * when two cuts move as few, since the order settles neither, when the one
652
+ * cut splits a range's longest stretch, since that moves the range's body
653
+ * rather than a step, or when too many sequences of its ranges are in play.
654
+ *
655
+ * The ranges may run in any sequence their stretches allow, and each sequence
656
+ * is cut the way that keeps the most steps where they were.
657
+ */
658
+ function fewestMoves(tangle, owners) {
659
+ const stretches = stretchesOf(tangle.steps, owners);
660
+ const cuts = (rangeOrders(tangle.ranges, stretches) ?? []).flatMap((order) => cutInOrder(order, tangle.steps, owners) ?? []);
661
+ const most = Math.max(...cuts.map((c) => c.kept));
662
+ const best = cuts.filter((c) => c.kept === most);
663
+ const [only] = best;
664
+ if (only === undefined || best.length > 1 || only.ways > 1 || !keepsBodies(tangle, stretches, only.placed)) {
665
+ return null;
666
+ }
667
+ return new Map(only.placed);
668
+ }
669
+ /**
670
+ * Whether a cut keeps each range's body: one of its longest stretches, whole,
671
+ * so a re-cut moves the steps the order moved and never the bulk of a range.
672
+ */
673
+ function keepsBodies(tangle, stretches, placed) {
674
+ return tangle.ranges.every((range) => {
675
+ const own = stretches.filter((s) => s.range === range);
676
+ const longest = Math.max(...own.map((s) => s.size));
677
+ return own.some((s) => s.size === longest && tangle.steps.slice(s.start, s.start + s.size).every((n) => placed.get(n) === range));
678
+ });
679
+ }
680
+ /**
681
+ * The sequences a tangle's ranges may run in: each range placed where one of
682
+ * its stretches sits, so every sequence a cut could take, each once; null when
683
+ * the stretches would make more than `ORDER_CAP` choices.
684
+ */
685
+ function rangeOrders(ranges, stretches) {
686
+ const anchors = ranges.map((range) => stretches.filter((s) => s.range === range));
687
+ if (anchors.reduce((n, a) => n * a.length, 1) > ORDER_CAP) {
688
+ return null;
689
+ }
690
+ const choices = anchors.reduce((sets, options) => sets.flatMap((set) => options.map((o) => [...set, o])), [[]]);
691
+ const orders = new Map();
692
+ for (const choice of choices) {
693
+ const order = [...choice].sort((a, b) => a.start - b.start).map((s) => s.range);
694
+ orders.set(order.map((r) => r.item).join(), order);
695
+ }
696
+ return [...orders.values()];
697
+ }
698
+ /** A tangle's steps as stretches, each an unbroken run of one range's steps. */
699
+ function stretchesOf(steps, owners) {
700
+ const runs = [];
701
+ steps.forEach((n, i) => {
702
+ const range = owners.get(n);
703
+ const last = runs.at(-1);
704
+ if (range !== undefined && last?.range === range && last.start + last.size === i) {
705
+ last.size += 1;
706
+ }
707
+ else if (range !== undefined) {
708
+ runs.push({ range, start: i, size: 1 });
709
+ }
710
+ });
711
+ return runs;
712
+ }
713
+ /**
714
+ * A tangle cut in one sequence of its ranges, keeping the most steps in the
715
+ * range that named them; null when no cut keeps every range to its rules.
716
+ *
717
+ * Each range takes one block of the tangle's steps, in the sequence given, and
718
+ * each block holds a step of its own; a bare range's block is its one step. A
719
+ * block ends on a step of its own unless a bare range follows it, so a step
720
+ * moved between two ranges joins the one it builds ahead of, or the one before
721
+ * when the one ahead is bare, the rule a step no range names joins by. The
722
+ * search keeps, for each range in turn and each step its block may end at, the
723
+ * most steps kept, so it costs the ranges times the steps squared.
724
+ */
725
+ function cutInOrder(order, steps, owners) {
726
+ const rows = [[{ kept: 0, ways: 1, from: -1 }]];
727
+ order.forEach((range, j) => {
728
+ const counts = runningCount(steps, owners, range);
729
+ const row = [];
730
+ rows[j]?.forEach((cell, from) => {
731
+ const prior = order[j - 1];
732
+ if (cell === undefined || (prior !== undefined && !range.bare && owners.get(steps[from - 1] ?? 0) !== prior)) {
733
+ return;
734
+ }
735
+ for (const block of rangeBlocks(range, counts, from)) {
736
+ keep(row, block.to, cell, block.kept, from);
737
+ }
738
+ });
739
+ rows.push(row);
740
+ });
741
+ const end = rows[order.length]?.[steps.length];
742
+ return end === undefined ? null : { kept: end.kept, ways: end.ways, placed: walkBack(rows, order, steps) };
743
+ }
744
+ /** How many of a range's own steps sit among a tangle's first `i` steps, for every `i` from none to all. */
745
+ function runningCount(steps, owners, range) {
746
+ const counts = [0];
747
+ for (const n of steps) {
748
+ counts.push((counts.at(-1) ?? 0) + (owners.get(n) === range ? 1 : 0));
749
+ }
750
+ return counts;
751
+ }
752
+ /**
753
+ * The blocks a range can take from a tangle's step `from`: where each ends,
754
+ * and how many of the range's own steps it keeps. Each keeps one at least, and
755
+ * a bare range's block is its one step.
756
+ */
757
+ function rangeBlocks(range, counts, from) {
758
+ const last = Math.min(range.bare ? from + 1 : Infinity, counts.length - 1);
759
+ const blocks = [];
760
+ for (let to = from + 1; to <= last; to += 1) {
761
+ const kept = (counts[to] ?? 0) - (counts[from] ?? 0);
762
+ if (kept > 0) {
763
+ blocks.push({ to, kept });
764
+ }
765
+ }
766
+ return blocks;
767
+ }
768
+ /**
769
+ * Record a block in the re-cut's search, in place: the cell where it ends keeps
770
+ * the most steps any way there does, and a second way to as many is a tie.
771
+ */
772
+ function keep(row, to, prior, kept, from) {
773
+ const total = prior.kept + kept;
774
+ const at = row[to];
775
+ if (at === undefined || total > at.kept) {
776
+ row[to] = { kept: total, ways: prior.ways, from };
777
+ }
778
+ else if (total === at.kept) {
779
+ row[to] = { ...at, ways: Math.min(2, at.ways + prior.ways) };
780
+ }
781
+ }
782
+ /** Each step's range along the cut the search kept, read back from the last range's block. */
783
+ function walkBack(rows, order, steps) {
784
+ const placed = new Map();
785
+ let to = steps.length;
786
+ order.forEach((_, i) => {
787
+ const j = order.length - i;
788
+ const from = rows[j]?.[to]?.from ?? 0;
789
+ const range = order[j - 1];
790
+ if (range !== undefined) {
791
+ steps.slice(from, to).forEach((n) => placed.set(n, range));
792
+ }
793
+ to = from;
794
+ });
795
+ return placed;
796
+ }
797
+ /**
798
+ * Give each run of steps no range names the range the order puts it in, in
799
+ * place in `placed`, read from the ranges on either side of the run. A run no
800
+ * range can take stays in none, and the dashboard flags it as before.
801
+ */
802
+ function placeUnnamed(undone, placed, unsettled) {
803
+ const named = undone.map((n) => placed.get(n));
804
+ let start = 0;
805
+ while (start < undone.length) {
806
+ let end = start;
807
+ while (end < undone.length && named[end] === undefined) {
808
+ end += 1;
809
+ }
810
+ const range = end === start ? null : joinRange(named.slice(0, start).findLast((r) => r !== undefined), named[end], unsettled);
811
+ if (range !== null) {
812
+ undone.slice(start, end).forEach((n) => placed.set(n, range));
813
+ }
814
+ start = Math.max(end, start + 1);
815
+ }
816
+ }
817
+ /**
818
+ * The range a run of steps no range names joins, from the ranges on either
819
+ * side of it in build order: the range around it, else the one it builds ahead
820
+ * of, else the one it builds after. A bare range takes no other step, and a
821
+ * range the order left tangled takes none either, since where its stretch runs
822
+ * is still the human's call.
823
+ */
824
+ function joinRange(before, after, unsettled) {
825
+ if ((before !== undefined && unsettled.has(before)) || (after !== undefined && unsettled.has(after))) {
826
+ return null;
827
+ }
828
+ if (before !== undefined && before === after) {
829
+ return before;
830
+ }
831
+ return [after, before].find((r) => r !== undefined && !r.bare) ?? null;
832
+ }
833
+ /**
834
+ * Write each re-cut range's command into its item's opener: its steps in build
835
+ * order, a landing range down to one step written `9-9` so it still lands. The
836
+ * stop, the model, and every other line of the item stay as written. Null when
837
+ * an opener does not hold its command in its first backticks, so the caller
838
+ * writes nothing rather than half a re-cut.
839
+ */
840
+ function writeRecut(intent, ranges, recut) {
841
+ const lines = intent.split('\n');
842
+ for (const cut of recut) {
843
+ const range = ranges.find((r) => r.item === cut.item);
844
+ const at = (range?.line ?? 0) - 1;
845
+ const opener = lines[at] ?? '';
846
+ const quoted = /`([^`]*)`/.exec(opener);
847
+ if (range === undefined || quoted === null || (quoted[1] ?? '').trim() !== range.command) {
848
+ return null;
849
+ }
850
+ const command = `\`/plumbbob:build ${leftExpression(range, cut.steps)}\``;
851
+ lines[at] = `${opener.slice(0, quoted.index)}${command}${opener.slice(quoted.index + quoted[0].length)}`;
852
+ }
853
+ return lines.join('\n');
854
+ }
518
855
  // --- The run: what `/plumbbob:run` names. The turn hook reads it to mint the
519
856
  // run's grant, `status --run` to print the run header, and the card to name the
520
857
  // run beneath Next Up, so all three read one answer from one plan. ---
@@ -7,15 +7,18 @@
7
7
  // `checkpoint`, `handoff`), so the dashboard's `← next`, a bare
8
8
  // `plumbbob build`, and the card's Next Up agree. The verb validates what a
9
9
  // hand edit cannot: every number names an undone step, none twice. `--reset`
10
- // drops the line, and the plan reads in document order again. It runs in any
11
- // state, since re-sequencing what comes after the step in flight is the
12
- // mid-step use, and the sidecar sits outside every seam.
10
+ // drops the line, and the plan reads in document order again. Either way the
11
+ // build ranges are re-cut to follow the order written, wherever the order
12
+ // settles which range a step is in, so a step placed next runs in the range
13
+ // it now builds with. It runs in any state, since re-sequencing what comes
14
+ // after the step in flight is the mid-step use, and the sidecar sits outside
15
+ // every seam.
13
16
  import { readFileSync, writeFileSync } from 'node:fs';
14
17
  import { findRepoRoot } from "../lib/git.js";
15
18
  import { activeBuild, hasSession, intentPath } from "../lib/sidecar.js";
16
- import { clearBuildOrder, orderSteps, parseSteps, setBuildOrder } from "../lib/orient.js";
17
- import { expandStepList, parseStepList } from "../lib/steplist.js";
18
- import { ending, notice, printEnding, transition } from "../lib/notice.js";
19
+ import { clearBuildOrder, orderSteps, parseBuildRanges, parseOrderedSteps, parseSteps, rangeStanding, recutPlan, setBuildOrder, } from "../lib/orient.js";
20
+ import { expandStepList, formatStepList, parseStepList } from "../lib/steplist.js";
21
+ import { advisory, blocks, ending, notice, printEnding, transition } from "../lib/notice.js";
19
22
  import { driverPointer } from "./handoff.js";
20
23
  /**
21
24
  * Set the build order to the given step numbers and spans, the rest of the
@@ -36,15 +39,7 @@ export function order(cwd, args) {
36
39
  const path = intentPath(root);
37
40
  const intent = readFileSync(path, 'utf8');
38
41
  if (args.includes('--reset')) {
39
- writeFileSync(path, clearBuildOrder(intent));
40
- printEnding(root, {
41
- tier: 'driver',
42
- render: 'order',
43
- text: ending({
44
- lead: transition({ label: 'Build order', fact: 'document order' }),
45
- pointer: driverPointer(root, activeBuild(root)),
46
- }),
47
- });
42
+ writeOrder(root, path, clearBuildOrder(intent), 'document order');
48
43
  return 0;
49
44
  }
50
45
  // Numbers and spans arrive space- or comma-separated, with or without a space
@@ -98,17 +93,66 @@ export function order(cwd, args) {
98
93
  const full = orderSteps(steps, given)
99
94
  .filter((s) => !s.done)
100
95
  .map((s) => s.n);
101
- writeFileSync(path, setBuildOrder(intent, full));
102
- // The pointer reads the plan back from disk, so it already follows the
103
- // order just written: back at the step in flight, or forward from the
104
- // boundary to the first step in the new sequence.
96
+ writeOrder(root, path, setBuildOrder(intent, full), full.join(', '));
97
+ return 0;
98
+ }
99
+ /**
100
+ * Write the plan under its new build order, its ranges re-cut to follow it,
101
+ * and print the ending: the order, the ranges the re-cut changed, any range it
102
+ * left for the human to re-cut, and the pointer.
103
+ *
104
+ * The pointer reads the plan back from disk, so it already follows the order
105
+ * just written: back at the step in flight, or forward from the boundary to
106
+ * the first step in the new sequence.
107
+ */
108
+ function writeOrder(root, path, ordered, sequence) {
109
+ const { intent, recut } = recutPlan(ordered);
110
+ writeFileSync(path, intent);
111
+ const standing = rangeStanding(parseBuildRanges(intent), parseOrderedSteps(intent), null);
105
112
  printEnding(root, {
106
113
  tier: 'driver',
107
114
  render: 'order',
108
115
  text: ending({
109
- lead: transition({ label: 'Build order', fact: full.join(', ') }),
116
+ lead: blocks([transition({ label: 'Build order', fact: sequence }), recutLead(recut, standing.count)]),
117
+ advisories: rangeAdvisories(standing),
110
118
  pointer: driverPointer(root, activeBuild(root)),
111
119
  }),
112
120
  });
113
- return 0;
121
+ }
122
+ /**
123
+ * The lead naming each range the re-cut changed and the steps it names now,
124
+ * `range 2 of 3 re-cut (steps 10,4-6)`; null when it changed none.
125
+ */
126
+ function recutLead(recut, count) {
127
+ const [only] = recut;
128
+ if (only === undefined) {
129
+ return null;
130
+ }
131
+ const steps = (cut) => `${cut.steps.length === 1 ? 'step' : 'steps'} ${formatStepList(cut.steps)}`;
132
+ const items = recut.map((cut) => cut.item).join(', ');
133
+ return transition({
134
+ label: 'Build ranges',
135
+ fact: `${recut.length === 1 ? 'range' : 'ranges'} ${items} of ${count} re-cut`,
136
+ detail: recut.length === 1 ? [steps(only)] : recut.map((cut) => `range ${cut.item}: ${steps(cut)}`),
137
+ });
138
+ }
139
+ /**
140
+ * Every range still flagged once the re-cut is written, in the words the
141
+ * dashboard flags it with, and beneath the last the move that re-cuts it by
142
+ * hand: the ties the order did not settle, a step no range can take, and the
143
+ * plan's other drift. Empty when every undone step sits in one stretch of one
144
+ * range.
145
+ */
146
+ function rangeAdvisories(standing) {
147
+ const { count, drift } = standing;
148
+ const own = drift.filter((d) => d.item !== null && d.kind !== 'malformed');
149
+ const flagged = [...new Set(own.map((d) => d.item))].map((item) => ({
150
+ fact: `range ${item} of ${count} needs a re-cut`,
151
+ detail: own.filter((d) => d.item === item).map((d) => d.words),
152
+ }));
153
+ const lines = [...flagged, ...drift.filter((d) => d.item === null || d.kind === 'malformed').map((d) => ({ fact: d.words }))];
154
+ if (lines.length === 0) {
155
+ return [];
156
+ }
157
+ return [lines.map((l, i) => advisory(i === lines.length - 1 ? { ...l, remedy: '/plumbbob:refine ranges' } : l)).join('')];
114
158
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plumbbob",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
4
4
  "description": "Attention-first build process: 15 skills + a CLI that keep you the decider — guidance on the work, a latch on the record.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -77,18 +77,23 @@ the call on each one theirs. The detail file you write first is the proposal; re
77
77
  - A confirmed **blocker** also folds its decision into `intent.md`, slugged at birth
78
78
  like any Decision, anchored the same way
79
79
  (`- <a id="d5"></a>**D5 (retry-cap)**: <the call>, *because* <why>`), never a bare `D5`.
80
- A blocker that becomes a new step takes the next free number under `## Steps` and,
81
- when it has to land before an existing undone step, its place in the sequence through
82
- `plumbbob order 7 8 5` (the `## Build order` line), never by renumbering. When the plan
83
- carries `## Build ranges`, the new step goes into a range too: a range still to come
84
- (the one it builds with), or a bare range of its own when it brings a call the human
85
- has to make before it lands, and never a range that has landed, since that range's call
86
- was made and the step would reopen it. A new range takes the next free item number, and
87
- no item is renumbered, because the stop card's stamp, the detail file's header, and
88
- `plumbbob harvest` find a landed range by its number. A range the step breaks is re-cut
89
- in the same confirmed edit
90
- ([D92 (build-ranges)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d92));
91
- `plumbbob status` afterward carries a `⚠` row for any range still broken.
80
+ A blocker that becomes a new step takes the next free number under `## Steps` and its
81
+ place in the sequence through `plumbbob order 7 8 5` (the `## Build order` line, every
82
+ undone step in the sequence to build them), never by renumbering, even when it builds
83
+ last. When the plan carries `## Build ranges`, the verb files the new step for you: it
84
+ re-cuts the ranges to follow the sequence it writes, so the step joins the range still
85
+ to come that the order puts it in, the one it builds ahead of when it lands first, and
86
+ never a range that has landed, since that range's call was made and the step would
87
+ reopen it
88
+ ([D109 (ranges-follow-the-order)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d109)).
89
+ Give the step a bare range of its own instead, written before you run the verb, when it
90
+ brings a call the human has to make before it lands. A new range takes the next free item
91
+ number, and no item is renumbered, because the stop card's stamp, the detail file's
92
+ header, and `plumbbob harvest` find a landed range by its number
93
+ ([D92 (build-ranges)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d92)).
94
+ The verb's ending names each range it re-cut, and an advisory names any it left for
95
+ you; re-cut that one in the same confirmed edit, and `plumbbob status` afterward carries
96
+ a `⚠` row for any range still broken.
92
97
  - Fold a confirmed blocker into `intent.md` before you run `plumbbob harvest`, so the
93
98
  record and the plan agree when it lands.
94
99
  - Never reclassify or resolve an item the human hasn't confirmed, and default every
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: order
3
- description: "Set the build order: turn the human's sequence (or a relative one, such as 7 and 8 before 5) into the full list of undone steps and write it by shelling `plumbbob order`, never by editing intent.md. A step keeps its number; the sequence moves."
3
+ description: "Set the build order: turn the human's sequence (or a relative one, such as 7 and 8 before 5) into the full list of undone steps and write it by shelling `plumbbob order`, never by editing intent.md. A step keeps its number; the sequence moves, and the CLI re-cuts the build ranges to follow it."
4
4
  argument-hint: "[steps in the sequence to build them, such as 1, 6, 2-4, 7, 5]"
5
5
  disable-model-invocation: true
6
6
  model: haiku
@@ -15,7 +15,10 @@ A step keeps its number for life, so once `/plumbbob:step` or `/plumbbob:refine`
15
15
  that has to land before an existing one, the sequence to build them stops being the
16
16
  numbering (`4, 7, 8, 5, 10, 6`). This skill writes that sequence down where every other move
17
17
  reads it: the `## Build order` line in `intent.md`, which `plumbbob status`, a bare
18
- `/plumbbob:build`, `plumbbob checkpoint`, and the card's Next Up all follow.
18
+ `/plumbbob:build`, `plumbbob checkpoint`, and the card's Next Up all follow. When the plan
19
+ carries `## Build ranges`, the same verb re-cuts them to follow the sequence, so
20
+ `/plumbbob:run` walks the steps the new order put in its range
21
+ ([D109 (ranges-follow-the-order)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d109)).
19
22
 
20
23
  ## Wrong-state refusal
21
24
 
@@ -39,22 +42,22 @@ after the step in flight is the usual reason to run it.
39
42
  as typed, and the line it writes spells the span out. `plumbbob order --reset` drops the
40
43
  line, and the plan reads in document order again.
41
44
  4. **Relay the ending verbatim, then stop.** The verb prints its whole ending: the
42
- `**Build order**: 7, 8, 5, 10, 6` line, a blank line, and the `**Next Up**` pointer (back
43
- at the step in flight, or forward to the first step in the new sequence). Run no second
44
- command but the one in step 5. A refusal (a number the plan lacks, a repeat, a step
45
- already checkpointed) is the CLI's call: report it verbatim, never retry or work around
46
- it, and ask the human which number they meant.
47
- 5. **Read the ranges when the plan has them.** If the dashboard injected above shows a
48
- `ranges` block, run `plumbbob status` after the verb and read that block again. A `⚠`
49
- row that was not there before means the new sequence broke a range (most often
50
- `the build order puts step 11 inside it`). This skill edits nothing, so it cannot re-cut
51
- one: open the turn with one line naming the range, its drift words, and
52
- `/plumbbob:refine` as the move that re-cuts it, then the verb's ending, whole, as the
53
- turn's last text. With no new `⚠` row, or no ranges block, relay the ending alone.
45
+ `**Build order**: 7, 8, 5, 10, 6` line; a
46
+ `**Build ranges**: range 2 of 3 re-cut (steps 10,4-6)` line when the new sequence moved a
47
+ step into or out of a range, naming each range it re-cut; an advisory for each range the
48
+ order left for the human, in the words its dashboard row carries
49
+ (`Range 2 of 3 needs a re-cut ⚠ (...)`), with
50
+ `→ /plumbbob:refine ranges` beneath the last; and the `**Next Up**` pointer (back at the
51
+ step in flight, or forward to the first step in the new sequence). Relay it whole as the
52
+ turn's last text and run no second command: the advisory already names the range and the
53
+ move, so there is no dashboard to read again. A refusal (a number the plan lacks, a
54
+ repeat, a step already checkpointed) is the CLI's call: report it verbatim, never retry
55
+ or work around it, and ask the human which number they meant.
54
56
 
55
57
  ## The one hard contract
56
58
 
57
59
  The line is written by the **dumb CLI**, never by an edit. This skill carries **no Edit and
58
60
  no Write tool** on purpose: you may not touch `intent.md` yourself. Compose, get approval,
59
- then shell `plumbbob order`; that is the only write path, and it is the one that validates
60
- every number against the plan.
61
+ then shell `plumbbob order`; that is the only write path, it validates every number against
62
+ the plan, and it is the one that re-cuts the ranges, so a range the order leaves for the
63
+ human is `/plumbbob:refine`'s to re-cut, never yours to edit here.
@@ -54,18 +54,25 @@ is fine; refining is always available.
54
54
  Show the before/after for each, and **write only what the human approves**. A repair
55
55
  that inserts a step, or moves one ahead of another, keeps every number where it is and
56
56
  writes the sequence instead: shell `plumbbob order 7 8 5` with the undone steps in the
57
- sequence to build them, and the `## Build order` line it writes is what every next-step
58
- pick follows. Never renumber, and never edit that line by hand.
57
+ sequence to build them, an inserted step included even when it builds last, and the
58
+ `## Build order` line it writes is what every next-step pick follows. Never renumber,
59
+ and never edit that line by hand.
59
60
 
60
- **A repair keeps the build ranges true.** When the plan carries `## Build ranges`, a
61
- step the repair adds goes into a range (the one it builds with, or a bare range of its
62
- own when it brings a call the human has to make before it lands), never a range that
63
- has landed, since that range's call was made and the step would reopen it. A new range
64
- takes the next free item number, and no item is renumbered, because the stop card's
65
- stamp, the detail file's header, and `plumbbob harvest` find a landed range by its
66
- number. A range the repair breaks is re-cut in the same before/after
61
+ **A repair keeps the build ranges true.** When the plan carries `## Build ranges`,
62
+ `plumbbob order` re-cuts them to follow the sequence it writes
63
+ ([D109 (ranges-follow-the-order)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d109)):
64
+ a step it places joins the range whose steps sit on both sides of it, else the one it
65
+ builds ahead of, else the one it builds after, never a bare range, and never a range that
66
+ has landed, since that range's call was made and the step would reopen it; its ending
67
+ names each range it re-cut. Edit a range yourself only where the
68
+ order cannot say it, and before you run the verb: a bare range of its own for a step
69
+ that brings a call the human has to make before it lands, or a re-cut that moves where a
70
+ call falls, shown in the same before/after
67
71
  ([D92 (build-ranges)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d92)).
68
- Read `plumbbob status` once the edits are written: its ranges block carries a `⚠` row
72
+ A new range takes the next free item number, and no item is renumbered, because the stop
73
+ card's stamp, the detail file's header, and `plumbbob harvest` find a landed range by its
74
+ number. A range the verb's ending names as still needing a re-cut is yours to re-cut in
75
+ the same approved edit. Read `plumbbob status` once the edits are written: its ranges block carries a `⚠` row
69
76
  for a range naming a step the plan lacks, a step two ranges name, a step the build order
70
77
  puts inside a range that does not name it, and an undone step no range names, and none
71
78
  once the section is sound.
@@ -48,29 +48,33 @@ grew, but its everyday job is to sharpen, not to invent.
48
48
  `**Scope:**` header; drop the scope and it falls back to that default → build slug →
49
49
  bare, and the type to `feat` ([D68 (conventional-subjects)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d68)). Aim for a soft
50
50
  ≤72 chars, no gate. Revise the existing step in place; only append when you are
51
- genuinely adding an increment. An appended step keeps the next free number for life;
52
- when it has to land before an existing undone step, place it by sequence, never by
53
- renumbering: shell `plumbbob order 7 8 5` with every undone step in the sequence to
54
- build them, and the `## Build order` line it writes is what `status`, a bare
51
+ genuinely adding an increment. An appended step keeps the next free number for life,
52
+ and it takes its place by sequence, never by renumbering: shell `plumbbob order 7 8 5`
53
+ with every undone step in the sequence to build them, the new step included even when
54
+ it builds last, and the `## Build order` line it writes is what `status`, a bare
55
55
  `/plumbbob:build`, and the card's Next Up then follow.
56
56
 
57
- **Keep the build ranges true.** When the plan carries `## Build ranges`, a step you add
58
- goes into a range: the one it builds with (`/plumbbob:build 6-9` becomes `6-10`, or
59
- `6-9,14` for a step numbered past the next range), or a bare range of its own when it
60
- brings a call the human has to make before it lands
57
+ **Keep the build ranges true.** When the plan carries `## Build ranges`,
58
+ `plumbbob order` files the step for you: it re-cuts the ranges to follow the sequence it
59
+ writes, so a step joins the range whose steps sit on both sides of it, else the range it
60
+ builds ahead of, else the one it builds after
61
+ ([D109 (ranges-follow-the-order)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d109)):
62
+ never a bare range, and never a range that has landed, since that range's call was made
63
+ and the step would reopen it. Edit `## Build ranges` yourself only where the order
64
+ cannot say what you mean, and before you run the verb. A step that brings a call the
65
+ human has to make before it lands gets a bare range of its own
61
66
  ([D92 (build-ranges)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d92)).
62
- Either way it is a range still to come, never a range that has landed, since that
63
- range's call was made and the step would reopen it. A new range takes the next free
64
- item number, and no item is renumbered, because the stop card's stamp, the detail
65
- file's header, and `plumbbob harvest` find a landed range by its number.
66
- A step the build order places between two of a range's steps belongs to that range. A
67
- split or re-cut that moves where a call falls moves the stop with it, so re-cut any
68
- range the edit breaks, and show the range edit in the same before/after, since it
69
- changes where the human will be asked. The edit lands on the item's command, the
70
- range's definition, not on the command the card names: the card names `/plumbbob:run`
71
- for the range, and the turn hook mints a run's grant from the plan as the human types
72
- the run, so the next run lands the range as you re-cut it, while a run already typed
73
- keeps the grant it minted
67
+ A new range takes the next free item number, and no item is renumbered, because the
68
+ stop card's stamp, the detail file's header, and `plumbbob harvest` find a landed range
69
+ by its number. A split or re-cut that moves where a call falls moves the stop with it. Show
70
+ any range edit in the same before/after, since it changes where the human will be
71
+ asked. The verb's ending names each range it re-cut
72
+ (`**Build ranges**: range 3 of 5 re-cut (steps 14,6-9)`), and an advisory beneath it
73
+ names any range the order left for you: re-cut that one by hand, in the same approved
74
+ edit. The edit lands on the item's command, the range's definition, not on the command
75
+ the card names: the card names `/plumbbob:run` for the range, and the turn hook mints a
76
+ run's grant from the plan as the human types the run, so the next run lands the range
77
+ as re-cut, while a run already typed keeps the grant it minted
74
78
  ([D96 (plan-names-the-grant)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d96)).
75
79
  Then read `plumbbob status`: its ranges block carries a `⚠` row for any range still
76
80
  broken, and none once every undone step sits in exactly one range.
@@ -88,9 +92,10 @@ grew, but its everyday job is to sharpen, not to invent.
88
92
  - **One verifiable increment.** Each step carries a done-when `/plumbbob:verify` can check
89
93
  and a seam small enough to review in one pass.
90
94
  - **Edit `## Steps` only**, in the standard format `status` and `build` parse, plus the
91
- `## Build ranges` items a step edit moves: never the Roadmap, never loose prose. A done
92
- step (`[x]`) is history; do not rewrite it. The `## Build order` line is
93
- `plumbbob order`'s to write, never yours to edit.
95
+ `## Build ranges` items the order cannot settle (a bare range of its own, a stop that
96
+ moves): never the Roadmap, never loose prose. A done step (`[x]`) is history; do not
97
+ rewrite it. The `## Build order` line is `plumbbob order`'s to write, never yours to
98
+ edit, and so is a range the order re-cuts.
94
99
  - **Cite the plan's own numbers in the linked form.** A `D`/`C`/`Q` you write into a
95
100
  step's done-when, seam, or notes reads `[D4 (default-waves)](#d4)`; the slug travels
96
101
  with the number, and the link lands on the `<a id="d4">` anchor minted where the item