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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -3
- package/dist/lib/git.js +109 -12
- package/dist/verbs/checkpoint.js +3 -3
- package/dist/verbs/handoff.js +16 -15
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plumbbob",
|
|
3
3
|
"displayName": "PlumbBob",
|
|
4
|
-
"version": "0.11.
|
|
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 -
|
|
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 -
|
|
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 -
|
|
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
|

|
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
|
|
99
|
-
*
|
|
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
|
|
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,
|
|
137
|
-
|
|
138
|
-
|
|
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', '--',
|
|
237
|
+
return runGit(root, ['diff', '--no-index', '--', '/dev/null', path]);
|
|
142
238
|
}
|
|
143
|
-
catch {
|
|
144
|
-
|
|
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/verbs/checkpoint.js
CHANGED
|
@@ -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:
|
|
85
|
-
//
|
|
86
|
-
//
|
|
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.
|
package/dist/verbs/handoff.js
CHANGED
|
@@ -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
|
|
19
|
-
//
|
|
20
|
-
// (`.plumbbob/detail.md`) supplies
|
|
21
|
-
//
|
|
22
|
-
// assembled rows worst-of with the
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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