plumbbob 0.13.4 → 0.13.6
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 +23 -23
- package/dist/cli-core.js +6 -5
- package/dist/lib/notice.js +42 -7
- package/dist/lib/orient.js +102 -23
- 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/relay.js +48 -6
- package/dist/verbs/turn.js +111 -31
- package/package.json +1 -1
- package/skills/build/SKILL.md +59 -52
- package/skills/finish/SKILL.md +10 -4
- package/skills/harvest/SKILL.md +26 -13
- package/skills/plan/SKILL.md +13 -9
- package/skills/run/SKILL.md +38 -24
- package/skills/verify/SKILL.md +48 -40
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plumbbob",
|
|
3
3
|
"displayName": "PlumbBob",
|
|
4
|
-
"version": "0.13.
|
|
4
|
+
"version": "0.13.6",
|
|
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
|
@@ -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
|
|
@@ -1602,6 +1602,33 @@ export function countDiff(entries) {
|
|
|
1602
1602
|
files: entries.length,
|
|
1603
1603
|
};
|
|
1604
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
|
+
}
|
|
1605
1632
|
/**
|
|
1606
1633
|
* The diff row's value: `+<added> -<removed> across <N> files`, plus the
|
|
1607
1634
|
* `inline below` pointer when the patch rides the turn. Null when nothing
|
|
@@ -1737,6 +1764,9 @@ export function recapLines(rows, extras) {
|
|
|
1737
1764
|
}
|
|
1738
1765
|
if (extras.diff !== null) {
|
|
1739
1766
|
body.push(`${'diff'.padEnd(13)}${extras.diff}`);
|
|
1767
|
+
for (const item of extras.files ?? []) {
|
|
1768
|
+
body.push(`${RECAP_INDENT}- ${item}`);
|
|
1769
|
+
}
|
|
1740
1770
|
}
|
|
1741
1771
|
if (extras.spent !== null) {
|
|
1742
1772
|
body.push(`${'spent'.padEnd(13)}${extras.spent}`);
|
|
@@ -1780,6 +1810,7 @@ export function measureRange(input) {
|
|
|
1780
1810
|
steps: stepsRow(held, shas),
|
|
1781
1811
|
rows,
|
|
1782
1812
|
diff: rangeDiffValue(landed.flatMap((l) => l.files)),
|
|
1813
|
+
files: diffFileItems(landed.flatMap((l) => l.files).filter((f) => !isArtifactPath(f.path))),
|
|
1783
1814
|
spent: rangeSpentValue(stats),
|
|
1784
1815
|
stats: {
|
|
1785
1816
|
driftWarnings: sum('driftWarnings'),
|
|
@@ -1998,13 +2029,64 @@ function sectionProse(detail, heading) {
|
|
|
1998
2029
|
if (text.length === 0) {
|
|
1999
2030
|
return null;
|
|
2000
2031
|
}
|
|
2001
|
-
return text
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2032
|
+
return proseBlocks(text).join('\n\n');
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* Prose's blocks, in order: each paragraph unwrapped onto one line, and each
|
|
2036
|
+
* fenced block kept line for line with its fences trimmed flush.
|
|
2037
|
+
*
|
|
2038
|
+
* A command the human is meant to copy rides a fence of its own
|
|
2039
|
+
* (D114 (command-fences)), and joining its lines would run two commands into
|
|
2040
|
+
* one. A fence opened straight under a sentence still ends that paragraph, so
|
|
2041
|
+
* it always stands between blank lines and renders as a block, never as
|
|
2042
|
+
* backticks inside the sentence. A fence left unclosed keeps everything after
|
|
2043
|
+
* it and is closed here, so it cannot swallow the `more details` link the
|
|
2044
|
+
* card prints beneath it.
|
|
2045
|
+
*/
|
|
2046
|
+
function proseBlocks(text) {
|
|
2047
|
+
const blocks = [];
|
|
2048
|
+
let paragraph = [];
|
|
2049
|
+
let fence = null;
|
|
2050
|
+
let fenced = [];
|
|
2051
|
+
for (const line of text.split('\n')) {
|
|
2052
|
+
if (fence !== null) {
|
|
2053
|
+
const closed = closesFence(line, fence);
|
|
2054
|
+
fenced.push(closed ? line.trim() : line.trimEnd());
|
|
2055
|
+
if (closed) {
|
|
2056
|
+
blocks.push(fenced.join('\n'));
|
|
2057
|
+
fence = null;
|
|
2058
|
+
}
|
|
2059
|
+
continue;
|
|
2060
|
+
}
|
|
2061
|
+
const opened = fenceOpening(line);
|
|
2062
|
+
if (opened !== null || line.trim() === '') {
|
|
2063
|
+
if (paragraph.length > 0)
|
|
2064
|
+
blocks.push(paragraph.join(' '));
|
|
2065
|
+
paragraph = [];
|
|
2066
|
+
}
|
|
2067
|
+
if (opened !== null) {
|
|
2068
|
+
fence = opened;
|
|
2069
|
+
fenced = [line.trim()];
|
|
2070
|
+
}
|
|
2071
|
+
else if (line.trim() !== '') {
|
|
2072
|
+
paragraph.push(line.trim());
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
if (fence !== null)
|
|
2076
|
+
blocks.push([...fenced, fence.char.repeat(fence.length)].join('\n'));
|
|
2077
|
+
if (paragraph.length > 0)
|
|
2078
|
+
blocks.push(paragraph.join(' '));
|
|
2079
|
+
return blocks;
|
|
2080
|
+
}
|
|
2081
|
+
/** The fence a line opens: three or more backticks or tildes, indented three spaces at most. Null when it opens none. */
|
|
2082
|
+
function fenceOpening(line) {
|
|
2083
|
+
const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
|
|
2084
|
+
return marker === undefined ? null : { char: marker.charAt(0), length: marker.length };
|
|
2085
|
+
}
|
|
2086
|
+
/** Whether a line closes an open fence: the same character, at least as many of it, and nothing after. */
|
|
2087
|
+
function closesFence(line, fence) {
|
|
2088
|
+
const marker = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line)?.[1];
|
|
2089
|
+
return marker !== undefined && marker.startsWith(fence.char) && marker.length >= fence.length;
|
|
2008
2090
|
}
|
|
2009
2091
|
/**
|
|
2010
2092
|
* The `## Summary` section of `.plumbbob/detail.md` and the numbered detail
|
|
@@ -2048,8 +2130,7 @@ export function normalizeDetailHeadings(detail) {
|
|
|
2048
2130
|
let afterHeading = false;
|
|
2049
2131
|
for (const line of detail.split('\n')) {
|
|
2050
2132
|
if (fence !== null) {
|
|
2051
|
-
|
|
2052
|
-
if (close !== undefined && close.startsWith(fence.char) && close.length >= fence.length) {
|
|
2133
|
+
if (closesFence(line, fence)) {
|
|
2053
2134
|
fence = null;
|
|
2054
2135
|
}
|
|
2055
2136
|
out.push(line);
|
|
@@ -2060,10 +2141,7 @@ export function normalizeDetailHeadings(detail) {
|
|
|
2060
2141
|
if ((heading || afterHeading) && line.trim() !== '' && prior !== undefined && prior.trim() !== '') {
|
|
2061
2142
|
out.push('');
|
|
2062
2143
|
}
|
|
2063
|
-
|
|
2064
|
-
if (open !== undefined) {
|
|
2065
|
-
fence = { char: open.charAt(0), length: open.length };
|
|
2066
|
-
}
|
|
2144
|
+
fence = fenceOpening(line);
|
|
2067
2145
|
out.push(line);
|
|
2068
2146
|
afterHeading = heading;
|
|
2069
2147
|
}
|
|
@@ -2101,8 +2179,9 @@ export function parseSections(detail) {
|
|
|
2101
2179
|
}
|
|
2102
2180
|
/**
|
|
2103
2181
|
* The `## Recommendation` section of `.plumbbob/detail.md`: the one or two
|
|
2104
|
-
* plain sentences a decision turn ends on,
|
|
2105
|
-
* `
|
|
2182
|
+
* plain sentences a decision turn ends on, each shell command they name in a
|
|
2183
|
+
* `sh` fence of its own, or null when the model wrote none. `handoff` emits it
|
|
2184
|
+
* after the card, the prose itself unfenced; the eye lands on the last text,
|
|
2106
2185
|
* and the last text should say which move the model would take.
|
|
2107
2186
|
*/
|
|
2108
2187
|
export function parseRecommendation(detail) {
|
package/dist/lib/sidecar.js
CHANGED
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
// human-owned (check/auto): the tool only ever reads it, never rewrites it. The
|
|
19
19
|
// phase the dashboard shows (DESIGN/BUILD/SPIKE) is derived, not stored: BUILD ⇔
|
|
20
20
|
// a STEP is in flight, SPIKE ⇔ the SPIKE marker is present, otherwise DESIGN.
|
|
21
|
-
import { createHash } from 'node:crypto';
|
|
22
21
|
import { existsSync, readdirSync, readFileSync, writeFileSync, appendFileSync, rmSync, mkdirSync } from 'node:fs';
|
|
23
22
|
import { dirname, join } from 'node:path';
|
|
24
23
|
import { gitPath } from "./git.js";
|
|
@@ -215,56 +214,85 @@ export function clearStep(root, slug) {
|
|
|
215
214
|
rmSync(stepPath(root, slug), { force: true });
|
|
216
215
|
}
|
|
217
216
|
/**
|
|
218
|
-
* PAUSE: the
|
|
219
|
-
*
|
|
220
|
-
*
|
|
217
|
+
* PAUSE: which card the human has already read, so a later `handoff` prints
|
|
218
|
+
* the one line that points back at it instead of the card. It holds a step
|
|
219
|
+
* number once that step's pause card has shown (`2`), or `landed 2` once the
|
|
220
|
+
* boundary step 2 landed on has shown.
|
|
221
|
+
*
|
|
222
|
+
* It records that a card showed, never what the card said: a later turn that
|
|
223
|
+
* changes the work is the model's reply, and the card comes back only on
|
|
224
|
+
* request (D110 (reply-after-the-card)).
|
|
221
225
|
*/
|
|
222
226
|
export function pausePath(root, slug) {
|
|
223
227
|
return join(artifactDir(root, slug), 'PAUSE');
|
|
224
228
|
}
|
|
225
229
|
/**
|
|
226
|
-
*
|
|
227
|
-
*
|
|
230
|
+
* The card PAUSE says has shown, or null when the file is absent or does not
|
|
231
|
+
* parse. A stamp written before D110 (reply-after-the-card) holds a step
|
|
232
|
+
* number and a hash, and reads as that step's pause.
|
|
233
|
+
*/
|
|
234
|
+
export function readPause(root, slug) {
|
|
235
|
+
try {
|
|
236
|
+
const m = /^(landed )?(\d+)(?:\s|$)/.exec(readFileSync(pausePath(root, slug), 'utf8').trim());
|
|
237
|
+
return m === null ? null : { kind: m[1] === undefined ? 'pause' : 'landed', step: Number(m[2]) };
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Record that a card has shown. Best-effort: a failed write costs one more
|
|
245
|
+
* full card on a later turn, never this turn's output or its exit code.
|
|
246
|
+
*/
|
|
247
|
+
export function writePause(root, slug, stamp) {
|
|
248
|
+
try {
|
|
249
|
+
writeFileSync(pausePath(root, slug), `${stamp.kind === 'landed' ? 'landed ' : ''}${stamp.step}\n`);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// A read-only tree or a vanished build folder: the card still renders.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Remove the PAUSE stamp: a revert or an abandon ends the pause it recorded,
|
|
257
|
+
* so the next pause renders whole. Absent is a no-op.
|
|
228
258
|
*/
|
|
229
259
|
export function clearPause(root, slug) {
|
|
230
260
|
rmSync(pausePath(root, slug), { force: true });
|
|
231
261
|
}
|
|
232
262
|
/**
|
|
233
263
|
* STOP: the stamp of the stop card `handoff --stop` last rendered whole, the
|
|
234
|
-
* range it reviews
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
* and only once a human turn has read it.
|
|
264
|
+
* range it reviews and the TURN it printed in. While a later render names the
|
|
265
|
+
* same range, handoff prints the one-line reminder, whatever changed since;
|
|
266
|
+
* `plumbbob harvest` records the stop only once a card for the range has shown
|
|
267
|
+
* and a human turn has read it.
|
|
239
268
|
*/
|
|
240
269
|
export function stopPath(root, slug) {
|
|
241
270
|
return join(artifactDir(root, slug), 'STOP');
|
|
242
271
|
}
|
|
243
272
|
/**
|
|
244
273
|
* The stamp of the stop card last rendered, or null when STOP is absent or
|
|
245
|
-
* does not parse.
|
|
274
|
+
* does not parse. A stamp written before D110 (reply-after-the-card) carries
|
|
275
|
+
* a hash and a digest after the turn, and both are ignored.
|
|
246
276
|
*/
|
|
247
277
|
export function readStop(root, slug) {
|
|
248
278
|
try {
|
|
249
|
-
const m = /^(\d+) (\d+|-)
|
|
279
|
+
const m = /^(\d+) (\d+|-)(?:\s|$)/.exec(readFileSync(stopPath(root, slug), 'utf8').trim());
|
|
250
280
|
if (m === null)
|
|
251
281
|
return null;
|
|
252
|
-
return { range: Number(m[1]), turn: m[2] === '-' ? null : Number(m[2])
|
|
282
|
+
return { range: Number(m[1]), turn: m[2] === '-' ? null : Number(m[2]) };
|
|
253
283
|
}
|
|
254
284
|
catch {
|
|
255
285
|
return null;
|
|
256
286
|
}
|
|
257
287
|
}
|
|
258
288
|
/**
|
|
259
|
-
* Record the stamp of the stop card just printed
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
* this turn's output or its exit code.
|
|
289
|
+
* Record the stamp of the stop card just printed. Best-effort: a failed write
|
|
290
|
+
* costs one more full card on a later turn, never this turn's output or its
|
|
291
|
+
* exit code.
|
|
263
292
|
*/
|
|
264
293
|
export function writeStop(root, slug, stamp) {
|
|
265
294
|
try {
|
|
266
|
-
|
|
267
|
-
writeFileSync(stopPath(root, slug), `${stamp.range} ${stamp.turn ?? '-'} ${stamp.hash} ${detail}\n`);
|
|
295
|
+
writeFileSync(stopPath(root, slug), `${stamp.range} ${stamp.turn ?? '-'}\n`);
|
|
268
296
|
}
|
|
269
297
|
catch {
|
|
270
298
|
// A read-only tree or a vanished build folder: the card still renders.
|
|
@@ -277,13 +305,6 @@ export function writeStop(root, slug, stamp) {
|
|
|
277
305
|
export function clearStop(root, slug) {
|
|
278
306
|
rmSync(stopPath(root, slug), { force: true });
|
|
279
307
|
}
|
|
280
|
-
/**
|
|
281
|
-
* The digest a STOP stamp keeps of a detail file, so the record can tell the
|
|
282
|
-
* detail a card showed from one written after it.
|
|
283
|
-
*/
|
|
284
|
-
export function detailDigest(detail) {
|
|
285
|
-
return createHash('sha256').update(detail).digest('hex');
|
|
286
|
-
}
|
|
287
308
|
/**
|
|
288
309
|
* The build's `checkpoints` ledger: the recorded SHAs of landed plan and step
|
|
289
310
|
* commits, one line each.
|
|
@@ -689,7 +710,7 @@ export function readTurn(root) {
|
|
|
689
710
|
// ending: a decision ending closes the turn, an orientation ending appears in
|
|
690
711
|
// it, and a driver ending is recorded only so that the last ending wins.
|
|
691
712
|
const RELAY_TIERS = ['decision', 'orientation', 'driver'];
|
|
692
|
-
// Which ending printed: handoff's
|
|
713
|
+
// Which ending printed: handoff's nine forms first, then one per transition.
|
|
693
714
|
// It is what the check's reason names when the ending went missing.
|
|
694
715
|
const RELAY_RENDERS = [
|
|
695
716
|
'card',
|
|
@@ -700,6 +721,7 @@ const RELAY_RENDERS = [
|
|
|
700
721
|
'boundary',
|
|
701
722
|
'pointer',
|
|
702
723
|
'driver',
|
|
724
|
+
'harvest-card',
|
|
703
725
|
'checkpoint',
|
|
704
726
|
'plan-commit',
|
|
705
727
|
'park',
|
|
@@ -911,10 +933,10 @@ export function excludeControl(root) {
|
|
|
911
933
|
// The entry stamp is in-flight control like STEP/SEAM: checkpoint's
|
|
912
934
|
// stageAll must never sweep it into a step commit.
|
|
913
935
|
`${DIRNAME}/builds/*/TICK`,
|
|
914
|
-
// The pause stamp is in-flight control too:
|
|
915
|
-
//
|
|
936
|
+
// The pause stamp is in-flight control too: which card has shown, so a
|
|
937
|
+
// later handoff prints its one line. It must never ride a step commit.
|
|
916
938
|
`${DIRNAME}/builds/*/PAUSE`,
|
|
917
|
-
// The stop stamp likewise:
|
|
939
|
+
// The stop stamp likewise: the range whose card has shown, and the turn it
|
|
918
940
|
// printed in, for the reminder and the record's latch.
|
|
919
941
|
`${DIRNAME}/builds/*/STOP`,
|
|
920
942
|
// The agent-run handoff ledger is step-scoped in-flight state, not a
|
package/dist/verbs/checkpoint.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { relative } from 'node:path';
|
|
14
14
|
import { commit, findRepoRoot, headSha, isDirty, stageAll, stagePath, stagedPaths, stagedStat } from "../lib/git.js";
|
|
15
|
-
import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearDetail, clearHandoff,
|
|
15
|
+
import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearDetail, clearHandoff, clearTick, detailPath, hasSession, intentPath, readRun, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, writePause, } from "../lib/sidecar.js";
|
|
16
16
|
import { runCheck } from "../lib/check.js";
|
|
17
17
|
import { readCommitBody } from "../lib/commitbody.js";
|
|
18
18
|
import { checkLatch } from "../lib/latch.js";
|
|
@@ -22,8 +22,7 @@ import { conventionalSubject, subjectFromTitle, withMarker } from "../lib/commit
|
|
|
22
22
|
import { appendToSection, checkpointLogLine, logEntry, planLogLine } from "../lib/buildlog.js";
|
|
23
23
|
import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
|
|
24
24
|
import { advisory, ending, notice, printEnding, transition } from "../lib/notice.js";
|
|
25
|
-
import { boundaryEnding, detailsLink, parseDetailRange, planRecord, stepRecord } from "./handoff.js";
|
|
26
|
-
import { isHarvestDetail } from "./harvest.js";
|
|
25
|
+
import { boundaryEnding, detailsLink, isHarvestDetail, parseDetailRange, planRecord, stepRecord } from "./handoff.js";
|
|
27
26
|
/**
|
|
28
27
|
* Land a step: latch, check gate, commit, record, return to the boundary.
|
|
29
28
|
*
|
|
@@ -122,11 +121,14 @@ export async function checkpoint(cwd, args) {
|
|
|
122
121
|
rmSync(stepPath(root), { force: true });
|
|
123
122
|
clearHandoff(root); // the agent-run handoff ledger is step-scoped; clear it with the markers.
|
|
124
123
|
clearTick(root); // the step's entry stamp is spent; the next `build <n>` re-stamps.
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
//
|
|
124
|
+
// The pause it stamped is over, and the boundary this ending prints is the
|
|
125
|
+
// card the stamp now names, so a later handoff here ends on its pointer
|
|
126
|
+
// (D110 (reply-after-the-card)); the next step's pause renders whole.
|
|
127
|
+
writePause(root, undefined, { kind: 'landed', step });
|
|
128
|
+
// The whole ending, one block: the lead line, the advisories this checkpoint
|
|
129
|
+
// alone can state, and the state table headed by the Verdict folded over
|
|
130
|
+
// what just landed. Read after the flip and the ledger append, so the fold
|
|
131
|
+
// and the pointer both see the boundary the human is standing at.
|
|
130
132
|
const landed = boundaryEnding(root, activeBuild(root), step);
|
|
131
133
|
// A landing that completes the range a typed run marked owes the stop card:
|
|
132
134
|
// the record says so, and the relay check holds a turn that ends here.
|
|
@@ -137,10 +139,8 @@ export async function checkpoint(cwd, args) {
|
|
|
137
139
|
...(owes === null ? {} : { owes }),
|
|
138
140
|
text: ending({
|
|
139
141
|
lead: transition({ label: 'Checkpoint', fact: `Step ${step} complete`, detail: leadDetail(sha, pointer) }),
|
|
140
|
-
verdict: landed.verdict,
|
|
141
142
|
advisories: [drift, flipNotice],
|
|
142
|
-
|
|
143
|
-
pointer: landed.pointer,
|
|
143
|
+
state: landed.state,
|
|
144
144
|
}),
|
|
145
145
|
});
|
|
146
146
|
return 0;
|
|
@@ -202,7 +202,7 @@ function checkpointPlan(root, args) {
|
|
|
202
202
|
remedy: 'unignore .plumbbob/builds/ to keep the record in the tree',
|
|
203
203
|
}),
|
|
204
204
|
],
|
|
205
|
-
|
|
205
|
+
state: boundaryEnding(root, activeBuild(root), null).state,
|
|
206
206
|
}),
|
|
207
207
|
});
|
|
208
208
|
return 0;
|