plumbbob 0.11.2 → 0.12.0

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.2",
4
+ "version": "0.12.0",
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
@@ -145,7 +145,7 @@ In Claude Code, inside any git repo with a clean tree:
145
145
  1. The bucket lives in memory, keyed by IP, refilling five tokens a minute.
146
146
  2. The clock is injected, so the test advances time instead of sleeping on it.
147
147
 
148
- **Readout**: Step 1 - Add a token-bucket limiter
148
+ **Readout**: Step 1 - feat(limiter): add a token-bucket limiter
149
149
 
150
150
  ```text
151
151
  check green: 7 of 7 checks
@@ -159,7 +159,7 @@ In Claude Code, inside any git repo with a clean tree:
159
159
 
160
160
  **Verdict**: ● Plumb
161
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`)
162
+ **Next Up**: Step 2 of 3 - feat(login): wire the limiter into POST /login (model: **Sonnet**, details: `.plumbbob/builds/2026-07-03-rate-limit-the-login-endpoint/intent.md:20`)
163
163
 
164
164
  **Your Call**:
165
165
 
@@ -186,7 +186,7 @@ In Claude Code, inside any git repo with a clean tree:
186
186
 
187
187
  **Verdict**: ● Plumb
188
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`)
189
+ **Next Up**: Step 2 of 3 - feat(login): wire the limiter into POST /login (model: **Sonnet**, details: `.plumbbob/builds/2026-07-03-rate-limit-the-login-endpoint/intent.md:20`)
190
190
  ```
191
191
 
192
192
  ![The boundary after looks good](docs/media/checkpoint-boundary.svg)
@@ -221,7 +221,7 @@ nothing else:
221
221
  | Skill <img alt="" width="110" height="1"> | Does |
222
222
  | ------------------------------------ | ------ |
223
223
  | `/plumbbob:plan` | plan the whole goal: open the session and author intent's Frame, Decisions, Constraints, **and all Steps** |
224
- | `/plumbbob:build` | implement the next planned step, then verify it to the pause: `--auto` self-approves and chains to done; a range like `1-3` self-approves through step 3, then pauses |
224
+ | `/plumbbob:build` | implement the next planned step, then verify it to the pause: `--auto` self-approves and chains to done; a range like `1-3` self-approves through step 3, then stops |
225
225
  | `/plumbbob:finish` | finish up: write the report, make the final commit, clear for a fresh goal |
226
226
 
227
227
  **Plan-shaping moves**: optional, for when the plan needs work mid-flight:
package/dist/lib/git.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // `--hard` only to plumbbob's own recorded checkpoint SHAs: never a
6
6
  // history-rewriting operation on anything pushed.
7
7
  import { execFileSync } from 'node:child_process';
8
+ import { readFileSync } from 'node:fs';
8
9
  import { isAbsolute, join } from 'node:path';
9
10
  /**
10
11
  * Run git in `root` and return its trimmed stdout.
@@ -95,16 +96,30 @@ export function commitsSince(root, sha, excluding) {
95
96
  }
96
97
  }
97
98
  /**
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.
99
+ * The step's whole product, one entry per file: everything changed since HEAD,
100
+ * staged or not, plus every non-ignored untracked file counted as all-added
101
+ * lines.
102
+ *
103
+ * That universe is the one `checkpoint`'s drift warning already uses after it
104
+ * stages, so the pause and the boundary measure the same thing. `diff HEAD`
105
+ * (rather than a working-tree diff merged with a cached one) nets a file
106
+ * staged and then edited again into a single row; `--no-renames` keeps a
107
+ * staged `git mv` as two plain paths instead of one `{old => new}` arrow,
108
+ * which is also the shape the seam row wants.
100
109
  *
101
110
  * This feeds the recap's `diff` and `seam` rows at the pause: information,
102
111
  * never a gate, so it is best-effort and never throws ([] on any failure).
103
112
  */
104
113
  export function diffNumstat(root) {
114
+ return [...trackedNumstat(root), ...untrackedNumstat(root)];
115
+ }
116
+ /**
117
+ * The tracked half of `diffNumstat`: what differs from HEAD, index included.
118
+ */
119
+ function trackedNumstat(root) {
105
120
  let out;
106
121
  try {
107
- out = runGit(root, ['diff', '--numstat']);
122
+ out = runGit(root, ['diff', '--numstat', '--no-renames', 'HEAD']);
108
123
  }
109
124
  catch {
110
125
  return [];
@@ -114,9 +129,59 @@ export function diffNumstat(root) {
114
129
  }
115
130
  return out.split('\n').map((line) => {
116
131
  const [added, removed, ...path] = line.split('\t');
117
- return { added: toCount(added), removed: toCount(removed), path: path.join('\t') };
132
+ return { added: toCount(added), removed: toCount(removed), path: path.join('\t'), untracked: false };
118
133
  });
119
134
  }
135
+ /**
136
+ * The untracked half of `diffNumstat`: every non-ignored new file, counted as
137
+ * all-added lines.
138
+ *
139
+ * The count is read in node rather than spawned per file: the alternative
140
+ * (`git diff --no-index --numstat /dev/null <path>`) exits 1 by design and
141
+ * prints the path in rename shape, so it needs an exit-1 catch and an arrow
142
+ * parser for every file. The trade-off, accepted: gitattributes are not
143
+ * consulted for a file git has never seen.
144
+ */
145
+ function untrackedNumstat(root) {
146
+ let paths;
147
+ try {
148
+ paths = untrackedPaths(root);
149
+ }
150
+ catch {
151
+ return [];
152
+ }
153
+ return paths.map((path) => ({ added: addedLines(join(root, path)), removed: 0, path, untracked: true }));
154
+ }
155
+ /**
156
+ * The line count a numstat row would carry for a whole new file: newlines, plus
157
+ * one for an unterminated last line, the way git counts it.
158
+ *
159
+ * A NUL byte in the first 8000 bytes means binary, which numstat prints as `-`
160
+ * and `toCount` reads as 0. An unreadable file counts 0 as well, and the caller
161
+ * keeps its path either way: the seam row still has to see it.
162
+ */
163
+ function addedLines(absolute) {
164
+ let buffer;
165
+ try {
166
+ buffer = readFileSync(absolute);
167
+ }
168
+ catch {
169
+ return 0;
170
+ }
171
+ if (buffer.subarray(0, 8000).includes(0)) {
172
+ return 0;
173
+ }
174
+ if (buffer.length === 0) {
175
+ return 0;
176
+ }
177
+ let lines = 0;
178
+ for (const byte of buffer) {
179
+ if (byte === 0x0a) {
180
+ lines += 1;
181
+ }
182
+ }
183
+ return buffer[buffer.length - 1] === 0x0a ? lines : lines + 1;
184
+ }
120
185
  /**
121
186
  * A numstat count field as a number: `-` (a binary file) and anything else
122
187
  * unparseable read as 0.
@@ -126,22 +191,54 @@ function toCount(field) {
126
191
  return Number.isFinite(n) && n >= 0 ? n : 0;
127
192
  }
128
193
  /**
129
- * The raw `git diff` patch for `paths` (working tree against the index), or ''
130
- * when nothing differs or git refuses.
194
+ * The raw patch for `entries` (everything since HEAD, new files included), or
195
+ * '' when nothing differs or git refuses.
196
+ *
197
+ * Tracked paths come through one `git diff HEAD`; an untracked file has no
198
+ * blob to diff, so each goes through `git diff --no-index` against /dev/null,
199
+ * which prints the same `new file mode` header a staged new file gets. That
200
+ * form implies `--exit-code`, so a patch arrives as a thrown status 1 with the
201
+ * text on `stdout`. Entries counted at 0 added lines are skipped: they would
202
+ * produce nothing, and skipping them caps the spawns at the inline threshold.
131
203
  *
132
204
  * Only the pause's inline-diff fence reads this, and only after the numstat
133
205
  * count came in at 20 changed lines or fewer, so the patch is always small.
134
206
  * Best-effort like diffNumstat: information, never a gate.
135
207
  */
136
- export function diffPatch(root, paths) {
137
- if (paths.length === 0) {
138
- return '';
208
+ export function diffPatch(root, entries) {
209
+ const tracked = entries.filter((e) => !e.untracked).map((e) => e.path);
210
+ const pieces = [];
211
+ if (tracked.length > 0) {
212
+ try {
213
+ pieces.push(runGit(root, ['diff', '--no-renames', 'HEAD', '--', ...tracked]));
214
+ }
215
+ catch {
216
+ // best-effort: a path git refuses drops out of the fence, the rest stays.
217
+ }
218
+ }
219
+ for (const entry of entries) {
220
+ if (!entry.untracked || entry.added === 0) {
221
+ continue;
222
+ }
223
+ pieces.push(noIndexPatch(root, entry.path));
139
224
  }
225
+ return pieces.filter((piece) => piece.length > 0).join('\n');
226
+ }
227
+ /**
228
+ * One untracked file's patch, `git diff --no-index` against /dev/null.
229
+ *
230
+ * The path stays repo-relative so the header reads `+++ b/src/new.ts`, the same
231
+ * shape `diff HEAD` prints for a staged new file. `--no-index` implies
232
+ * `--exit-code`: status 1 is the success case, and `encoding` is set on the
233
+ * spawn, so git's output arrives as a string on the error.
234
+ */
235
+ function noIndexPatch(root, path) {
140
236
  try {
141
- return runGit(root, ['diff', '--', ...paths]);
237
+ return runGit(root, ['diff', '--no-index', '--', '/dev/null', path]);
142
238
  }
143
- catch {
144
- return '';
239
+ catch (error) {
240
+ const failure = error;
241
+ return failure.status === 1 && typeof failure.stdout === 'string' ? failure.stdout.trim() : '';
145
242
  }
146
243
  }
147
244
  // --- mutation helpers (build-loop: done checkpoints, revert resets). Additive
package/dist/lib/latch.js CHANGED
@@ -112,7 +112,7 @@ export function checkLatch(root, step) {
112
112
  /**
113
113
  * The current GRANT, or null when the file is absent or unparseable.
114
114
  */
115
- function readGrant(root) {
115
+ export function readGrant(root) {
116
116
  try {
117
117
  return parseGrant(readFileSync(grantPath(root), 'utf8'));
118
118
  }
@@ -741,6 +741,16 @@ export function parseSections(detail) {
741
741
  export function parseRecommendation(detail) {
742
742
  return sectionProse(detail, /^##\s+recommendation\s*$/i);
743
743
  }
744
+ /**
745
+ * The step `.plumbbob/detail.md` was written for, read off its
746
+ * `# Detail · Step <n> · <title>` header, or null when the first line is not a
747
+ * step header (a plan pause's detail, or a file the model wrote without one).
748
+ */
749
+ export function parseDetailStep(detail) {
750
+ const first = detail.trimStart().split('\n', 1)[0] ?? '';
751
+ const m = /^#\s+Detail\b.*?\bStep\s+(\d+)\b/.exec(first);
752
+ return m === null ? null : Number(m[1]);
753
+ }
744
754
  /**
745
755
  * Render an Orientation as the plain-text dashboard `status` prints.
746
756
  */
@@ -10,14 +10,16 @@ import { hasSession, intentPath, resolveBuild, seamPath, stampStepStat, stampTic
10
10
  import { parseStepSeam } from "../lib/intent.js";
11
11
  import { parseSteps } from "../lib/orient.js";
12
12
  import { stepLabel, syncBuildLogState } from "../lib/buildlogsync.js";
13
+ import { readGrant } from "../lib/latch.js";
13
14
  import { notice } from "../lib/notice.js";
14
15
  /**
15
16
  * Enter step n (or the next undone step) and write its SEAM/STEP markers.
16
17
  *
17
18
  * Refuses a missing session, a malformed step argument, an `N-M` range (a
18
19
  * `/plumbbob:build` skill feature, not a CLI one), and a seam that fails to parse.
19
- * On entry it stamps the turn ledger and the step's start time, and re-renders
20
- * the build-log's Current step line.
20
+ * On entry it stamps the turn ledger and the step's start time, re-renders the
21
+ * build-log's Current step line, and says so when the step is the top of a
22
+ * range the human typed.
21
23
  */
22
24
  export function build(cwd, args) {
23
25
  const root = findRepoRoot(cwd);
@@ -28,12 +30,13 @@ export function build(cwd, args) {
28
30
  const { build: slug, rest } = resolveBuild(root, args);
29
31
  const raw = rest.find((a) => !a.startsWith('--'));
30
32
  // A step range like `1-3` is a `/plumbbob:build` skill affordance (auto-approve
31
- // through the range, then pause), not a CLI capability: the CLI records one
32
- // in-flight step at a time. Name it rather than bounce off the generic usage.
33
+ // every step through the range's top, then stop), not a CLI capability: the CLI
34
+ // records one in-flight step at a time. Name it rather than bounce off the
35
+ // generic usage.
33
36
  if (raw !== undefined && /^\d+-\d*$/.test(raw)) {
34
37
  process.stderr.write(notice({
35
38
  fact: 'build takes one step number',
36
- detail: [`\`${raw}\` is a /plumbbob:build range, which auto-approves through it and then pauses`],
39
+ detail: [`\`${raw}\` is a /plumbbob:build range, which auto-approves through its top and then stops`],
37
40
  remedy: `plumbbob build ${raw.split('-')[0]}`,
38
41
  }));
39
42
  return 1;
@@ -94,9 +97,22 @@ export function build(cwd, args) {
94
97
  : skipped > 0
95
98
  ? ['explicitly requested', `skips ${skipped} undone step${skipped === 1 ? '' : 's'}`]
96
99
  : [];
97
- // Two lines, one colon each, then the seam as a plain readout beneath the
98
- // notice that frames it: the paths are a list, and a list is not a one-liner.
100
+ // The top step of a typed range lands like every step under it, and the turn
101
+ // ends on its checkpoint. Saying so as the step is entered puts that fact in
102
+ // front of the model before it chooses between landing the step and holding
103
+ // it at a pause, which is the choice a range kept getting wrong.
104
+ const grant = readGrant(root);
105
+ const top = grant?.kind === 'range' && grant.ceiling === step
106
+ ? notice({
107
+ fact: `step ${step} is the top of the range you granted`,
108
+ remedy: 'land it on green, then stop at the boundary',
109
+ })
110
+ : '';
111
+ // Two lines (three at a range's top), one colon each, then the seam as a
112
+ // plain readout beneath the notice that frames it: the paths are a list, and
113
+ // a list is not a one-liner.
99
114
  process.stdout.write(notice({ fact: `building step ${step}`, detail: picked }) +
115
+ top +
100
116
  notice({
101
117
  fact: 'the seam is orientation, not a lock',
102
118
  detail: [`${parsed.seam.length} path${parsed.seam.length === 1 ? '' : 's'}`],
@@ -16,7 +16,7 @@ import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, check
16
16
  import { runCheck } from "../lib/check.js";
17
17
  import { readCommitBody } from "../lib/commitbody.js";
18
18
  import { checkLatch } from "../lib/latch.js";
19
- import { markStepDone, parseSteps } from "../lib/orient.js";
19
+ import { markStepDone, parseDetailStep, parseSteps } from "../lib/orient.js";
20
20
  import { parseBuildScope, parseStepSeam, scopeDrift } from "../lib/intent.js";
21
21
  import { conventionalSubject, subjectFromTitle, withMarker } from "../lib/commitmsg.js";
22
22
  import { appendToSection, checkpointLogLine, logEntry, planLogLine } from "../lib/buildlog.js";
@@ -81,13 +81,13 @@ export async function checkpoint(cwd, args) {
81
81
  : notice({ fact: 'checkpoint refused', detail: ['the check is red'], remedy: 'fix it, then run it again' }));
82
82
  return 1;
83
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
84
+ // The step's record, as the pause showed it: built here, before the work is
85
+ // staged, though both measures now run against HEAD, so staging no longer
86
+ // moves them. It lands in the build-log's Log beneath the dated line (the
87
87
  // tracked ledger is the archive, and it rides the branch where a commit body
88
88
  // would not survive the squash); the commit body keeps its marker and the
89
89
  // lead prose only.
90
- const record = stepRecord(root, activeBuild(root), step, readDetail(root) ?? '');
90
+ const record = stepRecord(root, activeBuild(root), step, readDetail(root, step) ?? '');
91
91
  let sha;
92
92
  // The drift advisory is read off the index (staged, before the commit clears
93
93
  // it) but printed inside the ending below, where the fixed order puts it: the
@@ -165,7 +165,7 @@ function checkpointPlan(root, args) {
165
165
  appendFileSync(checkpointsPath(root), `plan ${sha}\n`);
166
166
  // The cold read that approved the plan is the Log's first entry, beneath a
167
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) ?? ''));
168
+ const pointer = logPlan(root, sha, planRecord(readDetail(root, null) ?? ''));
169
169
  clearDetail(root);
170
170
  // Landing the plan consumes `start`'s entry stamp: a later hand-built diff
171
171
  // (no `build <n>`) must find no stale TICK and stay guidance-governed.
@@ -444,16 +444,23 @@ function fallbackBody(root, step) {
444
444
  }
445
445
  /**
446
446
  * The in-flight step's detail from `.plumbbob/detail.md`, or null when the file
447
- * is absent or empty.
447
+ * is absent, empty, or written for another step.
448
448
  *
449
449
  * The model overwrites it before every pause; checkpoint reads it once here for
450
450
  * the Log's record, and the caller clears it afterward. A missing or blank file
451
- * records nothing.
451
+ * records nothing, and so does one whose header names a different step: that
452
+ * file is a leftover, written after its own step had already landed, and
453
+ * recording it would file one step's story under another. The plan commit
454
+ * passes no step and takes the file as it is.
452
455
  */
453
- function readDetail(root) {
456
+ function readDetail(root, step) {
454
457
  try {
455
458
  const raw = readFileSync(detailPath(root), 'utf8').trim();
456
- return raw.length > 0 ? raw : null;
459
+ if (raw.length === 0) {
460
+ return null;
461
+ }
462
+ const written = step === null ? null : parseDetailStep(raw);
463
+ return written === null || written === step ? raw : null;
457
464
  }
458
465
  catch {
459
466
  return null;
@@ -15,12 +15,12 @@
15
15
  // it cannot narrate into.
16
16
  //
17
17
  // If the CLI can compute a recap row, the CLI does: the check row comes from
18
- // the last run's summary, the seam row from the SEAM marker against the
19
- // work-plane diff, the diff row from `git diff --numstat`. The model's part
20
- // (`.plumbbob/detail.md`) supplies only what takes judgment: the Summary, the
21
- // three judgment rows, and the recommendation; the Verdict folds the same
22
- // assembled rows worst-of with the step's accrued stats, so the fence shows
23
- // exactly what the fold saw.
18
+ // the last run's summary, the seam and diff rows from the SEAM marker against
19
+ // the step's whole product (everything changed since HEAD, staged or not, plus
20
+ // each non-ignored new file). The model's part (`.plumbbob/detail.md`) supplies
21
+ // only what takes judgment: the Summary, the three judgment rows, and the
22
+ // recommendation; the Verdict folds the same assembled rows worst-of with the
23
+ // step's accrued stats, so the fence shows exactly what the fold saw.
24
24
  //
25
25
  // Every tier's ending is emitted here, so no skill has to fake the furniture in
26
26
  // prose: `--plan` renders the plan-pause ending and `--driver` the driver
@@ -133,7 +133,11 @@ export function handoff(cwd, args = []) {
133
133
  const verdict = verdictLine(ladder, worst);
134
134
  const counts = countDiff(work);
135
135
  const changed = counts.added + counts.removed;
136
- const inline = changed > 0 && changed <= INLINE_DIFF_MAX;
136
+ // The patch comes first so `· inline below` is a promise the fence keeps: a
137
+ // small change git can still print nothing for (a vanished path) falls back
138
+ // to the bare counts rather than pointing at a fence that never arrives.
139
+ const patch = changed > 0 && changed <= INLINE_DIFF_MAX ? diffPatch(root, work) : '';
140
+ const inline = patch.length > 0;
137
141
  const readout = recapLines(rows, {
138
142
  diff: diffRowValue(counts, inline),
139
143
  spent: spentRowValue(spentInputs(root, slug, current, summary)),
@@ -144,10 +148,7 @@ export function handoff(cwd, args = []) {
144
148
  parts.push([readoutLabel(current, steps), '', ...fence('text', readout)].join('\n'));
145
149
  }
146
150
  if (inline) {
147
- const patch = diffPatch(root, work.map((e) => e.path));
148
- if (patch.length > 0) {
149
- parts.push(fence('diff', patch.split('\n')).join('\n'));
150
- }
151
+ parts.push(fence('diff', patch.split('\n')).join('\n'));
151
152
  }
152
153
  parts.push(verdict, nextUpLine(nextUp, steps.length, where), yourCallBlock(current, checkGreen));
153
154
  return emit(withRecommendation(parts, parseRecommendation(detail)));
@@ -179,10 +180,10 @@ export function boundaryEnding(root, slug, step) {
179
180
  * nests it under the Log's dated line.
180
181
  *
181
182
  * 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.
183
+ * the pause read them. Both measures run against HEAD, so staging no longer
184
+ * moves the counts and the caller's build-then-stage order is a nicety rather
185
+ * than a requirement. Null when the detail file carries nothing to record, so a
186
+ * hand-built step with no detail gets the bare dated line it always did.
186
187
  */
187
188
  export function stepRecord(root, slug, step, detail) {
188
189
  const summary = parseSummary(detail);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plumbbob",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
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",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: build
3
- description: "The default engine: read the next planned step from intent, implement it (its done-when, seam, Decisions, Constraints), then verify it through to the approval pause. Swappable: build by hand/vibed/another harness and run /plumbbob:verify instead. `--auto` self-approves and chains to done; a step range like `1-3` self-approves through step 3, then pauses."
3
+ description: "The default engine: read the next planned step from intent, implement it (its done-when, seam, Decisions, Constraints), then verify it through to the approval pause. Swappable: build by hand/vibed/another harness and run /plumbbob:verify instead. `--auto` self-approves and chains to done; a step range like `1-3` self-approves through step 3, then stops."
4
4
  argument-hint: "[step-number | step-range] [--auto]"
5
5
  disable-model-invocation: true
6
6
  allowed-tools: Read, Edit, Write, Bash(plumbbob status:*), Bash(plumbbob build:*), Bash(plumbbob handoff:*), Bash(plumbbob check:*), Bash(plumbbob checkpoint:*), Bash(plumbbob park:*), Bash(plumbbob agent:*), Bash(plumbbob spike:*), Bash(git diff:*)
@@ -36,8 +36,8 @@ switch with `/model` and rerun to honor it, or wave you on. Advisory, never a ga
36
36
 
37
37
  1. **Pick the step.** Use the number you were invoked with (for example `/plumbbob:build 4`), or,
38
38
  if you were given a range like `/plumbbob:build 1-3`, start at the first number and treat
39
- the second as the auto-approve ceiling (see the range note under `--auto`). With no
40
- argument, don't resolve the next step yourself; the CLI does: bare `plumbbob build`
39
+ the second as the last step you approve and land yourself (see the range note under
40
+ `--auto`). With no argument, don't resolve the next step yourself; the CLI does: bare `plumbbob build`
41
41
  (step 2) enters the next undone step and refuses with a `/plumbbob:step` nudge when every
42
42
  step is checkpointed.
43
43
  2. **Enter the step.** Run `plumbbob build <n>`, or bare `plumbbob build` to enter the
@@ -87,7 +87,9 @@ switch with `/model` and rerun to honor it, or wave you on. Advisory, never a ga
87
87
  <<'BODY'`). Do **not** pass it as an argument value (`--body "$(cat <<'BODY'…)"`):
88
88
  `--body` ignores an argument and always reads stdin. Under an agent harness that
89
89
  stdin is a socket, and `--body` now refuses rather than blocking on one; the
90
- refusal names this exact heredoc form.
90
+ refusal names this exact heredoc form. End that line at `<<'BODY'`, with no `2>&1`
91
+ after it: Claude Code's permission checker cannot analyze the extra redirect, so it
92
+ blocks the command or stops to ask instead of letting this skill's allowance cover it.
91
93
  - **A drifted subject is reconciled at the pause, never silently.** The planned
92
94
  title *is* the checkpoint subject ([D68 (conventional-subjects)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d68)). If the diff drifted
93
95
  from it, **present** a corrected subject at the pause (`planned title → proposed
@@ -256,30 +258,42 @@ and approves in the human's place**, and it **chains**:
256
258
  self-review finds no done-when / Decision / Constraint mismatch, checkpoint** and move
257
259
  straight on to the next planned step. Repeat. `--auto` adds no new machinery; the
258
260
  `after`-agent output simply feeds the *existing* self-review halt condition.
259
- - **Stop and hand back to the human** the moment any of these is true: the check is red,
260
- the self-review finds a mismatch (surface exactly what, and do not checkpoint it), a
261
- bound agent returns `blocked` or `drift` (unblock-and-re-run, or `/plumbbob:refine`; an
262
- agent cannot advance the loop), a new decision is needed, no planned steps
263
- remain, or the top of a requested range is reached. **When `--auto` halts back to the
264
- human, end on the same turn the default pause does**: write the detail file, run
265
- `plumbbob handoff`, and paste its block: the step just completed, the next undone step,
266
- and that next step's `- model:` recommendation if it has one, so a fresh context window
267
- knows which `/model` to select.
261
+ - **Stop and hand back to the human** the moment a halt arrives, and end the turn by the
262
+ kind of halt it is
263
+ ([D85 (range-top-lands)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d85)):
264
+ - **A halt on trouble leaves a step unlanded.** The check is red, the self-review finds
265
+ a mismatch (surface exactly what, and do not checkpoint it), a bound agent returns
266
+ `blocked` or `drift` (unblock-and-re-run, or `/plumbbob:refine`; an agent cannot
267
+ advance the loop), or a new decision is needed. The step is still in flight and the
268
+ human has a call to make about it, so end on the same turn the default pause does:
269
+ write the detail file, run `plumbbob handoff`, and paste its block.
270
+ - **A clean halt lands its step first.** The top of a requested range is reached, or no
271
+ planned steps remain. That step checkpoints like every step before it, and its
272
+ checkpoint's block is the whole ending: relay it with nothing written around it, then
273
+ end the turn. Write no detail file and run no `handoff`, because nothing is pending.
274
+ The block already carries the Verdict the step earned and a Next Up pointing at what
275
+ comes next (the next undone step and its `- model:` recommendation, or the finish),
276
+ which is what a fresh context window needs to pick the right `/model`.
268
277
 
269
278
  `--auto` and a step range are the only paths that checkpoint without a human pause, and
270
- only because the human asked for it by name; a range re-imposes the pause at its top.
271
- The default (no flag, no range) always ends at the pause.
279
+ only because the human asked for it by name. A range hands the clock back once its top
280
+ step has landed: the step after it waits for the human's next run. The default (no flag,
281
+ no range) always ends at the pause.
272
282
 
273
283
  ### A step range (`N-M`) is a bounded `--auto`
274
284
 
275
- `/plumbbob:build 1-3` self-approves steps 1 through 3 exactly as `--auto` does, then **pauses
276
- after step 3** instead of chaining to done. The range *is* the opt-in; you do not also
277
- pass `--auto`. Run it as the `--auto` loop with one extra halt: **stop before building
278
- any next step whose plan number is past the top of the range.** It adds no machinery:
279
- just the one more entry already in the halt list above.
280
-
281
- - **`N-M` with N ≤ M**: build N…M, self-approving and checkpointing each, then pause at
282
- M. `N-N` is just the single step `N` (which already ends at the pause).
285
+ `/plumbbob:build 1-3` self-approves steps 1 through 3 exactly as `--auto` does, then **stops
286
+ once step 3 has landed** instead of chaining to done. The range *is* the opt-in; you do not
287
+ also pass `--auto`. Run it as the `--auto` loop with one extra halt: **stop before building
288
+ any next step whose plan number is past the top of the range.** It adds no machinery: just
289
+ the one more entry already in the halt list above, and it is a clean halt, so the top step
290
+ is never held back for approval. The human approved it by typing the range, and
291
+ `plumbbob build` says so as it enters that step:
292
+ `step 3 is the top of the range you granted`.
293
+
294
+ - **`N-M` with N ≤ M**: build N…M, self-approving and checkpointing each, M included, then
295
+ relay M's checkpoint block and end the turn. `N-N` is a range like any other: it builds
296
+ and lands the single step `N`. Only a bare `/plumbbob:build N` ends at the pause.
283
297
  - **N > M** (for example `3-1`): that is not a range you can walk; report it and stop rather
284
298
  than guess the intent.
285
299
  - **M past the last planned step** (for example `1-9` with three steps): build through the
@@ -302,7 +316,7 @@ just the one more entry already in the halt list above.
302
316
  - **Default ends at the pause.** Implement → verify → wait for approval; never
303
317
  checkpoint without it. Only an explicit `--auto` or a step range lets the agent approve
304
318
  in your place, and it still halts on a red check or any mismatch; a range also stops
305
- at its top.
319
+ once its top step lands.
306
320
  - **Agents feed the beat; they never advance it**. `before` loads context,
307
321
  `build` writes the diff, `after` is advisory; none can checkpoint, flip a step, or
308
322
  chain. `blocked` → unblock and re-run; `drift` → `/plumbbob:refine`. You are still the one
@@ -53,8 +53,11 @@ record.
53
53
  Redirect the heredoc *into* the command (`--body <<'BODY'`). Do **not** pass it as
54
54
  an argument value (`--body "$(cat <<'BODY'…)"`): `--body` ignores an argument and
55
55
  always reads stdin. Under an agent harness that stdin is a socket, and `--body` now
56
- refuses rather than blocking on one; the refusal names this exact heredoc form. Omit
57
- `--body` entirely and the commit carries the `plumbbob finish` marker subject only.
56
+ refuses rather than blocking on one; the refusal names this exact heredoc form. End
57
+ that line at `<<'BODY'`, with no `2>&1` after it: Claude Code's permission checker
58
+ cannot analyze the extra redirect, so it blocks the command or stops to ask instead of
59
+ letting this skill's allowance cover it. Omit `--body` entirely and the commit carries
60
+ the `plumbbob finish` marker subject only.
58
61
  3. **Point at the next goal.** Relay `plumbbob finish`'s output verbatim, blank lines
59
62
  included: its lead line, which names the branch the tracked folder now rides into the
60
63
  PR, any advisory beneath it, and `**Next Up**: Nothing planned - /plumbbob:plan`. That
@@ -142,7 +142,8 @@ an agent can follow with `/plumbbob:build`. The argument only seeds how you get
142
142
  a `plan <sha>` line in
143
143
  `checkpoints`. This keeps the first step's diff clean, so history reads
144
144
  baseline → plan → steps. Pass a proportional `--body` (the single-quoted stdin
145
- heredoc) when the rationale is worth carrying; skip it for a small plan. Do this
145
+ heredoc, with nothing after `<<'BODY'` on its line, not even `2>&1`) when the
146
+ rationale is worth carrying; skip it for a small plan. Do this
146
147
  only on the human's approval; the plan is their convergence.
147
148
  - **The plan pause is a decision turn.** Present the framed plan for the human's call,
148
149
  then give it the cold read (**§ The cold read**), write the result into
@@ -163,7 +163,10 @@ this skill verifies it the same way: **it reads the diff, not the author**
163
163
  Redirect the heredoc *into* the command (`--body <<'BODY'`). Do **not** pass it as
164
164
  an argument value (`--body "$(cat <<'BODY'…)"`): `--body` ignores an argument and
165
165
  always reads stdin. Under an agent harness that stdin is a socket, and `--body` now
166
- refuses rather than blocking on one; the refusal names this exact heredoc form.
166
+ refuses rather than blocking on one; the refusal names this exact heredoc form. End
167
+ that line at `<<'BODY'`, with no `2>&1` after it: Claude Code's permission checker
168
+ cannot analyze the extra redirect, so it blocks the command or stops to ask instead of
169
+ letting this skill's allowance cover it.
167
170
 
168
171
  Do **not** run a TIL scan or reach for a separate commit skill; the body is yours to
169
172
  write in one breath. Omit `--body` entirely and the CLI writes a deterministic body