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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +24 -24
- package/dist/cli-core.js +6 -5
- package/dist/lib/notice.js +42 -7
- package/dist/lib/orient.js +379 -11
- package/dist/lib/sidecar.js +53 -31
- package/dist/verbs/checkpoint.js +12 -12
- package/dist/verbs/finish.js +7 -7
- package/dist/verbs/handoff.js +368 -302
- package/dist/verbs/harvest.js +20 -21
- package/dist/verbs/order.js +65 -21
- package/dist/verbs/relay.js +48 -6
- package/dist/verbs/turn.js +111 -31
- package/package.json +1 -1
- package/skills/build/SKILL.md +55 -51
- package/skills/finish/SKILL.md +10 -4
- package/skills/harvest/SKILL.md +43 -25
- package/skills/order/SKILL.md +19 -16
- package/skills/plan/SKILL.md +10 -9
- package/skills/refine/SKILL.md +17 -10
- package/skills/run/SKILL.md +34 -23
- package/skills/step/SKILL.md +28 -23
- package/skills/verify/SKILL.md +44 -39
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plumbbob",
|
|
3
3
|
"displayName": "PlumbBob",
|
|
4
|
-
"version": "0.13.
|
|
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="
|
|
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
|
-
**
|
|
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
|
|
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
|

|
|
@@ -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
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|

|
|
208
208
|
|
|
209
209
|
Ask instead (`expand 2`, or any question) and it shows more of what is there and
|
|
210
|
-
changes nothing.
|
|
211
|
-
|
|
212
|
-
block again
|
|
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
|
|
218
|
-
|
|
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
|
|
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 --
|
|
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 (`-
|
|
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',
|
package/dist/lib/notice.js
CHANGED
|
@@ -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
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
|
70
|
-
*
|
|
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,
|
|
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.
|
package/dist/lib/orient.js
CHANGED
|
@@ -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
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
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
|
|
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
|
-
|
|
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'),
|