plumbbob 0.13.3 → 0.13.5

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.3",
4
+ "version": "0.13.5",
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
@@ -102,7 +102,7 @@ payoff is [measured, not asserted](#why-not-just), not just claimed. **Guidance
102
102
  work, a latch on the record.**
103
103
 
104
104
  <p align="center">
105
- <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">
106
106
  </p>
107
107
 
108
108
  ## Install
@@ -150,12 +150,7 @@ In Claude Code, inside any git repo with a clean tree:
150
150
  diff, the model supplies only the judgment, and nothing is written around it.
151
151
 
152
152
  ````markdown
153
- **Now**: Step 1 of 3 - feat(limiter): add a token-bucket limiter
154
-
155
- **Summary**: A per-IP token bucket now refuses the 6th attempt inside a minute. (details: [detail.md](.plumbbob/detail.md))
156
-
157
- 1. The bucket lives in memory, keyed by IP, refilling on read: five tokens a minute.
158
- 2. The clock is injected, so the test advances time instead of sleeping on it.
153
+ **Summary**: A per-IP token bucket now refuses the 6th attempt inside a minute.
159
154
 
160
155
  **Readout**:
161
156
 
@@ -166,22 +161,27 @@ In Claude Code, inside any git repo with a clean tree:
166
161
  constraints 1 of 1 honored
167
162
  seam held: 2 of 2 declared, no strays
168
163
  diff +61 -3 across 2 files
164
+ - src/limiter.ts +38 -3
165
+ - test/limiter.test.ts +23 -0
169
166
  spent 22 min · 2 turns · 41s gate · green first run
170
167
  ```
171
168
 
172
- **Verdict**: ● Plumb
173
-
174
- **Next Up**: Step 2 of 3 - feat(login): wire the limiter into POST /login (model: **Sonnet**, details: [intent.md:28](.plumbbob/builds/2026-07-03-rate-limit-the-login-endpoint/intent.md#L28))
175
- · range 1 of 2 lands with step 1
176
-
177
169
  **Your Call**:
178
170
 
179
171
  - `looks good` → I checkpoint Step 1; back to the boundary
180
172
  - `expand`, or any question → I show more of what is there; nothing changes, and `show the pause` brings all of this back
181
- - anything that reads as direction → I take it as what to change; nothing lands until you approve
173
+ - anything that reads as direction → I make the change and say what moved; nothing lands until you approve
182
174
  - `revert` → I wind the work back to the last checkpoint
183
175
 
176
+ | **Verdict** | ● Plumb |
177
+ | --- | --- |
178
+ | Now | Step 1 of 3 - feat(limiter): add a token-bucket limiter |
179
+ | Next Up | Step 2 of 3 - feat(login): wire the limiter into POST /login (model: **Sonnet**, details: [intent.md:28](.plumbbob/builds/2026-07-03-rate-limit-the-login-endpoint/intent.md#L28)) |
180
+ | Range | 1 of 2 lands with step 1 |
181
+
184
182
  **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.
183
+
184
+ [more details](.plumbbob/detail.md)
185
185
  ````
186
186
 
187
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)
@@ -192,30 +192,30 @@ In Claude Code, inside any git repo with a clean tree:
192
192
  `.plumbbob/settings.json`, for example `"check": "npm test"`; in a repo where
193
193
  checkride finds no tools at all, `start` says so up front and names that key.)
194
194
  3. **Reply.** Say `looks good` and it commits the step as a checkpoint, marks it done,
195
- and returns to the boundary, where the line beneath Next Up names the run to type
195
+ and returns to the boundary, where the Range row beneath Next Up names the run to type
196
196
  next and the steps it will land:
197
197
 
198
198
  ```text
199
199
  **Checkpoint**: Step 1 complete (a1b2c3d4e, details: [build-log.md:70](.plumbbob/builds/2026-07-03-rate-limit-the-login-endpoint/build-log.md#L70))
200
200
 
201
- **Verdict**: ● Plumb
202
-
203
- **Next Up**: Step 2 of 3 - feat(login): wire the limiter into POST /login (model: **Sonnet**, details: [intent.md:28](.plumbbob/builds/2026-07-03-rate-limit-the-login-endpoint/intent.md#L28))
204
- · range 2 of 2: /plumbbob:run (steps 2-3)
201
+ | **Verdict** | ● Plumb |
202
+ | --- | --- |
203
+ | Next Up | Step 2 of 3 - feat(login): wire the limiter into POST /login (model: **Sonnet**, details: [intent.md:28](.plumbbob/builds/2026-07-03-rate-limit-the-login-endpoint/intent.md#L28)) |
204
+ | Range | 2 of 2: /plumbbob:run (steps 2-3) |
205
205
  ```
206
206
 
207
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)
208
208
 
209
209
  Ask instead (`expand 2`, or any question) and it shows more of what is there and
210
- changes nothing. The answer closes on one line pointing back at the step, not on the
211
- whole block again, since you have already read it; say `show the pause` to see the
212
- block again. Direct it and it takes that as the fix, and nothing lands until you say
213
- `looks good`.
210
+ changes nothing. Direct it and it makes the change and tells you what moved. Either
211
+ way the reply is its own, and it closes on one line pointing back at the step, not on
212
+ the whole block again, since you have already read it; say `show the pause` to see
213
+ the block again. Nothing lands until you say `looks good`.
214
214
  4. **Run again.** Fire `/plumbbob:run` again; re-firing it *is* the clock tick. Range 2
215
215
  lands steps 2 and 3, each on a green check with no pause between them, and ends on the
216
216
  **stop card**: the range's review (a summary of both steps, a readout measured across
217
- them, the Verdict), with every idea parked along the way listed under `Harvested` and
218
- a lean proposed for each. One reply settles it: `looks good` records the review and
217
+ them, the Verdict), with every idea parked along the way in a table under `Harvested`:
218
+ each park, why it matters, and the lean proposed for it. One reply settles it: `looks good` records the review and
219
219
  every lean, and `make P1 a blocker` changes one first. `/plumbbob:build` stays the
220
220
  granular move whenever you want it, one step to its pause. Whenever you lose the
221
221
  thread, `/plumbbob:status` shows where you are and names the next move.
package/dist/cli-core.js CHANGED
@@ -87,17 +87,18 @@ const VERBS = [
87
87
  },
88
88
  {
89
89
  name: 'handoff',
90
- description: 'print the footer card that ends a turn (pause, boundary, plan, driver, or stop), or a reminder for a pause or a stop already shown',
91
- synopsis: ['handoff [<n>] [--full] [--plan] [--driver] [--stop] [--build <slug>]'],
90
+ description: 'print the footer card that ends a turn (pause, boundary, plan, driver, stop, or harvest), or the one line for a card already shown',
91
+ synopsis: ['handoff [<n>] [--full] [--plan] [--driver] [--stop] [--harvest] [--build <slug>]'],
92
92
  args: [{ name: '<n>', gloss: 'step to report on (default: the in-flight step, else the last checkpointed one)' }],
93
93
  flags: [
94
- { name: '--full', gloss: 'print the whole pause card, or with --stop the whole stop card, even when it was already shown' },
94
+ { name: '--full', gloss: 'print the whole pause card or boundary, or with --stop the whole stop card, even when it was already shown' },
95
95
  { name: '--plan', gloss: 'render the plan-pause card instead of a step tier' },
96
96
  { name: '--driver', gloss: "render a driver turn's pointer back at the open step" },
97
97
  { name: '--stop', gloss: "render the stop card for the range the last landing completed, from the range's detail file" },
98
+ { name: '--harvest', gloss: "render a boundary harvest's lean table and its moves, from a `# Detail · Harvest` detail file" },
98
99
  BUILD_FLAG,
99
100
  ],
100
- notes: "Writes only the detail file (a blank line around each heading), the PAUSE stamp, and with --stop the STOP stamp; refuses (exit 1) with no session, with two of --plan, --driver, and --stop together, and with --stop when a step is in flight, no range that lands its steps just landed, or the detail file is not that range's.",
101
+ notes: "Writes only the detail file (a blank line around each heading), the PAUSE stamp naming the card shown, and with --stop the STOP stamp; refuses (exit 1) with no session, with two of --plan, --driver, --stop, and --harvest together, with --stop when a step is in flight, no range that lands its steps just landed, or the detail file is not that range's, and with --harvest when the detail file is not a harvest's or no park is open.",
101
102
  },
102
103
  {
103
104
  name: 'check',
@@ -167,7 +168,7 @@ const VERBS = [
167
168
  name: 'harvest',
168
169
  description: "record a harvest from the detail file, and at a run's stop the range's review",
169
170
  synopsis: ['harvest'],
170
- notes: "Reads the detail file's `## Harvest` proposals (`- park 6 → tangent: <reason>`), flips each settled park in build-log.md, appends the dated Harvest lines, and, when the detail is a range's, writes the range's review beneath a dated Log line; then clears the detail file and the STOP stamp, and commits nothing. Refuses (exit 1) with no session, a detail file that is no range's or harvest's, a range's detail in the turn its stop card printed, one no stop card showed, one whose range is open again, or one whose range a step joined after it landed, and a proposal naming a park the list lacks or a class other than blocker, tangent, or pivot.",
171
+ notes: "Reads the detail file's `## Harvest` proposals (`- P6 → tangent: <reason>`, with `- title:` and `- why:` lines beneath), flips each settled park in build-log.md, appends the dated Harvest lines, and, when the detail is a range's, writes the range's review beneath a dated Log line; then clears the detail file and the STOP stamp, and commits nothing. Refuses (exit 1) with no session, a detail file that is no range's or harvest's, a range's detail in the turn its stop card printed, a range no stop card showed, one whose range is open again, or one whose range a step joined after it landed, and a proposal naming a park the list lacks or a class other than blocker, tangent, or pivot.",
171
172
  },
172
173
  {
173
174
  name: 'spike',
@@ -11,9 +11,11 @@
11
11
  // the edge, and an optional indented arrow line carrying what to do next. One
12
12
  // shape, so moving it is one edit rather than a sweep. The pointer at the next
13
13
  // step is not a line's job: `handoff` renders one, and the verb prints it as
14
- // the ending's last part. The formatters are pure string assembly, no fs, no
15
- // gate; `printEnding` is the one function here that writes, and every ending
16
- // a verb prints goes through it so the relay check knows what was said.
14
+ // the ending's last part, either as the state table that closes a card or an
15
+ // orientation ending (D111 (state-table)) or as the one labeled line a driver
16
+ // turn ends on. The formatters are pure string assembly, no fs, no gate;
17
+ // `printEnding` is the one function here that writes, and every ending a verb
18
+ // prints goes through it so the relay check knows what was said.
17
19
  import { writeRelay } from "./sidecar.js";
18
20
  /** The column budget a notice line aims to fit, matching the readout fence. */
19
21
  const WIDTH = 80;
@@ -66,15 +68,48 @@ export function advisory(a) {
66
68
  return line(`${capitalize(trimFact(a.fact))} ⚠`, a);
67
69
  }
68
70
  /**
69
- * Assemble a transition's whole ending: the labeled lead line, the Verdict
70
- * where one was measured, the advisories, the Stop part where a range landed,
71
- * and the pointer.
71
+ * Assemble a transition's whole ending: the labeled lead line, the
72
+ * advisories, then the state table or the driver's pointer.
72
73
  *
73
74
  * The order is the type's field order, so a verb composes parts and never a
74
75
  * sequence. A part with nothing in it vanishes rather than leaving a gap.
75
76
  */
76
77
  export function ending(e) {
77
- return blocks([e.lead, e.verdict ?? null, ...(e.advisories ?? []), e.stop ?? null, e.pointer ?? null]);
78
+ return blocks([e.lead, ...(e.advisories ?? []), stateTable(e.state ?? []), e.pointer ?? null]);
79
+ }
80
+ /**
81
+ * Render the state table: two columns, one row per fact, the first row the
82
+ * header. '' with no rows, so a caller can pass a table it may not have.
83
+ *
84
+ * The header is the fact the human reads first, the Verdict wherever one was
85
+ * measured, and its label rides bold so it reads as the headline even where a
86
+ * renderer does not bolden a header row. The rows beneath keep plain labels.
87
+ * A table rather than labeled lines because these are the facts the human
88
+ * scans for, and a table is the shape that sets them apart from the prose
89
+ * around them (D111 (state-table)).
90
+ *
91
+ * ```text
92
+ * | **Verdict** | ● Plumb |
93
+ * | --- | --- |
94
+ * | Now | Step 2 of 3 - feat(login): wire the limiter into POST /login |
95
+ * ```
96
+ */
97
+ export function stateTable(rows) {
98
+ const [head, ...body] = rows;
99
+ if (head === undefined)
100
+ return '';
101
+ return [
102
+ `| **${cell(head.label)}** | ${cell(head.value)} |`,
103
+ '| --- | --- |',
104
+ ...body.map((r) => `| ${cell(r.label)} | ${cell(r.value)} |`),
105
+ ].join('\n');
106
+ }
107
+ /**
108
+ * A table cell's text: on one line, with every pipe escaped, so a step title
109
+ * or a park holding one cannot split its row.
110
+ */
111
+ export function cell(text) {
112
+ return text.replace(/\s*\n\s*/g, ' ').replace(/\|/g, '\\|').trim();
78
113
  }
79
114
  /**
80
115
  * Stack a turn's parts: one blank line between each, one beneath the last.
@@ -167,14 +167,14 @@ export function remainingOrder(steps, order, exclude) {
167
167
  return byOrder.some((n, i) => n !== byDocument[i]) ? byOrder : [];
168
168
  }
169
169
  /**
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.
170
+ * The build order as the state table's Order row says it: the next `cap` step
171
+ * numbers in sequence, the rest folded to a count (`and 2 more`), the way a
172
+ * long detail degrades on every one-liner; '' for an empty order. With no cap,
173
+ * the whole sequence.
174
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)).
175
+ * The label is the row's, `Order`, which is where the note's `order:` field
176
+ * went when the note became rows (D111 (state-table), revising
177
+ * D108 (order-label)).
178
178
  */
179
179
  export function buildOrderPhrase(order, cap) {
180
180
  if (order.length === 0) {
@@ -182,7 +182,7 @@ export function buildOrderPhrase(order, cap) {
182
182
  }
183
183
  const shown = cap === undefined ? order : order.slice(0, cap);
184
184
  const rest = order.length - shown.length;
185
- return `order: ${shown.join(', ')}${rest > 0 ? ` (and ${rest} more)` : ''}`;
185
+ return `${shown.join(', ')}${rest > 0 ? ` (and ${rest} more)` : ''}`;
186
186
  }
187
187
  /**
188
188
  * Write `order` as the one numeric line under `## Build order`: in place of
@@ -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. ---
@@ -1265,6 +1602,33 @@ export function countDiff(entries) {
1265
1602
  files: entries.length,
1266
1603
  };
1267
1604
  }
1605
+ // How many lines the changed files take beneath the diff row before the rest
1606
+ // folds to a count: seven named and `and 5 more`, so the list never runs past
1607
+ // eight lines.
1608
+ const DIFF_FILES_CAP = 8;
1609
+ /**
1610
+ * The changed files beneath the diff row, one `<path> +<added> -<removed>`
1611
+ * item per file, in git's path order. A file several commits touched is
1612
+ * counted once, its lines summed. Past eight files the tail folds to a count
1613
+ * (`and 5 more`), never a silent cut, and a path is never shortened: it is the
1614
+ * name the human opens, and a cut one is wrong where a wrapped one is legible
1615
+ * (D113 (short-card)).
1616
+ */
1617
+ export function diffFileItems(entries) {
1618
+ const byPath = new Map();
1619
+ for (const e of entries) {
1620
+ const prior = byPath.get(e.path) ?? { added: 0, removed: 0 };
1621
+ byPath.set(e.path, { added: prior.added + e.added, removed: prior.removed + e.removed });
1622
+ }
1623
+ const items = [...byPath.entries()]
1624
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
1625
+ .map(([path, c]) => `${path} +${c.added} -${c.removed}`);
1626
+ if (items.length <= DIFF_FILES_CAP) {
1627
+ return items;
1628
+ }
1629
+ const shown = items.slice(0, DIFF_FILES_CAP - 1);
1630
+ return [...shown, `and ${items.length - shown.length} more`];
1631
+ }
1268
1632
  /**
1269
1633
  * The diff row's value: `+<added> -<removed> across <N> files`, plus the
1270
1634
  * `inline below` pointer when the patch rides the turn. Null when nothing
@@ -1400,6 +1764,9 @@ export function recapLines(rows, extras) {
1400
1764
  }
1401
1765
  if (extras.diff !== null) {
1402
1766
  body.push(`${'diff'.padEnd(13)}${extras.diff}`);
1767
+ for (const item of extras.files ?? []) {
1768
+ body.push(`${RECAP_INDENT}- ${item}`);
1769
+ }
1403
1770
  }
1404
1771
  if (extras.spent !== null) {
1405
1772
  body.push(`${'spent'.padEnd(13)}${extras.spent}`);
@@ -1443,6 +1810,7 @@ export function measureRange(input) {
1443
1810
  steps: stepsRow(held, shas),
1444
1811
  rows,
1445
1812
  diff: rangeDiffValue(landed.flatMap((l) => l.files)),
1813
+ files: diffFileItems(landed.flatMap((l) => l.files).filter((f) => !isArtifactPath(f.path))),
1446
1814
  spent: rangeSpentValue(stats),
1447
1815
  stats: {
1448
1816
  driftWarnings: sum('driftWarnings'),