plumbbob 0.11.0 → 0.11.2

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.11.0",
4
+ "version": "0.11.2",
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.
@@ -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
@@ -311,7 +311,7 @@ function classifyRecapRow(name, value) {
311
311
  test(/^bent\b/, 'failing') ??
312
312
  test(/^drift\b/, 'drift'));
313
313
  case 'seam':
314
- return test(/^held\b/, 'true') ?? test(/^strayed\b/, 'failing') ?? test(/^drift\b/, 'drift');
314
+ return test(/^held\b/, 'true') ?? test(/^strayed\b/, 'advisory') ?? test(/^drift\b/, 'drift');
315
315
  }
316
316
  }
317
317
  /**
@@ -381,6 +381,13 @@ const NOT_STANDING = { glyph: '✗', state: 'Not standing' };
381
381
  * drift beats a live failure beats an advisory beats plumb, and each rung
382
382
  * names the one component that earned it. The advisory rung runs in a fixed
383
383
  * order, the seam stray first.
384
+ *
385
+ * A strayed seam is an advisory, never a failure: a path outside the seam
386
+ * says either that the work wandered or that the line was drawn too tight,
387
+ * and truing the diff fixes neither. At the pause the measured seam row
388
+ * carries the stray; at the boundary the tree is clean, the row has vanished,
389
+ * and the stat the checkpoint bumped carries it instead. Both name it
390
+ * `seam strayed`, so the rung does not change between the two.
384
391
  */
385
392
  export function foldVerdict(rows, stats) {
386
393
  for (const name of RECAP_ROW_NAMES) {
@@ -394,11 +401,17 @@ export function foldVerdict(rows, stats) {
394
401
  return { ladder: OUT_OF_PLUMB, worst: `${name} ${row.word}` };
395
402
  }
396
403
  }
404
+ for (const name of RECAP_ROW_NAMES) {
405
+ const row = rows[name];
406
+ if (row !== undefined && row.verdict === 'advisory') {
407
+ return { ladder: A_HAIR_OFF, worst: `${name} ${row.word}` };
408
+ }
409
+ }
397
410
  if (stats.driftWarnings > 0) {
398
411
  // First of the advisory rung, and uncounted: the stat bumps once per
399
412
  // checkpoint rather than once per stray path, and this is the one advisory
400
413
  // that asks a question of the plan rather than of the work.
401
- return { ladder: A_HAIR_OFF, worst: 'staged outside the seam' };
414
+ return { ladder: A_HAIR_OFF, worst: 'seam strayed' };
402
415
  }
403
416
  if (stats.redChecks > 0) {
404
417
  return { ladder: A_HAIR_OFF, worst: `${stats.redChecks} red run${stats.redChecks === 1 ? '' : 's'} before green` };
@@ -469,10 +482,12 @@ export function summaryCheckRow(summary) {
469
482
  * (the row then vanishes, or the model's attested row stands).
470
483
  *
471
484
  * Green sizes the declared seam: how many of its tokens the diff actually
472
- * touched, and that nothing landed outside them. Red states the size of the
473
- * problem and lets the paths themselves be the evidence: one stray is the `→`
474
- * pointer, and several break onto `- ` lines. `paths` are work-plane changes,
475
- * artifacts already filtered by the caller.
485
+ * touched, and that nothing landed outside them. A stray states the size of
486
+ * the problem and lets the paths themselves be the evidence: one stray is the
487
+ * `→` pointer, and several break onto `- ` lines. It is an advisory, not a
488
+ * failure (the seam is awareness, not a lock), so the fold puts it on the
489
+ * third rung, never the second. `paths` are work-plane changes, artifacts
490
+ * already filtered by the caller.
476
491
  */
477
492
  export function seamRowFromDiff(paths, seam) {
478
493
  if (seam.length === 0 || paths.length === 0) {
@@ -486,8 +501,8 @@ export function seamRowFromDiff(paths, seam) {
486
501
  const evidence = `strayed: ${outside.length} path${outside.length === 1 ? '' : 's'} outside the seam`;
487
502
  const only = outside[0];
488
503
  return outside.length === 1 && only !== undefined
489
- ? { verdict: 'failing', word: 'strayed', evidence, pointer: only }
490
- : { verdict: 'failing', word: 'strayed', evidence, items: outside };
504
+ ? { verdict: 'advisory', word: 'strayed', evidence, pointer: only }
505
+ : { verdict: 'advisory', word: 'strayed', evidence, items: outside };
491
506
  }
492
507
  /**
493
508
  * Sum numstat entries into the diff row's three counts.
@@ -687,6 +702,36 @@ export function parseSummary(detail) {
687
702
  }
688
703
  return { lead, highlights };
689
704
  }
705
+ /**
706
+ * The `## <n>` sections of `.plumbbob/detail.md` with their bodies: the full
707
+ * story behind each highlight, in the order the model wrote them. The body is
708
+ * the section's prose as written, trimmed; empty when the model wrote only the
709
+ * title. The build-log's Log carries these beneath the step's record, so an
710
+ * older step's "expand" reads the ledger rather than a commit.
711
+ */
712
+ export function parseSections(detail) {
713
+ const lines = detail.split('\n');
714
+ const sections = [];
715
+ for (let i = 0; i < lines.length; i++) {
716
+ const m = /^##\s+(\d+)\s+(\S.*?)\s*$/.exec(lines[i] ?? '');
717
+ if (m === null) {
718
+ continue;
719
+ }
720
+ let end = lines.findIndex((l, j) => j > i && l.startsWith('## '));
721
+ if (end === -1) {
722
+ end = lines.length;
723
+ }
724
+ sections.push({
725
+ n: Number(m[1]),
726
+ title: m[2] ?? '',
727
+ body: lines
728
+ .slice(i + 1, end)
729
+ .join('\n')
730
+ .trim(),
731
+ });
732
+ }
733
+ return sections;
734
+ }
690
735
  /**
691
736
  * The `## Recommendation` section of `.plumbbob/detail.md`: the one or two
692
737
  * plain sentences a decision turn ends on, or null when the model wrote none.
@@ -322,10 +322,10 @@ export function clearHandoff(root, slug) {
322
322
  // --- The detail plane: `.plumbbob/detail.md`, one flat per-session file holding
323
323
  // the in-flight step's full detail. The model overwrites it before every pause
324
324
  // (the recap first, then numbered sections matching the highlights); checkpoint
325
- // folds it into the commit body and then clears it, so the durable archive is
326
- // the commit and no stale detail piles up. Flat like STATE/TURN, not per-build:
327
- // a session drives one build at a time, and a past step's expansion reads the
328
- // commit, never this file. ---
325
+ // records it beneath the step's dated line in the build-log's Log and then
326
+ // clears it, so the durable archive is the tracked ledger and no stale detail
327
+ // piles up. Flat like STATE/TURN, not per-build: a session drives one build at
328
+ // a time, and a past step's expansion reads the ledger, never this file. ---
329
329
  /**
330
330
  * detail.md: the in-flight step's full detail, at the flat sidecar root.
331
331
  */
@@ -333,8 +333,8 @@ export function detailPath(root) {
333
333
  return join(root, DIRNAME, 'detail.md');
334
334
  }
335
335
  /**
336
- * Clear detail.md once checkpoint has folded it into the commit body; absent is
337
- * a no-op.
336
+ * Clear detail.md once checkpoint has recorded it in the Log; absent is a
337
+ * no-op.
338
338
  *
339
339
  * The rmSync lives here with the sidecar's other deletions, the same home
340
340
  * clearHandoff and clearTick keep.
@@ -10,6 +10,7 @@
10
10
  // `[x]`, and clears the STEP/SEAM markers: returning the dashboard to the
11
11
  // DESIGN boundary.
12
12
  import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
13
+ import { relative } from 'node:path';
13
14
  import { commit, findRepoRoot, headSha, isDirty, stageAll, stagePath, stagedPaths, stagedStat } from "../lib/git.js";
14
15
  import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearDetail, clearHandoff, clearTick, detailPath, hasSession, intentPath, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.js";
15
16
  import { runCheck } from "../lib/check.js";
@@ -18,10 +19,10 @@ import { checkLatch } from "../lib/latch.js";
18
19
  import { markStepDone, parseSteps } from "../lib/orient.js";
19
20
  import { parseBuildScope, parseStepSeam, scopeDrift } from "../lib/intent.js";
20
21
  import { conventionalSubject, subjectFromTitle, withMarker } from "../lib/commitmsg.js";
21
- import { appendToSection, checkpointLogLine } from "../lib/buildlog.js";
22
+ import { appendToSection, checkpointLogLine, logEntry, planLogLine } from "../lib/buildlog.js";
22
23
  import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
23
24
  import { advisory, ending, notice, transition } from "../lib/notice.js";
24
- import { boundaryEnding } from "./handoff.js";
25
+ import { boundaryEnding, planRecord, stepRecord } from "./handoff.js";
25
26
  /**
26
27
  * Land a step: latch, check gate, commit, record, return to the boundary.
27
28
  *
@@ -80,6 +81,13 @@ export async function checkpoint(cwd, args) {
80
81
  : notice({ fact: 'checkpoint refused', detail: ['the check is red'], remedy: 'fix it, then run it again' }));
81
82
  return 1;
82
83
  }
84
+ // The step's record, as the pause showed it: measured now, before the work
85
+ // is staged, since the seam and diff rows read the working tree the way the
86
+ // pause did. It lands in the build-log's Log beneath the dated line (the
87
+ // tracked ledger is the archive, and it rides the branch where a commit body
88
+ // would not survive the squash); the commit body keeps its marker and the
89
+ // lead prose only.
90
+ const record = stepRecord(root, activeBuild(root), step, readDetail(root) ?? '');
83
91
  let sha;
84
92
  // The drift advisory is read off the index (staged, before the commit clears
85
93
  // it) but printed inside the ending below, where the fixed order puts it: the
@@ -88,18 +96,17 @@ export async function checkpoint(cwd, args) {
88
96
  if (isDirty(root)) {
89
97
  stageAll(root);
90
98
  drift = scopeDriftNotice(root, step);
91
- const lead = bodyResult.body ?? fallbackBody(root, step);
92
- const body = withMarker(`plumbbob step ${step}`, foldDetail(lead, readDetail(root)));
99
+ const body = withMarker(`plumbbob step ${step}`, bodyResult.body ?? fallbackBody(root, step));
93
100
  sha = commit(root, messageArg(args) ?? subjectForStep(root, step), body);
94
- clearDetail(root); // the detail is folded into the commit now; clear it so no stale detail rides into the next step.
95
101
  }
96
102
  else {
97
103
  sha = headSha(root);
98
104
  }
105
+ clearDetail(root); // the detail is recorded in the Log below; clear it so no stale detail rides into the next step.
99
106
  appendFileSync(checkpointsPath(root), `step ${step} ${sha}\n`);
100
107
  const flipNotice = flipIntent(root, step);
101
108
  stampStepStat(root, undefined, step, 'landedAt', new Date().toISOString());
102
- logCheckpoint(root, step, sha);
109
+ const pointer = logCheckpoint(root, step, sha, record);
103
110
  // The step landed: the build-log's ☐/☑ mirror flips and its Current-step line
104
111
  // returns to the boundary, re-read from the intent.md `flipIntent` just wrote.
105
112
  // The artifact-plane whitelist keeps this build-log write from ever reading as
@@ -115,7 +122,7 @@ export async function checkpoint(cwd, args) {
115
122
  // pointer both see the boundary the human is standing at.
116
123
  const landed = boundaryEnding(root, activeBuild(root), step);
117
124
  process.stdout.write(ending({
118
- lead: transition({ label: 'Checkpoint', fact: `Step ${step} complete`, detail: [sha.slice(0, 9)] }),
125
+ lead: transition({ label: 'Checkpoint', fact: `Step ${step} complete`, detail: leadDetail(sha, pointer) }),
119
126
  verdict: landed.verdict,
120
127
  advisories: [drift, flipNotice],
121
128
  pointer: landed.pointer,
@@ -156,13 +163,17 @@ function checkpointPlan(root, args) {
156
163
  const staged = stagePath(root, buildFolder(root));
157
164
  const sha = commit(root, planSubject(root), withMarker('plumbbob plan', bodyResult.body ?? undefined));
158
165
  appendFileSync(checkpointsPath(root), `plan ${sha}\n`);
166
+ // The cold read that approved the plan is the Log's first entry, beneath a
167
+ // dated plan line; the detail file is then spent, the same as at a step.
168
+ const pointer = logPlan(root, sha, planRecord(readDetail(root) ?? ''));
169
+ clearDetail(root);
159
170
  // Landing the plan consumes `start`'s entry stamp: a later hand-built diff
160
171
  // (no `build <n>`) must find no stale TICK and stay guidance-governed.
161
172
  clearTick(root);
162
173
  // No Verdict: the plan commit measures nothing. The pointer aims at the step
163
174
  // the build starts on, which is what `handoff` renders from a null step.
164
175
  process.stdout.write(ending({
165
- lead: transition({ label: 'Plan', fact: 'committed', detail: [sha.slice(0, 9)] }),
176
+ lead: transition({ label: 'Plan', fact: 'committed', detail: leadDetail(sha, pointer) }),
166
177
  advisories: staged
167
178
  ? []
168
179
  : [
@@ -214,7 +225,9 @@ function resolveStep(root, args) {
214
225
  * step's seam, or '' when it held.
215
226
  *
216
227
  * Guidance, not a gate: the checkpoint captures the drift and says so, and the
217
- * bumped stat puts a `staged outside the seam` rung under the Verdict. The seam
228
+ * bumped stat puts a `seam strayed` rung under the Verdict, the same rung the
229
+ * pause's measured seam row gave it, since the row vanishes once the tree is
230
+ * committed and the stat is what carries the stray past that point. The seam
218
231
  * (the step's edit grant: exact paths or `dir/` prefixes) comes from the
219
232
  * in-flight SEAM file when a build is live, else the step's declared seam in
220
233
  * intent.md. Plumbbob's own artifact plane is whitelisted inside `scopeDrift`,
@@ -278,25 +291,51 @@ function flipIntent(root, step) {
278
291
  }
279
292
  }
280
293
  /**
281
- * Append a dated line to the build-log's `## Log` section.
294
+ * The lead line's trailing parenthetical: the short SHA, and where the Log
295
+ * entry landed when one did, as the same `details:` clause Next Up carries.
296
+ */
297
+ function leadDetail(sha, pointer) {
298
+ return pointer === null ? [sha.slice(0, 9)] : [sha.slice(0, 9), `details: \`${pointer}\``];
299
+ }
300
+ /**
301
+ * Append the step's entry to the build-log's `## Log` section: the dated line,
302
+ * and beneath it the record of the pause when the model wrote one.
282
303
  *
283
304
  * The build's history accrues at each checkpoint instead of being reconstructed
284
305
  * at finish; the step's title is lifted from intent.md when still present.
285
306
  * Best-effort: a missing or odd build-log never blocks a checkpoint; the
286
- * `checkpoints` SHA is the source of truth.
307
+ * `checkpoints` SHA is the source of truth. Returns where the line landed, as
308
+ * `path:line` from the repo root, or null when nothing was written.
309
+ */
310
+ function logCheckpoint(root, step, sha, record) {
311
+ const date = new Date().toISOString().slice(0, 10);
312
+ return appendLog(root, checkpointLogLine(date, step, sha, titleForStep(root, step), statsSuffix(root, step)), record);
313
+ }
314
+ /**
315
+ * Append the plan's entry to the Log: a dated `plan committed` line, and
316
+ * beneath it the cold read that approved the plan when the model wrote one.
287
317
  */
288
- function logCheckpoint(root, step, sha) {
318
+ function logPlan(root, sha, record) {
319
+ return appendLog(root, planLogLine(new Date().toISOString().slice(0, 10), sha), record);
320
+ }
321
+ /**
322
+ * Write one Log entry and say where its dated line landed (`path:line` from
323
+ * the repo root), or null when the section is missing or the write failed.
324
+ */
325
+ function appendLog(root, line, record) {
289
326
  try {
290
327
  const path = buildLogPath(root);
291
- const date = new Date().toISOString().slice(0, 10);
292
- const line = checkpointLogLine(date, step, sha, titleForStep(root, step), statsSuffix(root, step));
293
- const updated = appendToSection(readFileSync(path, 'utf8'), 'Log', line);
294
- if (updated !== null) {
295
- writeFileSync(path, updated);
328
+ const updated = appendToSection(readFileSync(path, 'utf8'), 'Log', logEntry(line, record));
329
+ if (updated === null) {
330
+ return null;
296
331
  }
332
+ writeFileSync(path, updated);
333
+ const at = updated.split('\n').lastIndexOf(line);
334
+ return at === -1 ? null : `${relative(root, path)}:${at + 1}`;
297
335
  }
298
336
  catch {
299
337
  // best-effort ledger; never fail a checkpoint over the build-log.
338
+ return null;
300
339
  }
301
340
  }
302
341
  /**
@@ -408,8 +447,8 @@ function fallbackBody(root, step) {
408
447
  * is absent or empty.
409
448
  *
410
449
  * The model overwrites it before every pause; checkpoint reads it once here for
411
- * the fold, and the caller clears it afterward. A missing or blank file folds
412
- * nothing.
450
+ * the Log's record, and the caller clears it afterward. A missing or blank file
451
+ * records nothing.
413
452
  */
414
453
  function readDetail(root) {
415
454
  try {
@@ -420,18 +459,6 @@ function readDetail(root) {
420
459
  return null;
421
460
  }
422
461
  }
423
- /**
424
- * Fold the detail beneath the lead prose for the commit body.
425
- *
426
- * The lead (an explicit `--body`, else the deterministic fallback) leads and the
427
- * detail file's content follows, joined by a blank line so git keeps them as
428
- * separate paragraphs. Either part may be absent; an all-empty fold returns
429
- * undefined so `withMarker` leaves just the marker line.
430
- */
431
- function foldDetail(lead, detail) {
432
- const parts = [lead, detail].filter((part) => part !== undefined && part !== null && part.length > 0);
433
- return parts.length > 0 ? parts.join('\n\n') : undefined;
434
- }
435
462
  /**
436
463
  * The step's done-when line from intent.md, or null when absent or unreadable.
437
464
  */
@@ -35,7 +35,7 @@ import { commitsSince, diffNumstat, diffPatch, findRepoRoot } from "../lib/git.j
35
35
  import { blocks } from "../lib/notice.js";
36
36
  import { isArtifactPath, parseStepSeam } from "../lib/intent.js";
37
37
  import { checkpointsPath, detailPath, hasSession, inSpike, intentPath, readStats, readTurn, resolveBuild, seamPath, stepPath, tickPath, } from "../lib/sidecar.js";
38
- import { countDiff, diffRowValue, foldVerdict, lastLedgerSha, parseConstraintCount, parseLastCheckpoint, parseRecap, parseRecommendation, parseSteps, parseSummary, recapLines, seamRowFromDiff, spentRowValue, summaryCheckRow, } from "../lib/orient.js";
38
+ import { countDiff, diffRowValue, foldVerdict, lastLedgerSha, parseConstraintCount, parseLastCheckpoint, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, recapLines, seamRowFromDiff, spentRowValue, summaryCheckRow, } from "../lib/orient.js";
39
39
  // The inline-diff threshold (docs/presentation.md): at 20 changed lines or
40
40
  // fewer the patch rides the turn; at 21 it stays in the working tree behind
41
41
  // the counted diff row.
@@ -171,6 +171,80 @@ export function boundaryEnding(root, slug, step) {
171
171
  pointer: nextUpLine(nextUp, steps.length, relative(root, intentPath(root, slug))),
172
172
  };
173
173
  }
174
+ /**
175
+ * The step's record for the build-log's Log: the pause as the human approved
176
+ * it, with the full story behind each highlight beneath. The Summary and its
177
+ * highlights, the Readout fence, the Verdict, the recommendation, then the
178
+ * numbered sections' bodies, blank-line separated and unindented; the caller
179
+ * nests it under the Log's dated line.
180
+ *
181
+ * Measured, not recalled: the check, seam, and diff rows are read the same way
182
+ * the pause read them, so this has to be built before the work is staged (the
183
+ * seam and diff rows read the working tree). Null when the detail file carries
184
+ * nothing to record, so a hand-built step with no detail gets the bare dated
185
+ * line it always did.
186
+ */
187
+ export function stepRecord(root, slug, step, detail) {
188
+ const summary = parseSummary(detail);
189
+ const recommendation = parseRecommendation(detail);
190
+ const sections = parseSections(detail);
191
+ if (summary === null && recommendation === null && sections.length === 0 && parseRecap(detail) === null) {
192
+ // Nothing on the wire's shape: a blank file records nothing, and anything
193
+ // else the model wrote is kept verbatim rather than lost with the file.
194
+ const raw = detail.trim();
195
+ return raw.length === 0 ? null : raw;
196
+ }
197
+ const intent = readOr(intentPath(root, slug));
198
+ const { rows, work, summary: check } = measured(root, slug, intent, detail, step);
199
+ const { ladder, worst } = foldVerdict(rows, accruedStats(root, slug, step));
200
+ const readout = recapLines(rows, {
201
+ diff: diffRowValue(countDiff(work), false),
202
+ spent: spentRowValue(spentInputs(root, slug, step, check)),
203
+ constraints: parseConstraintCount(intent),
204
+ });
205
+ const parts = [];
206
+ if (summary !== null) {
207
+ parts.push(recordSummary(summary));
208
+ }
209
+ if (readout.length > 0) {
210
+ parts.push([readoutLabel(step, parseSteps(intent)), '', ...fence('text', readout)].join('\n'));
211
+ }
212
+ parts.push(verdictLine(ladder, worst));
213
+ return [...withRecommendation(parts, recommendation), ...sectionBlocks(sections)].join('\n\n');
214
+ }
215
+ /**
216
+ * The plan's record for the Log: the cold read's recommendation and the
217
+ * findings behind it. Nothing is measured at the plan pause, so no Readout and
218
+ * no Verdict ride here. Null when the detail file carries neither.
219
+ */
220
+ export function planRecord(detail) {
221
+ const recommendation = parseRecommendation(detail);
222
+ const sections = parseSections(detail);
223
+ if (recommendation === null && sections.length === 0) {
224
+ return null;
225
+ }
226
+ return [...withRecommendation([], recommendation), ...sectionBlocks(sections)].join('\n\n');
227
+ }
228
+ /**
229
+ * The record's opening block: the Summary lead without the `(details: …)`
230
+ * bracket, since the record is where the detail now lives, then the
231
+ * highlights under their own handles.
232
+ */
233
+ function recordSummary(summary) {
234
+ const lead = `**Summary**: ${summary.lead}`;
235
+ if (summary.highlights.length === 0) {
236
+ return lead;
237
+ }
238
+ return [lead, '', ...summary.highlights.map((h) => `${h.n}. ${h.title}`)].join('\n');
239
+ }
240
+ /**
241
+ * The full story behind each highlight, one block per section: the handle in
242
+ * bold, the title, then the body as the model wrote it. A section with no body
243
+ * is just its line.
244
+ */
245
+ function sectionBlocks(sections) {
246
+ return sections.map((s) => (s.body.length === 0 ? `**${s.n}.** ${s.title}` : `**${s.n}.** ${s.title}\n\n${s.body}`));
247
+ }
174
248
  /**
175
249
  * The driver turn's pointer: back at the step still open, at the spike raised
176
250
  * over it, or forward from the boundary when the step has already exited.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plumbbob",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "Attention-first build process: 14 skills + a CLI that keep you the decider — guidance on the work, a latch on the record.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -173,8 +173,9 @@ switch with `/model` and rerun to honor it, or wave you on. Advisory, never a ga
173
173
  sentence, closed by a period, then the reason as a capitalized sentence or two. The lead
174
174
  and the recommendation are flowing prose and handoff unwraps both to the renderer's
175
175
  width, so never hard-wrap them; handoff also prepends the bold `**Recommendation**:`
176
- label, which you never type. `checkpoint` folds this file into the commit body and then
177
- truncates it, so the detail lands in the durable commit rather than piling up on disk.
176
+ label, which you never type. `checkpoint` records this file beneath the step's dated
177
+ line in the build-log's `## Log` and then truncates it, so the detail lands in the
178
+ tracked ledger rather than piling up on disk.
178
179
 
179
180
  *Paste the turn.* Run `plumbbob handoff` and paste its output whole, verbatim, at top
180
181
  level, trailing blank line included, then end the turn. Never nest it inside a fence of
@@ -192,7 +193,7 @@ switch with `/model` and rerun to honor it, or wave you on. Advisory, never a ga
192
193
 
193
194
  *Then read the reply as an ask or a direction.* A message that **asks** ("expand 2",
194
195
  "what does that mean?", "why did the seam row flag that?") is an expand: answer it from the detail file, from
195
- `git diff`, or from `git show`, never from recall, then run `plumbbob handoff` again and
196
+ `git diff`, or from the build-log's `## Log` for an older step, never from recall, then run `plumbbob handoff` again and
196
197
  paste it. The step is still in flight, so it renders the same pause, and the Your Call
197
198
  block stays the CLI's to render rather than yours to retype. A message that **directs**
198
199
  is needs-work: take it as what to change, and nothing lands until the human says
@@ -145,13 +145,16 @@ an agent can follow with `/plumbbob:build`. The argument only seeds how you get
145
145
  heredoc) when the rationale is worth carrying; skip it for a small plan. Do this
146
146
  only on the human's approval; the plan is their convergence.
147
147
  - **The plan pause is a decision turn.** Present the framed plan for the human's call,
148
- then relay `plumbbob handoff --plan`'s plan-pause card: the CLI renders the your-call
149
- block with the two moves that apply before anything is recorded (the decision tier of
150
- the [turn anatomy](https://github.com/robmclarty/plumbbob/blob/main/docs/presentation.md)),
148
+ then give it the cold read (**§ The cold read**), write the result into
149
+ `.plumbbob/detail.md`, and relay `plumbbob handoff --plan`'s block whole: the CLI
150
+ renders the your-call block with the moves that apply before anything is recorded and
151
+ prints your recommendation last (the decision tier of the
152
+ [turn anatomy](https://github.com/robmclarty/plumbbob/blob/main/docs/presentation.md)),
151
153
  so the moves are relayed, never hand-composed. *looks good* marks the plan decided and
152
- starts `/plumbbob:build` at the first undone step; *needs work* sends you back to sharpen
153
- it, which is cheap now, before any code. The revert move vanishes there on its own:
154
- nothing has landed to wind back.
154
+ starts `/plumbbob:build` at the first undone step; a message that asks (`expand 2`, or
155
+ any question) is answered from the detail file or `intent.md` and changes nothing; a
156
+ message that directs is what to sharpen, cheap now, before any code. The revert move
157
+ vanishes there on its own: nothing has landed to wind back.
155
158
  - **The plan commit is latched too.** Once the turn ledger exists, `checkpoint
156
159
  --plan` refuses to land in the same turn `start` stamped it: present the plan,
157
160
  **end the turn**, and the human's approving message is the tick that lets it
@@ -179,6 +182,48 @@ an agent can follow with `/plumbbob:build`. The argument only seeds how you get
179
182
  7. **Offer to stress-test it.** Suggest `/plumbbob:refine` to attack the frame for holes (or
180
183
  to repair the plan as it drifts). Optional, the human's call.
181
184
 
185
+ ## The cold read
186
+
187
+ The plan pause ends on your recommendation, and the recommendation is an estimate made
188
+ with fresh eyes, not a re-reading of your own plan. Before you relay the card, give the
189
+ framed plan one bounded adversarial pass under a fixed lens, the pass `/plumbbob:refine`'s
190
+ attack mode makes, but without its writing: surface, never append. Edit nothing, add no
191
+ Open questions, and keep it to one screen of reading. The lens:
192
+
193
+ - an ambiguity a builder would have to guess through;
194
+ - an edge case or a hidden assumption no Decision settles;
195
+ - a collision with the existing code (read the files the seams name);
196
+ - a done-when no test or check can measure, or a seam that names a file that does not
197
+ exist or misses one that must change;
198
+ - a step too large to review in one pass, or a Decision with no *because*.
199
+
200
+ Write what you found into `.plumbbob/detail.md`, overwriting whatever a past session
201
+ left, the findings first and the recommendation last:
202
+
203
+ ```markdown
204
+ # Detail · Plan · <the title>
205
+
206
+ ## 1 <the first hole: one sentence, naming where it sits in intent.md>
207
+ <what it is, and what closing it would take>
208
+
209
+ ## 2 ...
210
+
211
+ ## Recommendation
212
+
213
+ <The move.> <The reason, one or two sentences.>
214
+ ```
215
+
216
+ The recommendation takes one of two shapes. A sound plan gets `Approve it.` and what the
217
+ read checked and found sound. A plan with a hole gets `Sharpen <the one worst hole> first.`
218
+ and why it matters; when the read found more than one, say how many sit behind `expand`
219
+ and name `/plumbbob:refine` as the move that writes them up as Open questions, with their
220
+ plain and lean sub-lines, for the human to settle. Three findings at most; the rest is
221
+ refine's. `plumbbob handoff --plan` prints the label and your recommendation as the turn's
222
+ last text, "expand 2" opens `## 2`, and `checkpoint --plan` records the read beneath the
223
+ plan's line in the build-log's `## Log`, the build's first entry. The cold read is the tip of refine: it makes the
224
+ full attack discoverable at the moment it would pay, and lets a sound plan skip it. Refine
225
+ is where the real adversary looks; this is an estimate, and it says so by staying short.
226
+
182
227
  ## The interview (mode 1)
183
228
 
184
229
  Make it easy and non-intrusive:
@@ -109,8 +109,8 @@ this skill verifies it the same way: **it reads the diff, not the author**
109
109
  width, so never hard-wrap them; handoff also prepends the bold `**Recommendation**:`
110
110
  label, which you never type. This is the **one** file `/plumbbob:verify` writes, and it
111
111
  is turn presentation, never the diff under review; the author-blind contract below still
112
- holds. `checkpoint` folds this file into the commit body and then truncates it (the
113
- detail plane in the
112
+ holds. `checkpoint` records this file beneath the step's dated line in the build-log's
113
+ `## Log` and then truncates it (the detail plane in the
114
114
  [turn anatomy](https://github.com/robmclarty/plumbbob/blob/main/docs/presentation.md)).
115
115
 
116
116
  *Paste the turn.* Run `plumbbob handoff` and paste its output whole, verbatim, at top
@@ -130,7 +130,7 @@ this skill verifies it the same way: **it reads the diff, not the author**
130
130
 
131
131
  *Then read the reply as an ask or a direction.* A message that **asks** ("expand 2",
132
132
  "what does that mean?", "why did the seam row flag that?") is an expand: answer it from the detail file, from
133
- `git diff`, or from `git show`, never from recall, then run `plumbbob handoff` again and
133
+ `git diff`, or from the build-log's `## Log` for an older step, never from recall, then run `plumbbob handoff` again and
134
134
  paste it. The step is still in flight, so it renders the same pause, and the Your Call
135
135
  block stays the CLI's to render rather than yours to retype. A message that **directs**
136
136
  is needs-work: take it as what to change, and nothing lands until the human says
@@ -51,9 +51,10 @@ Harvest results this boundary:
51
51
 
52
52
  ## Log
53
53
 
54
- *(The build's history, oldest first. `plumbbob checkpoint` appends a dated line here
55
- every time a step lands (via `/plumbbob:build` or `/plumbbob:verify`), so this
56
- fills in as you go, not at the end. Add your own decision/event lines too: this is what
54
+ *(The build's history, oldest first. `plumbbob checkpoint` appends an entry here
55
+ every time a step lands (via `/plumbbob:build` or `/plumbbob:verify`): the dated line,
56
+ and beneath it the pause as you approved it, so this fills in as you go, not at the
57
+ end, and an older step's detail is a scroll away. Add your own decision/event lines too: this is what
57
58
  you point at to say "I did that: the LLM helped, but those were my calls."
58
59
  `/plumbbob:finish` reads this for the report; `plumbbob finish` commits it with the build
59
60
  folder, so it rides the branch into the PR.)*