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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "plumbbob",
3
3
  "displayName": "PlumbBob",
4
- "version": "0.10.3",
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="demo.svg" alt="An animated terminal session: /plumbbob:plan writes the plan, /plumbbob:build implements step 1 and stops at the PAUSE, the human approves, the step is checkpointed" width="760">
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
- PAUSE — read the diff as an editor. Approve to checkpoint, or send fixes.
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
+ ![The build pause as Claude Code renders it](docs/media/pause.svg)
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
- 3. **Approve**, or send fixes. On your OK it commits the step as a checkpoint, marks
129
- it done, and returns to the boundary. Fire `/plumbbob:build` again for the next step:
130
- re-firing it *is* the clock tick. Whenever you lose the thread, `/plumbbob:status` shows
131
- where you are and names the next move.
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
+ ![The boundary after looks good](docs/media/checkpoint-boundary.svg)
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), one session-gated post-edit hook (non-blocking lint feedback in
191
- flow), and a `.plumbbob/` sidecar you can open and edit by hand at any time: a tracked
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: the skill-eval harness
264
- > ([`test/evals/`](test/evals/)) sweeps this loop headless (prose-only baseline vs. the
265
- > shipped latch, N=5 per contract, every assertion a mechanical read of git state) and
266
- > commits each receipt under [`docs/evals/`](docs/evals/). The first sweep
267
- > ([opus, 2026-07-11](docs/evals/2026-07-11.md)) closed the route the latch owns
268
- > (*no checkpoint over a red check under pressure* went from **2/5 prose-only to 5/5
269
- > latched**) and named, rather than papered over, the two honest gaps it surfaced: park
270
- > capture at 0/5, and adversarial pressure flipping a standing `auto` grant a model could
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 standardized build hand-off block (pause or post-checkpoint); read-only',
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: [BUILD_FLAG],
86
- notes: 'Read-only; refuses (exit 1) only with no session.',
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',
@@ -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}" — looked in ${projectAgentsDir(root)} and ${personalAgentsDir(home)}.`,
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 — the envelope must be a single JSON object.' };
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
  }
@@ -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 { spawnSync } from 'node:child_process';
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 and return its exit code: the command is trusted verbatim, no
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 result = spawnSync(command, { cwd: root, shell: true, stdio: 'inherit' });
116
- return result.status ?? 1;
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. Best-effort and never throws: 0 when the range
80
- * is empty, `sha` is unknown to the repo, or HEAD is unborn: the count is
81
- * informational (the human commits freely), never a gate.
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 n = Number.parseInt(runGit(root, ['rev-list', '--count', '--first-parent', `${sha}..HEAD`]), 10);
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
+ }