plumbbob 0.10.3 → 0.11.1
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 +108 -51
- package/dist/cli-core.js +8 -4
- package/dist/lib/agents.js +2 -2
- package/dist/lib/buildlog.js +22 -0
- package/dist/lib/check.js +76 -6
- package/dist/lib/git.js +60 -5
- package/dist/lib/notice.js +140 -0
- package/dist/lib/orient.js +467 -8
- package/dist/lib/sidecar.js +28 -0
- package/dist/verbs/abandon.js +25 -8
- package/dist/verbs/agent.js +63 -31
- package/dist/verbs/build.js +25 -9
- package/dist/verbs/check.js +8 -3
- package/dist/verbs/checkpoint.js +130 -32
- package/dist/verbs/finish.js +33 -9
- package/dist/verbs/handoff.js +506 -63
- package/dist/verbs/park.js +11 -5
- package/dist/verbs/revert.js +16 -5
- package/dist/verbs/spike.js +61 -22
- package/dist/verbs/start.js +56 -17
- package/dist/verbs/use.js +26 -16
- package/package.json +1 -1
- package/skills/abandon/SKILL.md +2 -1
- package/skills/build/SKILL.md +117 -35
- package/skills/finish/SKILL.md +19 -8
- package/skills/park/SKILL.md +15 -1
- package/skills/plan/SKILL.md +59 -1
- package/skills/recover/SKILL.md +3 -2
- package/skills/revert/SKILL.md +2 -1
- package/skills/spike/SKILL.md +3 -2
- package/skills/status/SKILL.md +13 -5
- package/skills/verify/SKILL.md +127 -31
- package/templates/build-log.md +4 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plumbbob",
|
|
3
3
|
"displayName": "PlumbBob",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.11.1",
|
|
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
|
@@ -29,6 +29,45 @@ Ridgeline runs large, settled work autonomously without you, PlumbBob keeps you
|
|
|
29
29
|
driver's seat for the work your judgment has to steer. This repository was built using
|
|
30
30
|
PlumbBob itself, running its own loop.
|
|
31
31
|
|
|
32
|
+
## Who it's for
|
|
33
|
+
|
|
34
|
+
- **You already build with a coding agent and end the day reviewing a wall of diff.**
|
|
35
|
+
PlumbBob turns that wall into steps you approve one at a time, each small enough to
|
|
36
|
+
read as an editor.
|
|
37
|
+
- **You are skeptical of AI-written code.** Nothing lands without a green check, a
|
|
38
|
+
self-review against the plan, and your say-so, and the CLI refuses to record a step
|
|
39
|
+
the model approved for itself.
|
|
40
|
+
- **You would rather not learn a tool.** Everything it writes is markdown and ordinary
|
|
41
|
+
git commits in your own repo; the skills are slash commands, and `/plumbbob:status`
|
|
42
|
+
always names the next one. A [glossary](docs/glossary.md) covers the words it mints.
|
|
43
|
+
- **Not for a typo, and not for a rewrite.** A one-line fix needs no session, and a
|
|
44
|
+
large, settled build wants an autonomous harness such as Ridgeline. The loop earns
|
|
45
|
+
its keep in between: a feature, a bug, a refactor.
|
|
46
|
+
|
|
47
|
+
## The loop in one picture
|
|
48
|
+
|
|
49
|
+
<p align="center">
|
|
50
|
+
<img src="docs/media/loop-one-step.svg" alt="One step of the loop: /plumbbob:build implements step 1 and stops at the pause, the human says looks good, the step is checkpointed" width="760">
|
|
51
|
+
</p>
|
|
52
|
+
|
|
53
|
+
Everything the model must stay true to lives in one file, the build's `intent.md`
|
|
54
|
+
(under `.plumbbob/builds/<slug>/`), written before any code: the **Frame** (the problem, what done looks like, what you're
|
|
55
|
+
explicitly *not* doing), the **Decisions** and **Constraints** (the settled calls, each
|
|
56
|
+
with its *because*), and the **Steps**: a numbered list where every step carries a
|
|
57
|
+
**done-when** (a checkable finish line, ideally a test) and a **seam** (the files it's
|
|
58
|
+
expected to touch).
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
/plumbbob:plan decide everything on paper — frame, decisions, all steps (once)
|
|
62
|
+
per step:
|
|
63
|
+
/plumbbob:build implement the next step → run checks → self-review → PAUSE
|
|
64
|
+
(looks good) → the step is committed as a checkpoint; fire /plumbbob:build again
|
|
65
|
+
/plumbbob:finish report what shipped and why, final commit, clear (once)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Nothing is locked and nothing refuses you: the loop does a step's labor, pulls up to
|
|
69
|
+
the line, and waits for you to advance it. **You are the clock.**
|
|
70
|
+
|
|
32
71
|
## What a prompt can't replicate
|
|
33
72
|
|
|
34
73
|
A system prompt can *ask* a model to plan first and stop for review. What it can't do is
|
|
@@ -55,30 +94,10 @@ The planning surface is the on-ramp; these five are the floor under it. And the
|
|
|
55
94
|
payoff is [measured, not asserted](#why-not-just), not just claimed. **Guidance on the
|
|
56
95
|
work, a latch on the record.**
|
|
57
96
|
|
|
58
|
-
## The loop in one picture
|
|
59
|
-
|
|
60
97
|
<p align="center">
|
|
61
|
-
<img src="
|
|
98
|
+
<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">
|
|
62
99
|
</p>
|
|
63
100
|
|
|
64
|
-
Everything the model must stay true to lives in one file, the build's `intent.md`
|
|
65
|
-
(under `.plumbbob/builds/<slug>/`), written before any code: the **Frame** (the problem, what done looks like, what you're
|
|
66
|
-
explicitly *not* doing), the **Decisions** and **Constraints** (the settled calls, each
|
|
67
|
-
with its *because*), and the **Steps**: a numbered list where every step carries a
|
|
68
|
-
**done-when** (a checkable finish line, ideally a test) and a **seam** (the files it's
|
|
69
|
-
expected to touch).
|
|
70
|
-
|
|
71
|
-
```text
|
|
72
|
-
/plumbbob:plan decide everything on paper — frame, decisions, all steps (once)
|
|
73
|
-
per step:
|
|
74
|
-
/plumbbob:build implement the next step → run checks → self-review → PAUSE
|
|
75
|
-
(approve) → the step is committed as a checkpoint; fire /plumbbob:build again
|
|
76
|
-
/plumbbob:finish report what shipped and why, final commit, clear (once)
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
Nothing is locked and nothing refuses you: the loop does a step's labor, pulls up to
|
|
80
|
-
the line, and waits for you to advance it. **You are the clock.**
|
|
81
|
-
|
|
82
101
|
## Install
|
|
83
102
|
|
|
84
103
|
PlumbBob installs **once, globally** (like `gh` or your dotfiles). Two ways work;
|
|
@@ -115,20 +134,68 @@ In Claude Code, inside any git repo with a clean tree:
|
|
|
115
134
|
path to a spec file. Together you fill the build's `intent.md`. No code is written
|
|
116
135
|
yet.
|
|
117
136
|
2. **Build.** Fire `/plumbbob:build`. It implements the next undone step, runs the
|
|
118
|
-
heavy check gate, reviews its own diff against the plan, and stops
|
|
137
|
+
heavy check gate, reviews its own diff against the plan, and stops. What it stops on
|
|
138
|
+
is one block with a fixed shape (the [turn anatomy](docs/presentation.md)): the CLI
|
|
139
|
+
measures the check, the seam, and the diff, the model supplies only the judgment, and
|
|
140
|
+
nothing is written around it.
|
|
141
|
+
|
|
142
|
+
````markdown
|
|
143
|
+
**Summary**: A per-IP token bucket now refuses the 6th attempt inside a minute. (details: `.plumbbob/detail.md`)
|
|
144
|
+
|
|
145
|
+
1. The bucket lives in memory, keyed by IP, refilling five tokens a minute.
|
|
146
|
+
2. The clock is injected, so the test advances time instead of sleeping on it.
|
|
147
|
+
|
|
148
|
+
**Readout**: Step 1 - Add a token-bucket limiter
|
|
119
149
|
|
|
120
150
|
```text
|
|
121
|
-
|
|
151
|
+
check green: 7 of 7 checks
|
|
152
|
+
done-when met
|
|
153
|
+
decisions 2 of 2 honored
|
|
154
|
+
constraints 1 of 1 honored
|
|
155
|
+
seam held: 2 of 2 declared, no strays
|
|
156
|
+
diff +61 -3 across 2 files
|
|
157
|
+
spent 22 min · 2 turns · 41s gate · green first run
|
|
122
158
|
```
|
|
123
159
|
|
|
160
|
+
**Verdict**: ● Plumb
|
|
161
|
+
|
|
162
|
+
**Next Up**: Step 2 of 3 - Wire the limiter into POST /login (model: **Sonnet**, details: `.plumbbob/builds/2026-07-03-rate-limit-the-login-endpoint/intent.md:20`)
|
|
163
|
+
|
|
164
|
+
**Your Call**:
|
|
165
|
+
|
|
166
|
+
- `looks good` → I checkpoint Step 1; back to the boundary
|
|
167
|
+
- `expand`, or any question → I show more of what is there; nothing changes
|
|
168
|
+
- anything that reads as direction → I take it as what to change; nothing lands until you approve
|
|
169
|
+
- `revert` → I wind the work back to the last checkpoint
|
|
170
|
+
|
|
171
|
+
**Recommendation**: Approve it. The gate is green, the seam held, and every call the step made is one the plan already decided.
|
|
172
|
+
````
|
|
173
|
+
|
|
174
|
+

|
|
175
|
+
|
|
124
176
|
(The check gate is [checkride](https://www.npmjs.com/package/checkride), one
|
|
125
177
|
run across the tools your repo already configures: types, lint, tests, dead
|
|
126
178
|
code, docs. To gate through your own command instead, set the `"check"` key in
|
|
127
|
-
`.plumbbob/settings.json`, for example `"check": "npm test"
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
179
|
+
`.plumbbob/settings.json`, for example `"check": "npm test"`; in a repo where
|
|
180
|
+
checkride finds no tools at all, `start` says so up front and names that key.)
|
|
181
|
+
3. **Reply.** Say `looks good` and it commits the step as a checkpoint, marks it done,
|
|
182
|
+
and returns to the boundary with the same three lines every landed step gets:
|
|
183
|
+
|
|
184
|
+
```text
|
|
185
|
+
**Checkpoint**: Step 1 complete (a1b2c3d4e, details: `.plumbbob/builds/2026-07-03-rate-limit-the-login-endpoint/build-log.md:44`)
|
|
186
|
+
|
|
187
|
+
**Verdict**: ● Plumb
|
|
188
|
+
|
|
189
|
+
**Next Up**: Step 2 of 3 - Wire the limiter into POST /login (model: **Sonnet**, details: `.plumbbob/builds/2026-07-03-rate-limit-the-login-endpoint/intent.md:20`)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+

|
|
193
|
+
|
|
194
|
+
Ask instead (`expand 2`, or any question) and it shows more of what is there and
|
|
195
|
+
changes nothing; direct it and it takes that as the fix, and nothing lands until you
|
|
196
|
+
say `looks good`. Then fire `/plumbbob:build` again for the next step: re-firing it
|
|
197
|
+
*is* the clock tick. Whenever you lose the thread, `/plumbbob:status` shows where you
|
|
198
|
+
are and names the next move.
|
|
132
199
|
4. **Finish.** When the last step is done, `/plumbbob:finish` writes a report of what shipped
|
|
133
200
|
and why into the build's tracked folder, makes the final commit, and clears the slate
|
|
134
201
|
for the next goal. The folder rides the branch into the PR: the build's record merges
|
|
@@ -187,8 +254,9 @@ All fourteen, with inputs and effects, are in
|
|
|
187
254
|
[`docs/skills-reference.md`](docs/skills-reference.md).
|
|
188
255
|
|
|
189
256
|
Under the skills ships a lean `plumbbob` CLI (the mechanical verbs the
|
|
190
|
-
skills shell out to),
|
|
191
|
-
|
|
257
|
+
skills shell out to), three hooks (a non-blocking post-edit lint, a `git commit`
|
|
258
|
+
ask-hook while a step is in flight, and the per-prompt turn ledger the approval latch
|
|
259
|
+
reads), and a `.plumbbob/` sidecar you can open and edit by hand at any time: a tracked
|
|
192
260
|
`builds/<slug>/` folder per build (`intent.md`, `build-log.md`, `checkpoints`, `report.md`)
|
|
193
261
|
that rides the branch into the PR, plus an untracked control plane (`settings.local.json`,
|
|
194
262
|
the session sentinel, the in-flight markers).
|
|
@@ -260,37 +328,26 @@ you, reading the diff. And when a raw commit does slip the ledger, `status` says
|
|
|
260
328
|
checkpoint record makes recovery one command; cheap recovery, not a cage, is the control
|
|
261
329
|
that matters.
|
|
262
330
|
|
|
263
|
-
> The latch's payoff is measured, not asserted
|
|
264
|
-
> ([`test/evals/`](test/evals/)) sweeps
|
|
265
|
-
> shipped latch,
|
|
266
|
-
>
|
|
267
|
-
>
|
|
268
|
-
> (
|
|
269
|
-
>
|
|
270
|
-
>
|
|
271
|
-
> write into settings, a legal side door. The re-sweep
|
|
272
|
-
> ([opus, 2026-07-18](docs/evals/2026-07-18.md), plumbbob 0.8.7) reproduced the win
|
|
273
|
-
> (*no checkpoint over a red check* still **5/5 latched**) and pinned both gaps. Both are
|
|
274
|
-
> now closed. The side door went by construction: [D67 (auto-not-a-grant)](docs/decisions.md#d67)
|
|
275
|
-
> (self-approval is human-typed only) retired the settings `auto` grant, so the one
|
|
276
|
-
> approval a model could forge is gone. Park capture was never the latch's to reach,
|
|
277
|
-
> prose-governed by design ([D10 (pause-not-lock)](docs/decisions.md#d10)/[D13 (no-edit-guards)](docs/decisions.md#d13)),
|
|
278
|
-
> and it failed because the guidance couldn't reach a fresh session at all; the turn hook
|
|
279
|
-
> now injects one line when a step is in flight, and the third sweep
|
|
280
|
-
> ([opus, 2026-07-27](docs/evals/2026-07-27.md), plumbbob 0.9.0) measures it at **5/5**,
|
|
281
|
-
> alongside every other contract. That sweep ran the latched arm only, so its numbers are
|
|
282
|
-
> a regression check on the shipped configuration, not a fresh baseline delta: the
|
|
283
|
-
> 2/5→5/5 headline above remains the 2026-07-11 measurement.
|
|
331
|
+
> The latch's payoff is measured, not asserted. A headless skill-eval harness
|
|
332
|
+
> ([`test/evals/`](test/evals/)) sweeps the loop with a prose-only baseline against the
|
|
333
|
+
> shipped latch, every assertion a mechanical read of git state, and commits each receipt
|
|
334
|
+
> under [`docs/evals/`](docs/evals/): the route the latch owns (*no checkpoint over a red
|
|
335
|
+
> check under pressure*) went from **2/5 prose-only to 5/5 latched** on the first sweep
|
|
336
|
+
> ([2026-07-11](docs/evals/2026-07-11.md)), every sweep since has held it and closed the
|
|
337
|
+
> two gaps that one surfaced, and the latest ([2026-09-03](docs/evals/2026-09-03.md))
|
|
338
|
+
> scores the shape of the turn itself.
|
|
284
339
|
|
|
285
340
|
## Documentation
|
|
286
341
|
|
|
287
342
|
Each doc answers one question (in rough reading order for a new user):
|
|
288
343
|
|
|
289
344
|
- *What does a session actually look like?* → [`docs/happy-path.md`](docs/happy-path.md): one goal walked end to end; read this first.
|
|
345
|
+
- *What do the words mean?* → [`docs/glossary.md`](docs/glossary.md): every term the loop mints, one line each, with where to read more.
|
|
290
346
|
- *Show me the artifacts it leaves behind.* → [`examples/`](examples/): that same session's finished build folder, file by file.
|
|
291
347
|
- *Should I / can I / what about…?* → [`docs/faq.md`](docs/faq.md): the adoption questions, answered straight.
|
|
292
348
|
- *What is each method for?* → [`docs/techniques.md`](docs/techniques.md): steps, seams, the pause, park/harvest, spikes.
|
|
293
349
|
- *What does each skill do?* → [`docs/skills-reference.md`](docs/skills-reference.md): all fourteen skills: inputs, effects, when to reach for each.
|
|
350
|
+
- *What does a turn look like, and who renders which part?* → [`docs/presentation.md`](docs/presentation.md): the anatomy of the pause, the boundary, and the driver turns.
|
|
294
351
|
- *How do I plug in my own agent?* → [`docs/agents.md`](docs/agents.md): the subprocess envelope, the manifest, `harness.json`, and working examples (including a local-model reviewer via Ollama).
|
|
295
352
|
- *How do I get a local model reviewing my steps?* → [`docs/local-model-review.md`](docs/local-model-review.md): the ollama-reviewer example walked end to end, install to every-pause review.
|
|
296
353
|
- *How do I install it, exactly?* → [`docs/install.md`](docs/install.md): the full guide and the agent-neutral roadmap.
|
package/dist/cli-core.js
CHANGED
|
@@ -79,11 +79,15 @@ const VERBS = [
|
|
|
79
79
|
},
|
|
80
80
|
{
|
|
81
81
|
name: 'handoff',
|
|
82
|
-
description: 'print the
|
|
83
|
-
synopsis: ['handoff [<n>] [--build <slug>]'],
|
|
82
|
+
description: 'print the footer card that ends a turn (pause, boundary, plan, or driver); read-only',
|
|
83
|
+
synopsis: ['handoff [<n>] [--plan] [--driver] [--build <slug>]'],
|
|
84
84
|
args: [{ name: '<n>', gloss: 'step to report on (default: the in-flight step, else the last checkpointed one)' }],
|
|
85
|
-
flags: [
|
|
86
|
-
|
|
85
|
+
flags: [
|
|
86
|
+
{ name: '--plan', gloss: 'render the plan-pause card instead of a step tier' },
|
|
87
|
+
{ name: '--driver', gloss: "render a driver turn's pointer back at the open step" },
|
|
88
|
+
BUILD_FLAG,
|
|
89
|
+
],
|
|
90
|
+
notes: 'Read-only; refuses (exit 1) with no session, or with --plan and --driver together.',
|
|
87
91
|
},
|
|
88
92
|
{
|
|
89
93
|
name: 'check',
|
package/dist/lib/agents.js
CHANGED
|
@@ -267,7 +267,7 @@ export function resolveAgent(root, name, opts = {}) {
|
|
|
267
267
|
}
|
|
268
268
|
return {
|
|
269
269
|
ok: false,
|
|
270
|
-
error: `no agent named "${name}"
|
|
270
|
+
error: `no agent named "${name}", looked in ${projectAgentsDir(root)} and ${personalAgentsDir(home)}`,
|
|
271
271
|
};
|
|
272
272
|
}
|
|
273
273
|
/**
|
|
@@ -610,7 +610,7 @@ function parseChildEnvelope(stdout) {
|
|
|
610
610
|
raw = JSON.parse(stdout);
|
|
611
611
|
}
|
|
612
612
|
catch {
|
|
613
|
-
return { ok: false, error: 'the agent wrote non-JSON to stdout
|
|
613
|
+
return { ok: false, error: 'the agent wrote non-JSON to stdout, so the envelope is not a single JSON object' };
|
|
614
614
|
}
|
|
615
615
|
return parseEnvelope(raw);
|
|
616
616
|
}
|
package/dist/lib/buildlog.js
CHANGED
|
@@ -118,6 +118,28 @@ export function checkpointLogLine(date, step, sha, title, stats = null) {
|
|
|
118
118
|
const titled = title !== null && title.length > 0 ? `${head} — ${title}` : head;
|
|
119
119
|
return stats !== null && stats.length > 0 ? `${titled} (${stats})` : titled;
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* The Log line `checkpoint --plan` writes when the plan lands: dated, with the
|
|
123
|
+
* short SHA. The cold read's record rides beneath it, so the first entry of a
|
|
124
|
+
* build's history is the plan and the read that approved it.
|
|
125
|
+
*/
|
|
126
|
+
export function planLogLine(date, sha) {
|
|
127
|
+
return `- ${date} — plan committed · ${sha.slice(0, 9)}`;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* A Log entry: the dated line and, beneath it, the record of what landed (the
|
|
131
|
+
* pause as the human approved it), blank-line separated and indented two
|
|
132
|
+
* spaces so it nests as the line's own content. Blank lines inside the record
|
|
133
|
+
* stay bare rather than carrying the indent. A null record is the bare line,
|
|
134
|
+
* which is what a hand-built step with no detail file gets.
|
|
135
|
+
*/
|
|
136
|
+
export function logEntry(line, record) {
|
|
137
|
+
if (record === null || record.trim().length === 0) {
|
|
138
|
+
return line;
|
|
139
|
+
}
|
|
140
|
+
const nested = record.split('\n').map((l) => (l.trim().length === 0 ? '' : ` ${l}`));
|
|
141
|
+
return [line, '', ...nested].join('\n');
|
|
142
|
+
}
|
|
121
143
|
/**
|
|
122
144
|
* The Log line `abandon` writes for a dropped-but-kept step: the first
|
|
123
145
|
* non-checkpoint event the log records. Dated, names the step (its title when
|
package/dist/lib/check.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// settings.local.json, the untracked personal overlay → settings.json, the
|
|
5
5
|
// tracked project defaults) is a shell command → spawn it verbatim, so any
|
|
6
6
|
// repo can gate through anything (test fixtures point it at `true`/`false`
|
|
7
|
-
// to keep throwaway repos deterministic)
|
|
7
|
+
// to keep throwaway repos deterministic), and leave the same
|
|
8
|
+
// `.check/summary.json` checkride would, so the pause's readout can
|
|
9
|
+
// measure the gate whichever path ran it;
|
|
8
10
|
// - no setting at all → checkride, our sibling package, imported
|
|
9
11
|
// programmatically rather than spawned: the typed summary comes back
|
|
10
12
|
// in-process, failing slots are reported with their `.check/` raw-output
|
|
@@ -13,7 +15,9 @@
|
|
|
13
15
|
// Checkride's stream discipline holds: human progress goes to stderr. Exit 0 is
|
|
14
16
|
// green, 1 is red, 2 means the harness itself broke: reported distinctly,
|
|
15
17
|
// because a misconfigured gate must not read as broken code (both still block).
|
|
16
|
-
import {
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
17
21
|
import { runChecks, runDoctor } from 'checkride';
|
|
18
22
|
import { gateIsRunningFor, withGateMarker } from "./reentry.js";
|
|
19
23
|
import { resolveString } from "./settings.js";
|
|
@@ -103,17 +107,83 @@ export async function runCheck(root, flags = {}, commandFlag) {
|
|
|
103
107
|
const command = resolveString(root, 'check', '', commandFlag);
|
|
104
108
|
return withGateMarker(root, () => (command.length > 0 ? runCommand(root, command, flags) : runCheckride(root, flags)));
|
|
105
109
|
}
|
|
110
|
+
/** Where the configured command's combined output lands, relative to `.check/`. */
|
|
111
|
+
const OVERRIDE_OUTPUT = 'check.stdout.txt';
|
|
112
|
+
/** The longest a command may run as the summary's check name before it is cut with an ellipsis. */
|
|
113
|
+
const OVERRIDE_NAME_BUDGET = 40;
|
|
106
114
|
/**
|
|
107
115
|
* The spawn override: stream the configured command's own output to the
|
|
108
|
-
* terminal
|
|
109
|
-
* interpretation.
|
|
116
|
+
* terminal as it arrives, keep a copy under `.check/`, and return its exit
|
|
117
|
+
* code. The command is trusted verbatim, no interpretation.
|
|
118
|
+
*
|
|
119
|
+
* The run leaves the same `summary.json` checkride would, with the command as
|
|
120
|
+
* its one check: the pause's readout measures its `check` row from that file
|
|
121
|
+
* and offers `looks good` only on a measured green, so a gate that left no
|
|
122
|
+
* summary rendered no row and withheld the move at every pause in a repo that
|
|
123
|
+
* gates through its own command.
|
|
110
124
|
*/
|
|
111
125
|
function runCommand(root, command, flags) {
|
|
112
126
|
if (hasFlags(flags)) {
|
|
113
127
|
process.stderr.write(`plumbbob: check flags only narrow the checkride gate — ignored for the configured command '${command}'.\n`);
|
|
114
128
|
}
|
|
115
|
-
const
|
|
116
|
-
return
|
|
129
|
+
const started = Date.now();
|
|
130
|
+
return new Promise((resolve) => {
|
|
131
|
+
const chunks = [];
|
|
132
|
+
const child = spawn(command, { cwd: root, shell: true, stdio: ['inherit', 'pipe', 'pipe'] });
|
|
133
|
+
child.stdout.on('data', (chunk) => {
|
|
134
|
+
chunks.push(chunk);
|
|
135
|
+
process.stdout.write(chunk);
|
|
136
|
+
});
|
|
137
|
+
child.stderr.on('data', (chunk) => {
|
|
138
|
+
chunks.push(chunk);
|
|
139
|
+
process.stderr.write(chunk);
|
|
140
|
+
});
|
|
141
|
+
child.on('error', () => resolve(1));
|
|
142
|
+
child.on('close', (code) => {
|
|
143
|
+
const exitCode = code ?? 1;
|
|
144
|
+
writeOverrideSummary(root, command, exitCode, Date.now() - started, Buffer.concat(chunks));
|
|
145
|
+
resolve(exitCode);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Record a configured command's run as a one-check summary under `.check/`,
|
|
151
|
+
* beside its captured output, in the shape checkride writes for its own runs.
|
|
152
|
+
*
|
|
153
|
+
* A summary that cannot be written is reported, never fatal: the gate's
|
|
154
|
+
* verdict is the command's exit code, and a full disk must not turn a green
|
|
155
|
+
* run red.
|
|
156
|
+
*/
|
|
157
|
+
function writeOverrideSummary(root, command, exitCode, durationMs, output) {
|
|
158
|
+
const ok = exitCode === 0;
|
|
159
|
+
const summary = {
|
|
160
|
+
schema_version: 1,
|
|
161
|
+
timestamp: new Date().toISOString(),
|
|
162
|
+
ok,
|
|
163
|
+
checks_run: 1,
|
|
164
|
+
total_duration_ms: durationMs,
|
|
165
|
+
checks: [
|
|
166
|
+
{
|
|
167
|
+
name: command.length <= OVERRIDE_NAME_BUDGET ? command : `${command.slice(0, OVERRIDE_NAME_BUDGET - 1)}…`,
|
|
168
|
+
adapter: null,
|
|
169
|
+
description: `the configured "check" command: ${command}`,
|
|
170
|
+
ok,
|
|
171
|
+
exit_code: exitCode,
|
|
172
|
+
duration_ms: durationMs,
|
|
173
|
+
output_file: OVERRIDE_OUTPUT,
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
try {
|
|
178
|
+
const dir = join(root, '.check');
|
|
179
|
+
mkdirSync(dir, { recursive: true });
|
|
180
|
+
writeFileSync(join(dir, OVERRIDE_OUTPUT), output);
|
|
181
|
+
writeFileSync(join(dir, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`);
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
185
|
+
process.stderr.write(`plumbbob: could not record the check summary under .check/ (${message}).\n`);
|
|
186
|
+
}
|
|
117
187
|
}
|
|
118
188
|
/**
|
|
119
189
|
* The checkride path: run the gate in-process, refuse an all-skipped run, and
|
package/dist/lib/git.js
CHANGED
|
@@ -76,19 +76,74 @@ export function isDirty(root) {
|
|
|
76
76
|
* commits are surfaced, never blocked). `--first-parent` keeps the count on
|
|
77
77
|
* the branch's own line: merging upstream reads as the one merge commit, not
|
|
78
78
|
* the dozens it carried; those didn't land "outside the ledger" in any sense
|
|
79
|
-
* the receipt should nag about.
|
|
80
|
-
*
|
|
81
|
-
*
|
|
79
|
+
* the receipt should nag about. `excluding` is a commit-message pattern whose
|
|
80
|
+
* matches are left out of the count, which is how a caller drops plumbbob's own
|
|
81
|
+
* commits from a tally of what landed around them.
|
|
82
|
+
*
|
|
83
|
+
* Best-effort and never throws: 0 when the range is empty, `sha` is unknown to
|
|
84
|
+
* the repo, or HEAD is unborn: the count is informational (the human commits
|
|
85
|
+
* freely), never a gate.
|
|
82
86
|
*/
|
|
83
|
-
export function commitsSince(root, sha) {
|
|
87
|
+
export function commitsSince(root, sha, excluding) {
|
|
84
88
|
try {
|
|
85
|
-
const
|
|
89
|
+
const skip = excluding === undefined ? [] : ['--grep', excluding, '--invert-grep'];
|
|
90
|
+
const n = Number.parseInt(runGit(root, ['rev-list', '--count', '--first-parent', ...skip, `${sha}..HEAD`]), 10);
|
|
86
91
|
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
87
92
|
}
|
|
88
93
|
catch {
|
|
89
94
|
return 0;
|
|
90
95
|
}
|
|
91
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* The working tree's changes against the index, one entry per file, as
|
|
99
|
+
* `git diff --numstat` reports them; a binary file counts 0/0.
|
|
100
|
+
*
|
|
101
|
+
* This feeds the recap's `diff` and `seam` rows at the pause: information,
|
|
102
|
+
* never a gate, so it is best-effort and never throws ([] on any failure).
|
|
103
|
+
*/
|
|
104
|
+
export function diffNumstat(root) {
|
|
105
|
+
let out;
|
|
106
|
+
try {
|
|
107
|
+
out = runGit(root, ['diff', '--numstat']);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
if (out.length === 0) {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
return out.split('\n').map((line) => {
|
|
116
|
+
const [added, removed, ...path] = line.split('\t');
|
|
117
|
+
return { added: toCount(added), removed: toCount(removed), path: path.join('\t') };
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* A numstat count field as a number: `-` (a binary file) and anything else
|
|
122
|
+
* unparseable read as 0.
|
|
123
|
+
*/
|
|
124
|
+
function toCount(field) {
|
|
125
|
+
const n = Number.parseInt(field ?? '', 10);
|
|
126
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The raw `git diff` patch for `paths` (working tree against the index), or ''
|
|
130
|
+
* when nothing differs or git refuses.
|
|
131
|
+
*
|
|
132
|
+
* Only the pause's inline-diff fence reads this, and only after the numstat
|
|
133
|
+
* count came in at 20 changed lines or fewer, so the patch is always small.
|
|
134
|
+
* Best-effort like diffNumstat: information, never a gate.
|
|
135
|
+
*/
|
|
136
|
+
export function diffPatch(root, paths) {
|
|
137
|
+
if (paths.length === 0) {
|
|
138
|
+
return '';
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
return runGit(root, ['diff', '--', ...paths]);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return '';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
92
147
|
// --- mutation helpers (build-loop: done checkpoints, revert resets). Additive
|
|
93
148
|
// only: stage/commit forward, reset --hard to a recorded checkpoint SHA. ---
|
|
94
149
|
/**
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// The one renderer for every line plumbbob says out loud and a skill relays,
|
|
2
|
+
// and the assembly that stacks those lines into an ending. It has three heads,
|
|
3
|
+
// and the ending picks which: a transition's lead line goes to stdout wearing a
|
|
4
|
+
// bold label, like every other part of the ending
|
|
5
|
+
// (`**Checkpoint**: Step 15 complete (2d917cde7)`); an advisory riding that
|
|
6
|
+
// ending goes to stdout too, as a bare sentence, since a block with one speaker
|
|
7
|
+
// needs no one named; and a refusal goes to stderr keeping the `plumbbob:`
|
|
8
|
+
// prefix, which earns its colon where checkride's output and git's share one
|
|
9
|
+
// result. Under every head the shape is the same: the fact as one clause, the
|
|
10
|
+
// detail a trailing parenthetical that degrades by count instead of running off
|
|
11
|
+
// the edge, and an optional indented arrow line carrying what to do next. One
|
|
12
|
+
// shape, so moving it is one edit rather than a sweep. The pointer at the next
|
|
13
|
+
// step is not a line's job: `handoff` renders one, and the verb prints it as
|
|
14
|
+
// the ending's last part. Pure string assembly, no fs, no gate.
|
|
15
|
+
/** The column budget a notice line aims to fit, matching the readout fence. */
|
|
16
|
+
const WIDTH = 80;
|
|
17
|
+
/** The fewest detail items a degraded parenthetical still names, so the count has a universe to size. */
|
|
18
|
+
const FLOOR = 2;
|
|
19
|
+
/**
|
|
20
|
+
* Render one prefixed notice, newline included.
|
|
21
|
+
*
|
|
22
|
+
* The refusal head, and the head for a warning that joins no ending (an
|
|
23
|
+
* ambient agent that could not resolve, say):
|
|
24
|
+
*
|
|
25
|
+
* ```text
|
|
26
|
+
* plumbbob: no build named "auth" in .plumbbob/builds/ (rate-limit, logging)
|
|
27
|
+
* → plumbbob use <slug>
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function notice(n) {
|
|
31
|
+
return line(`${n.prefix ?? 'plumbbob'}: ${trimFact(n.fact)}${n.advisory === true ? ' ⚠' : ''}`, n);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Render one transition, newline included.
|
|
35
|
+
*
|
|
36
|
+
* The lead line of an ending, wearing a bold label so it reads as a part of the
|
|
37
|
+
* ending rather than as a second grammar beside it:
|
|
38
|
+
*
|
|
39
|
+
* ```text
|
|
40
|
+
* **Checkpoint**: Step 15 complete (2d917cde7)
|
|
41
|
+
* **Parked**: should /password-reset get the same throttle? (tangent)
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* A transition never warns: an advisory qualifies the line and prints beneath
|
|
45
|
+
* it, on the same stream, as its own part of the ending.
|
|
46
|
+
*/
|
|
47
|
+
export function transition(t) {
|
|
48
|
+
return line(`**${t.label}**: ${trimFact(t.fact)}`, t);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Render one advisory of an ending, newline included.
|
|
52
|
+
*
|
|
53
|
+
* No prefix and no label: it rides stdout inside a block that has one speaker
|
|
54
|
+
* already, so it reads as the sentence it is, the warning glyph trailing the
|
|
55
|
+
* fact and the remedy on the line beneath.
|
|
56
|
+
*
|
|
57
|
+
* ```text
|
|
58
|
+
* Staged paths reach outside Step 16's seam ⚠ (test/integration/spike.test.ts)
|
|
59
|
+
* → the checkpoint captures them, so revise the plan with /plumbbob:step
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export function advisory(a) {
|
|
63
|
+
return line(`${capitalize(trimFact(a.fact))} ⚠`, a);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Assemble a transition's whole ending: the labeled lead line, the Verdict
|
|
67
|
+
* where one was measured, the advisories, and the pointer.
|
|
68
|
+
*
|
|
69
|
+
* The order is the type's field order, so a verb composes parts and never a
|
|
70
|
+
* sequence. A part with nothing in it vanishes rather than leaving a gap.
|
|
71
|
+
*/
|
|
72
|
+
export function ending(e) {
|
|
73
|
+
return blocks([e.lead, e.verdict ?? null, ...(e.advisories ?? []), e.pointer ?? null]);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Stack a turn's parts: one blank line between each, one beneath the last.
|
|
77
|
+
*
|
|
78
|
+
* The trailing blank is the ending's own: the block is the turn's last text,
|
|
79
|
+
* and one flush against what follows cannot read as an ending. Empty parts drop
|
|
80
|
+
* out, so a caller can pass a part it may not have.
|
|
81
|
+
*/
|
|
82
|
+
export function blocks(parts) {
|
|
83
|
+
const kept = parts.filter((p) => p !== null && p.trim().length > 0);
|
|
84
|
+
return `${kept.map((p) => p.replace(/\n+$/, '')).join('\n\n')}\n\n`;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The head, its trailing parenthetical, and the remedy line beneath, assembled.
|
|
88
|
+
*
|
|
89
|
+
* The one place the parts meet, so a prefixed line and a labeled one can never
|
|
90
|
+
* drift in anything but their heads.
|
|
91
|
+
*/
|
|
92
|
+
function line(head, n) {
|
|
93
|
+
const detail = fit(head, (n.detail ?? []).map((d) => d.trim()).filter((d) => d.length > 0));
|
|
94
|
+
const text = detail.length === 0 ? head : `${head} (${detail.join(', ')})`;
|
|
95
|
+
return n.remedy === undefined ? `${text}\n` : `${text}\n → ${n.remedy}\n`;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The fact as a bare clause: whitespace trimmed and one terminal period
|
|
99
|
+
* dropped.
|
|
100
|
+
*
|
|
101
|
+
* The formatter owns the line's punctuation, so a message composed elsewhere
|
|
102
|
+
* (a parse error, a park line a human wrote) still reads as one clause with a
|
|
103
|
+
* parenthetical after it rather than `… . (detail)`.
|
|
104
|
+
*/
|
|
105
|
+
function trimFact(fact) {
|
|
106
|
+
const text = fact.trim();
|
|
107
|
+
return text.endsWith('.') && !text.endsWith('..') ? text.slice(0, -1) : text;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The detail items that fit the width budget, with the overrun collapsed to a
|
|
111
|
+
* count.
|
|
112
|
+
*
|
|
113
|
+
* Two items or fewer are the fact's own qualifiers rather than a list, so they
|
|
114
|
+
* always render whole even when the line runs long: a notice wraps where a
|
|
115
|
+
* fence row cannot. A real list drops from the tail until the line fits,
|
|
116
|
+
* naming at least `FLOOR` of them, because a count with nothing beside it
|
|
117
|
+
* sizes a universe the reader cannot see.
|
|
118
|
+
*/
|
|
119
|
+
function fit(head, items) {
|
|
120
|
+
if (items.length <= FLOOR)
|
|
121
|
+
return items;
|
|
122
|
+
for (let keep = items.length; keep > FLOOR; keep -= 1) {
|
|
123
|
+
const shown = keep === items.length ? items : [...items.slice(0, keep), others(items.length - keep)];
|
|
124
|
+
if (columns(head) + columns(` (${shown.join(', ')})`) <= WIDTH)
|
|
125
|
+
return shown;
|
|
126
|
+
}
|
|
127
|
+
return [...items.slice(0, FLOOR), others(items.length - FLOOR)];
|
|
128
|
+
}
|
|
129
|
+
/** A fact's first letter raised, so an unprefixed advisory opens as a sentence. */
|
|
130
|
+
function capitalize(fact) {
|
|
131
|
+
return fact.length === 0 ? fact : `${fact[0]?.toUpperCase()}${fact.slice(1)}`;
|
|
132
|
+
}
|
|
133
|
+
/** The collapsed tail naming how many items the line left out. */
|
|
134
|
+
function others(count) {
|
|
135
|
+
return `and ${count} other${count === 1 ? '' : 's'}`;
|
|
136
|
+
}
|
|
137
|
+
/** A string's printed width, counted in code points so the glyphs measure as one column each. */
|
|
138
|
+
function columns(text) {
|
|
139
|
+
return [...text].length;
|
|
140
|
+
}
|