plumbbob 0.11.2 → 0.11.3

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.11.3",
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)
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
@@ -81,9 +81,9 @@ 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.
@@ -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.11.3",
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",