plumbbob 0.12.1 → 0.13.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +12 -7
- package/dist/cli-core.js +14 -3
- package/dist/lib/git.js +20 -8
- package/dist/lib/notice.js +15 -1
- package/dist/lib/orient.js +43 -1
- package/dist/lib/sidecar.js +149 -3
- package/dist/verbs/abandon.js +19 -14
- package/dist/verbs/checkpoint.js +30 -21
- package/dist/verbs/doctor.js +23 -4
- package/dist/verbs/finish.js +21 -17
- package/dist/verbs/handoff.js +177 -58
- package/dist/verbs/order.js +17 -9
- package/dist/verbs/park.js +6 -2
- package/dist/verbs/relay.js +306 -0
- package/dist/verbs/revert.js +17 -11
- package/dist/verbs/spike.js +50 -38
- package/dist/verbs/start.js +13 -9
- package/dist/verbs/status.js +2 -2
- package/dist/verbs/turn.js +9 -3
- package/dist/verbs/use.js +21 -17
- package/hooks/hooks.json +7 -0
- package/package.json +1 -1
- package/skills/build/SKILL.md +35 -16
- package/skills/park/SKILL.md +7 -0
- package/skills/plan/SKILL.md +1 -0
- package/skills/verify/SKILL.md +34 -17
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plumbbob",
|
|
3
3
|
"displayName": "PlumbBob",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.13.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
|
@@ -140,12 +140,14 @@ In Claude Code, inside any git repo with a clean tree:
|
|
|
140
140
|
nothing is written around it.
|
|
141
141
|
|
|
142
142
|
````markdown
|
|
143
|
+
**Now**: Step 1 of 3 - feat(limiter): add a token-bucket limiter
|
|
144
|
+
|
|
143
145
|
**Summary**: A per-IP token bucket now refuses the 6th attempt inside a minute. (details: `.plumbbob/detail.md`)
|
|
144
146
|
|
|
145
147
|
1. The bucket lives in memory, keyed by IP, refilling five tokens a minute.
|
|
146
148
|
2. The clock is injected, so the test advances time instead of sleeping on it.
|
|
147
149
|
|
|
148
|
-
**Readout**:
|
|
150
|
+
**Readout**:
|
|
149
151
|
|
|
150
152
|
```text
|
|
151
153
|
check green: 7 of 7 checks
|
|
@@ -164,7 +166,7 @@ In Claude Code, inside any git repo with a clean tree:
|
|
|
164
166
|
**Your Call**:
|
|
165
167
|
|
|
166
168
|
- `looks good` → I checkpoint Step 1; back to the boundary
|
|
167
|
-
- `expand`, or any question → I show more of what is there; nothing changes
|
|
169
|
+
- `expand`, or any question → I show more of what is there; nothing changes, and `show the pause` brings all of this back
|
|
168
170
|
- anything that reads as direction → I take it as what to change; nothing lands until you approve
|
|
169
171
|
- `revert` → I wind the work back to the last checkpoint
|
|
170
172
|
|
|
@@ -192,8 +194,10 @@ In Claude Code, inside any git repo with a clean tree:
|
|
|
192
194
|

|
|
193
195
|
|
|
194
196
|
Ask instead (`expand 2`, or any question) and it shows more of what is there and
|
|
195
|
-
changes nothing
|
|
196
|
-
|
|
197
|
+
changes nothing. The answer closes on one line pointing back at the step, not on the
|
|
198
|
+
whole block again, since you have already read it; say `show the pause` to see the
|
|
199
|
+
block again. Direct it and it takes that as the fix, and nothing lands until you say
|
|
200
|
+
`looks good`. Then fire `/plumbbob:build` again for the next step: re-firing it
|
|
197
201
|
*is* the clock tick. Whenever you lose the thread, `/plumbbob:status` shows where you
|
|
198
202
|
are and names the next move.
|
|
199
203
|
4. **Finish.** When the last step is done, `/plumbbob:finish` writes a report of what shipped
|
|
@@ -254,9 +258,10 @@ All fourteen, with inputs and effects, are in
|
|
|
254
258
|
[`docs/skills-reference.md`](docs/skills-reference.md).
|
|
255
259
|
|
|
256
260
|
Under the skills ships a lean `plumbbob` CLI (the mechanical verbs the
|
|
257
|
-
skills shell out to),
|
|
258
|
-
ask-hook while a step is in flight,
|
|
259
|
-
reads
|
|
261
|
+
skills shell out to), four hooks (a non-blocking post-edit lint, a `git commit`
|
|
262
|
+
ask-hook while a step is in flight, the per-prompt turn ledger the approval latch
|
|
263
|
+
reads, and a check at each turn's end that the turn relayed the ending plumbbob
|
|
264
|
+
printed), and a `.plumbbob/` sidecar you can open and edit by hand at any time: a tracked
|
|
260
265
|
`builds/<slug>/` folder per build (`intent.md`, `build-log.md`, `checkpoints`, `report.md`)
|
|
261
266
|
that rides the branch into the PR, plus an untracked control plane (`settings.local.json`,
|
|
262
267
|
the session sentinel, the in-flight markers).
|
package/dist/cli-core.js
CHANGED
|
@@ -28,6 +28,7 @@ import { init } from "./verbs/init.js";
|
|
|
28
28
|
import { doctor } from "./verbs/doctor.js";
|
|
29
29
|
import { recover } from "./verbs/recover.js";
|
|
30
30
|
import { agent } from "./verbs/agent.js";
|
|
31
|
+
import { relay } from "./verbs/relay.js";
|
|
31
32
|
import { turn } from "./verbs/turn.js";
|
|
32
33
|
/** Shared across every verb that resolves through the active-build cursor. */
|
|
33
34
|
const BUILD_FLAG = {
|
|
@@ -80,15 +81,16 @@ const VERBS = [
|
|
|
80
81
|
},
|
|
81
82
|
{
|
|
82
83
|
name: 'handoff',
|
|
83
|
-
description: 'print the footer card that ends a turn (pause, boundary, plan, or driver)
|
|
84
|
-
synopsis: ['handoff [<n>] [--plan] [--driver] [--build <slug>]'],
|
|
84
|
+
description: 'print the footer card that ends a turn (pause, boundary, plan, or driver), or a reminder for a pause already shown',
|
|
85
|
+
synopsis: ['handoff [<n>] [--full] [--plan] [--driver] [--build <slug>]'],
|
|
85
86
|
args: [{ name: '<n>', gloss: 'step to report on (default: the in-flight step, else the last checkpointed one)' }],
|
|
86
87
|
flags: [
|
|
88
|
+
{ name: '--full', gloss: 'print the whole pause card even when that pause was already shown' },
|
|
87
89
|
{ name: '--plan', gloss: 'render the plan-pause card instead of a step tier' },
|
|
88
90
|
{ name: '--driver', gloss: "render a driver turn's pointer back at the open step" },
|
|
89
91
|
BUILD_FLAG,
|
|
90
92
|
],
|
|
91
|
-
notes: '
|
|
93
|
+
notes: 'Writes only the detail file (a blank line around each heading) and the PAUSE stamp; refuses (exit 1) with no session, or with --plan and --driver together.',
|
|
92
94
|
},
|
|
93
95
|
{
|
|
94
96
|
name: 'check',
|
|
@@ -225,6 +227,13 @@ const VERBS = [
|
|
|
225
227
|
tolerateUnknownFlags: true,
|
|
226
228
|
notes: 'Reads the hook payload from stdin and always exits 0 — a broken hook must never wedge a prompt.',
|
|
227
229
|
},
|
|
230
|
+
{
|
|
231
|
+
name: 'relay',
|
|
232
|
+
description: 'Stop hook machinery: check that the turn carried the ending it printed (not a user verb)',
|
|
233
|
+
synopsis: ['relay'],
|
|
234
|
+
tolerateUnknownFlags: true,
|
|
235
|
+
notes: "Reads the hook payload from stdin and always exits 0: a broken hook must never wedge a turn's end.",
|
|
236
|
+
},
|
|
228
237
|
];
|
|
229
238
|
/**
|
|
230
239
|
* Render the verb table for `plumbbob help`.
|
|
@@ -379,6 +388,8 @@ async function dispatch(verb, cwd, rest) {
|
|
|
379
388
|
return agent(cwd, rest);
|
|
380
389
|
case 'turn':
|
|
381
390
|
return turn(cwd, rest);
|
|
391
|
+
case 'relay':
|
|
392
|
+
return relay(cwd, rest);
|
|
382
393
|
default:
|
|
383
394
|
process.stderr.write(`plumbbob: unknown verb '${verb}'. Run 'plumbbob help' for the verb table.\n`);
|
|
384
395
|
return 1;
|
package/dist/lib/git.js
CHANGED
|
@@ -68,18 +68,30 @@ export function hasCommit(root) {
|
|
|
68
68
|
export function isDirty(root) {
|
|
69
69
|
return runGit(root, ['status', '--porcelain']).length > 0;
|
|
70
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* The body line that marks a plan commit made by hand (a `chore(plan)`
|
|
73
|
+
* harvest). A harvest lands between nearly every step, so counting it as
|
|
74
|
+
* out-of-band would trip the advisory on routine housekeeping and stop meaning
|
|
75
|
+
* anything within three steps. `status` and `handoff` both pass it to
|
|
76
|
+
* `commitsSince`, which is what keeps the two counts one quantity.
|
|
77
|
+
*/
|
|
78
|
+
export const PLAN_COMMIT_MARKER = '^plumbbob plan$';
|
|
71
79
|
/**
|
|
72
80
|
* The number of commits on HEAD not reachable from `sha`: `git rev-list
|
|
73
81
|
* --count --first-parent <sha>..HEAD`.
|
|
74
82
|
*
|
|
75
|
-
* `status`
|
|
76
|
-
* landed since the last checkpoint outside
|
|
77
|
-
* commits are surfaced, never blocked).
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
+
* `status` and `handoff` both read this for their out-of-band count: one
|
|
84
|
+
* neutral note when commits landed since the last checkpoint outside
|
|
85
|
+
* plumbbob's ledger (out-of-band commits are surfaced, never blocked). Two
|
|
86
|
+
* mechanisms keep plumbbob's own commits out of that count. A commit any
|
|
87
|
+
* plumbbob verb makes is recorded in the ledger and becomes the anchor, so the
|
|
88
|
+
* range starts after it. A plan commit made by hand carries
|
|
89
|
+
* `PLAN_COMMIT_MARKER` on a line of its body, and both callers pass it as
|
|
90
|
+
* `excluding` so the range drops it. `--first-parent` keeps the count on the
|
|
91
|
+
* branch's own line: merging upstream reads as the one merge commit, not the
|
|
92
|
+
* dozens it carried; those didn't land "outside the ledger" in any sense the
|
|
93
|
+
* receipt should nag about. `excluding` is a commit-message pattern whose
|
|
94
|
+
* matches are left out of the count.
|
|
83
95
|
*
|
|
84
96
|
* Best-effort and never throws: 0 when the range is empty, `sha` is unknown to
|
|
85
97
|
* the repo, or HEAD is unborn: the count is informational (the human commits
|
package/dist/lib/notice.js
CHANGED
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
// the edge, and an optional indented arrow line carrying what to do next. One
|
|
12
12
|
// shape, so moving it is one edit rather than a sweep. The pointer at the next
|
|
13
13
|
// step is not a line's job: `handoff` renders one, and the verb prints it as
|
|
14
|
-
// the ending's last part.
|
|
14
|
+
// the ending's last part. The formatters are pure string assembly, no fs, no
|
|
15
|
+
// gate; `printEnding` is the one function here that writes, and every ending
|
|
16
|
+
// a verb prints goes through it so the relay check knows what was said.
|
|
17
|
+
import { writeRelay } from "./sidecar.js";
|
|
15
18
|
/** The column budget a notice line aims to fit, matching the readout fence. */
|
|
16
19
|
const WIDTH = 80;
|
|
17
20
|
/** The fewest detail items a degraded parenthetical still names, so the count has a universe to size. */
|
|
@@ -83,6 +86,17 @@ export function blocks(parts) {
|
|
|
83
86
|
const kept = parts.filter((p) => p !== null && p.trim().length > 0);
|
|
84
87
|
return `${kept.map((p) => p.replace(/\n+$/, '')).join('\n\n')}\n\n`;
|
|
85
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Print an ending to stdout and record it in RELAY, for the Stop check that
|
|
91
|
+
* the turn closed on it.
|
|
92
|
+
*
|
|
93
|
+
* Stdout first, and the record never throws: a failed write leaves the
|
|
94
|
+
* printed ending and the verb's exit code exactly as they would have been.
|
|
95
|
+
*/
|
|
96
|
+
export function printEnding(root, relay) {
|
|
97
|
+
process.stdout.write(relay.text);
|
|
98
|
+
writeRelay(root, relay);
|
|
99
|
+
}
|
|
86
100
|
/**
|
|
87
101
|
* The head, its trailing parenthetical, and the remedy line beneath, assembled.
|
|
88
102
|
*
|
package/dist/lib/orient.js
CHANGED
|
@@ -903,6 +903,45 @@ export function parseSummary(detail) {
|
|
|
903
903
|
}
|
|
904
904
|
return { lead, highlights };
|
|
905
905
|
}
|
|
906
|
+
/**
|
|
907
|
+
* `.plumbbob/detail.md` with a blank line around every heading, and everything
|
|
908
|
+
* else as written.
|
|
909
|
+
*
|
|
910
|
+
* A model copying the detail template can run a heading straight into its body
|
|
911
|
+
* or its predecessor, and markdownlint's MD022 fails that file wherever
|
|
912
|
+
* `.plumbbob/` is linted, which turns a downstream checkpoint red. Heading lines
|
|
913
|
+
* inside a fenced block are content, not headings, so a fence is left whole.
|
|
914
|
+
* Pure and idempotent: text that already has its blank lines comes back byte for
|
|
915
|
+
* byte, so a caller can compare the result to the input to learn whether
|
|
916
|
+
* anything needed fixing.
|
|
917
|
+
*/
|
|
918
|
+
export function normalizeDetailHeadings(detail) {
|
|
919
|
+
const out = [];
|
|
920
|
+
let fence = null;
|
|
921
|
+
let afterHeading = false;
|
|
922
|
+
for (const line of detail.split('\n')) {
|
|
923
|
+
if (fence !== null) {
|
|
924
|
+
const close = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line)?.[1];
|
|
925
|
+
if (close !== undefined && close.startsWith(fence.char) && close.length >= fence.length) {
|
|
926
|
+
fence = null;
|
|
927
|
+
}
|
|
928
|
+
out.push(line);
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
const heading = /^ {0,3}#{1,6}(\s|$)/.test(line);
|
|
932
|
+
const prior = out[out.length - 1];
|
|
933
|
+
if ((heading || afterHeading) && line.trim() !== '' && prior !== undefined && prior.trim() !== '') {
|
|
934
|
+
out.push('');
|
|
935
|
+
}
|
|
936
|
+
const open = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
|
|
937
|
+
if (open !== undefined) {
|
|
938
|
+
fence = { char: open.charAt(0), length: open.length };
|
|
939
|
+
}
|
|
940
|
+
out.push(line);
|
|
941
|
+
afterHeading = heading;
|
|
942
|
+
}
|
|
943
|
+
return out.join('\n');
|
|
944
|
+
}
|
|
906
945
|
/**
|
|
907
946
|
* The `## <n>` sections of `.plumbbob/detail.md` with their bodies: the full
|
|
908
947
|
* story behind each highlight, in the order the model wrote them. The body is
|
|
@@ -989,7 +1028,10 @@ export function formatOrientation(o) {
|
|
|
989
1028
|
const cpLine = cp === null ? 'last checkpoint none yet' : `last checkpoint step ${cp.n} · ${cp.sha.slice(0, 7)}`;
|
|
990
1029
|
// A neutral reconciliation note, only when there is something to reconcile:
|
|
991
1030
|
// commits landed since the last checkpoint that plumbbob's ledger didn't
|
|
992
|
-
// record.
|
|
1031
|
+
// record. A commit a plumbbob verb makes starts the range, and a hand-made
|
|
1032
|
+
// plan commit drops out on its `plumbbob plan` body marker, so the count is
|
|
1033
|
+
// the same one `handoff` folds into its Verdict. Informational: the human
|
|
1034
|
+
// commits freely, so this never gates.
|
|
993
1035
|
const receipts = o.outOfBand > 0
|
|
994
1036
|
? [`${o.outOfBand} commit${o.outOfBand === 1 ? '' : 's'} since the last checkpoint landed outside plumbbob's ledger.`]
|
|
995
1037
|
: [];
|
package/dist/lib/sidecar.js
CHANGED
|
@@ -213,6 +213,21 @@ export function stepPath(root, slug) {
|
|
|
213
213
|
export function clearStep(root, slug) {
|
|
214
214
|
rmSync(stepPath(root, slug), { force: true });
|
|
215
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* PAUSE: the stamp of the step pause `handoff` last rendered whole, the step
|
|
218
|
+
* number and a hash of what the card showed. While a later render matches it,
|
|
219
|
+
* handoff prints the one-line reminder instead of the card.
|
|
220
|
+
*/
|
|
221
|
+
export function pausePath(root, slug) {
|
|
222
|
+
return join(artifactDir(root, slug), 'PAUSE');
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Remove the PAUSE stamp: a checkpoint, a revert, or an abandon ends the pause
|
|
226
|
+
* it recorded, so the next pause renders whole. Absent is a no-op.
|
|
227
|
+
*/
|
|
228
|
+
export function clearPause(root, slug) {
|
|
229
|
+
rmSync(pausePath(root, slug), { force: true });
|
|
230
|
+
}
|
|
216
231
|
/**
|
|
217
232
|
* The build's `checkpoints` ledger: the recorded SHAs of landed plan and step
|
|
218
233
|
* commits, one line each.
|
|
@@ -536,6 +551,131 @@ export function readTurn(root) {
|
|
|
536
551
|
return null;
|
|
537
552
|
}
|
|
538
553
|
}
|
|
554
|
+
// --- The relay record: `.plumbbob/RELAY`, the ending the CLI printed last, so a
|
|
555
|
+
// Stop hook can check that the turn closed on it. The verb that prints an
|
|
556
|
+
// ending records it here: it knows what it printed, where a hook scraping
|
|
557
|
+
// stdout would have to guess which command ran. Flat like TURN, because the
|
|
558
|
+
// turn it is checked against is the session's, not a build's. A later ending
|
|
559
|
+
// overwrites an earlier one, so a turn is judged by the last thing it printed. ---
|
|
560
|
+
// The tiers of docs/presentation.md, which decide how a turn has to carry an
|
|
561
|
+
// ending: a decision ending closes the turn, an orientation ending appears in
|
|
562
|
+
// it, and a driver ending is recorded only so that the last ending wins.
|
|
563
|
+
const RELAY_TIERS = ['decision', 'orientation', 'driver'];
|
|
564
|
+
// Which ending printed: handoff's six forms first, then one per transition. It
|
|
565
|
+
// is what the check's reason names when the ending went missing.
|
|
566
|
+
const RELAY_RENDERS = [
|
|
567
|
+
'card',
|
|
568
|
+
'reminder',
|
|
569
|
+
'plan',
|
|
570
|
+
'boundary',
|
|
571
|
+
'pointer',
|
|
572
|
+
'driver',
|
|
573
|
+
'checkpoint',
|
|
574
|
+
'plan-commit',
|
|
575
|
+
'park',
|
|
576
|
+
'revert',
|
|
577
|
+
'abandon',
|
|
578
|
+
'spike-open',
|
|
579
|
+
'spike-report',
|
|
580
|
+
'spike-done',
|
|
581
|
+
'use',
|
|
582
|
+
'order',
|
|
583
|
+
'finish',
|
|
584
|
+
'start',
|
|
585
|
+
];
|
|
586
|
+
/**
|
|
587
|
+
* RELAY: the ending the CLI printed last, at the flat sidecar root.
|
|
588
|
+
*/
|
|
589
|
+
export function relayPath(root) {
|
|
590
|
+
return join(root, DIRNAME, 'RELAY');
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Record an ending in RELAY, stamped with the current TURN: a header of
|
|
594
|
+
* `key value` lines, a blank line, then the text exactly as it printed.
|
|
595
|
+
*
|
|
596
|
+
* Written only while TURN holds a count, the same dormancy `stampTick` keeps: a
|
|
597
|
+
* host with no turn hook has no turn to check the ending against. Best-effort
|
|
598
|
+
* by contract, since the ending has already reached stdout and a failed record
|
|
599
|
+
* must not change it or the exit code.
|
|
600
|
+
*/
|
|
601
|
+
export function writeRelay(root, relay) {
|
|
602
|
+
const turn = readTurn(root);
|
|
603
|
+
if (turn === null)
|
|
604
|
+
return;
|
|
605
|
+
// A hold the check already spent on this turn survives the ending the retry
|
|
606
|
+
// reprints, or a turn whose retry missed again would be held a second time.
|
|
607
|
+
const held = readRelay(root)?.held === turn ? { held: turn } : {};
|
|
608
|
+
try {
|
|
609
|
+
writeFileSync(relayPath(root), formatRelay({ ...relay, turn, ...held }));
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
// A read-only tree or a directory in the way: the ending already printed.
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Stamp the record with the turn the check held open over it, so the check
|
|
617
|
+
* holds at most once per turn. False when the stamp could not be written, and
|
|
618
|
+
* the check then holds nothing, since a hold it cannot count could repeat.
|
|
619
|
+
*/
|
|
620
|
+
export function holdRelay(root, record) {
|
|
621
|
+
try {
|
|
622
|
+
writeFileSync(relayPath(root), formatRelay({ ...record, held: record.turn }));
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
/** A record as RELAY stores it: the header's `key value` lines, a blank line, then the text. */
|
|
630
|
+
function formatRelay(record) {
|
|
631
|
+
const header = [`turn ${record.turn}`, `tier ${record.tier}`, `render ${record.render}`];
|
|
632
|
+
if (record.command !== undefined)
|
|
633
|
+
header.push(`command ${record.command}`);
|
|
634
|
+
if (record.held !== undefined)
|
|
635
|
+
header.push(`held ${record.held}`);
|
|
636
|
+
return `${header.join('\n')}\n\n${record.text}`;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* The recorded ending, or null when RELAY is absent or its header does not
|
|
640
|
+
* parse: a record the check cannot read is no record.
|
|
641
|
+
*/
|
|
642
|
+
export function readRelay(root) {
|
|
643
|
+
let raw;
|
|
644
|
+
try {
|
|
645
|
+
raw = readFileSync(relayPath(root), 'utf8');
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
const split = raw.indexOf('\n\n');
|
|
651
|
+
if (split === -1)
|
|
652
|
+
return null;
|
|
653
|
+
const fields = new Map();
|
|
654
|
+
for (const line of raw.slice(0, split).split('\n')) {
|
|
655
|
+
const space = line.indexOf(' ');
|
|
656
|
+
if (space > 0)
|
|
657
|
+
fields.set(line.slice(0, space), line.slice(space + 1));
|
|
658
|
+
}
|
|
659
|
+
const turn = count(fields.get('turn'));
|
|
660
|
+
const tier = RELAY_TIERS.find((t) => t === fields.get('tier'));
|
|
661
|
+
const render = RELAY_RENDERS.find((r) => r === fields.get('render'));
|
|
662
|
+
if (turn === null || tier === undefined || render === undefined)
|
|
663
|
+
return null;
|
|
664
|
+
const command = fields.get('command');
|
|
665
|
+
const held = count(fields.get('held'));
|
|
666
|
+
return {
|
|
667
|
+
turn,
|
|
668
|
+
tier,
|
|
669
|
+
render,
|
|
670
|
+
...(command === undefined ? {} : { command }),
|
|
671
|
+
...(held === null ? {} : { held }),
|
|
672
|
+
text: raw.slice(split + 2),
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
/** A header field read as a bare count, or null when it is absent or anything else. */
|
|
676
|
+
function count(field) {
|
|
677
|
+
return field !== undefined && /^\d+$/.test(field) ? Number(field) : null;
|
|
678
|
+
}
|
|
539
679
|
/**
|
|
540
680
|
* Walk up from `cwd` to the nearest ancestor with an active session (a
|
|
541
681
|
* `.plumbbob/STATE` file), or null when there is none.
|
|
@@ -600,16 +740,22 @@ export function excludeControl(root) {
|
|
|
600
740
|
`${DIRNAME}/TURN`,
|
|
601
741
|
`${DIRNAME}/GRANT`,
|
|
602
742
|
// The in-flight step's detail file: flat per-session control the model
|
|
603
|
-
// overwrites before every pause. checkpoint
|
|
604
|
-
// and then clears it, so its archive is the
|
|
605
|
-
// excluding it keeps stageAll from ever sweeping it into a step commit.
|
|
743
|
+
// overwrites before every pause. checkpoint records it in the build-log's
|
|
744
|
+
// Log and then clears it, so its archive is the tracked ledger, never this
|
|
745
|
+
// path; excluding it keeps stageAll from ever sweeping it into a step commit.
|
|
606
746
|
`${DIRNAME}/detail.md`,
|
|
747
|
+
// The ending the CLI printed last, for the Stop check: per-worktree
|
|
748
|
+
// control, rewritten by every ending, and never a step commit's to carry.
|
|
749
|
+
`${DIRNAME}/RELAY`,
|
|
607
750
|
`${DIRNAME}/builds/*/STEP`,
|
|
608
751
|
`${DIRNAME}/builds/*/SEAM`,
|
|
609
752
|
`${DIRNAME}/builds/*/SPIKE`,
|
|
610
753
|
// The entry stamp is in-flight control like STEP/SEAM: checkpoint's
|
|
611
754
|
// stageAll must never sweep it into a step commit.
|
|
612
755
|
`${DIRNAME}/builds/*/TICK`,
|
|
756
|
+
// The pause stamp is in-flight control too: what handoff last showed, so
|
|
757
|
+
// a repeat prints the reminder. It must never ride a step commit.
|
|
758
|
+
`${DIRNAME}/builds/*/PAUSE`,
|
|
613
759
|
// The agent-run handoff ledger is step-scoped in-flight state, not a
|
|
614
760
|
// tracked artifact: it must never ride a step commit into the PR.
|
|
615
761
|
`${DIRNAME}/builds/*/handoff.json`,
|
package/dist/verbs/abandon.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// `plumbbob abandon`: the third exit from an in-flight step. Checkpoint lands
|
|
2
2
|
// the step and revert destroys the work; abandon drops the attempt but keeps the
|
|
3
3
|
// working-tree diff exactly where it is. Its only writes are the in-flight
|
|
4
|
-
// control markers it clears (STEP, SEAM, TICK, and the
|
|
5
|
-
// ledger), a build-log line, and the stats receipt: it
|
|
6
|
-
// working tree nor git nor the intent checkbox, so the step
|
|
7
|
-
// re-buildable with its diff still in the tree for the human
|
|
8
|
-
// commit by hand.
|
|
4
|
+
// control markers it clears (STEP, SEAM, TICK, the PAUSE stamp, and the
|
|
5
|
+
// step-scoped handoff ledger), a build-log line, and the stats receipt: it
|
|
6
|
+
// touches neither the working tree nor git nor the intent checkbox, so the step
|
|
7
|
+
// stays planned and re-buildable with its diff still in the tree for the human
|
|
8
|
+
// to keep, rework, or commit by hand.
|
|
9
9
|
//
|
|
10
10
|
// A step exit is a boundary crossing, so abandon honors the same approval latch
|
|
11
11
|
// as checkpoint. That is not decoration: abandon clears the entry stamp the
|
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
// control-state clears; abandon itself never calls the raw fs deleters.
|
|
16
16
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
17
17
|
import { findRepoRoot } from "../lib/git.js";
|
|
18
|
-
import { bumpStepStat, buildLogPath, clearHandoff, clearSeam, clearStep, clearTick, hasSession, intentPath, resolveBuild, stepPath, } from "../lib/sidecar.js";
|
|
18
|
+
import { bumpStepStat, buildLogPath, clearHandoff, clearPause, clearSeam, clearStep, clearTick, hasSession, intentPath, resolveBuild, stepPath, } from "../lib/sidecar.js";
|
|
19
19
|
import { checkLatch } from "../lib/latch.js";
|
|
20
20
|
import { abandonLogLine, appendToSection } from "../lib/buildlog.js";
|
|
21
21
|
import { parseSteps } from "../lib/orient.js";
|
|
22
|
-
import { ending, notice, transition } from "../lib/notice.js";
|
|
22
|
+
import { ending, notice, printEnding, transition } from "../lib/notice.js";
|
|
23
23
|
import { driverPointer } from "./handoff.js";
|
|
24
24
|
/**
|
|
25
25
|
* Drop the in-flight step while keeping its work: latch, clear the markers,
|
|
@@ -55,18 +55,23 @@ export function abandon(cwd, args) {
|
|
|
55
55
|
clearSeam(root, slug);
|
|
56
56
|
clearTick(root, slug);
|
|
57
57
|
clearHandoff(root, slug);
|
|
58
|
+
clearPause(root, slug);
|
|
58
59
|
bumpStepStat(root, slug, step, 'abandons');
|
|
59
60
|
logAbandon(root, slug, step);
|
|
60
61
|
// A step exit: with the step back at `[ ]` and its markers gone, the pointer
|
|
61
62
|
// aims forward from the boundary, at the very step just dropped.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
printEnding(root, {
|
|
64
|
+
tier: 'driver',
|
|
65
|
+
render: 'abandon',
|
|
66
|
+
text: ending({
|
|
67
|
+
lead: transition({
|
|
68
|
+
label: 'Abandoned',
|
|
69
|
+
fact: `Step ${step}`,
|
|
70
|
+
detail: ['work kept in the tree', 'the step stays planned'],
|
|
71
|
+
}),
|
|
72
|
+
pointer: driverPointer(root, slug),
|
|
67
73
|
}),
|
|
68
|
-
|
|
69
|
-
}));
|
|
74
|
+
});
|
|
70
75
|
return 0;
|
|
71
76
|
}
|
|
72
77
|
/**
|
package/dist/verbs/checkpoint.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { relative } from 'node:path';
|
|
14
14
|
import { commit, findRepoRoot, headSha, isDirty, stageAll, stagePath, stagedPaths, stagedStat } from "../lib/git.js";
|
|
15
|
-
import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearDetail, clearHandoff, clearTick, detailPath, hasSession, intentPath, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.js";
|
|
15
|
+
import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearDetail, clearHandoff, clearPause, clearTick, detailPath, hasSession, intentPath, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.js";
|
|
16
16
|
import { runCheck } from "../lib/check.js";
|
|
17
17
|
import { readCommitBody } from "../lib/commitbody.js";
|
|
18
18
|
import { checkLatch } from "../lib/latch.js";
|
|
@@ -21,7 +21,7 @@ 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";
|
|
23
23
|
import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
|
|
24
|
-
import { advisory, ending, notice, transition } from "../lib/notice.js";
|
|
24
|
+
import { advisory, ending, notice, printEnding, transition } from "../lib/notice.js";
|
|
25
25
|
import { boundaryEnding, planRecord, stepRecord } from "./handoff.js";
|
|
26
26
|
/**
|
|
27
27
|
* Land a step: latch, check gate, commit, record, return to the boundary.
|
|
@@ -116,17 +116,22 @@ export async function checkpoint(cwd, args) {
|
|
|
116
116
|
rmSync(stepPath(root), { force: true });
|
|
117
117
|
clearHandoff(root); // the agent-run handoff ledger is step-scoped; clear it with the markers.
|
|
118
118
|
clearTick(root); // the step's entry stamp is spent; the next `build <n>` re-stamps.
|
|
119
|
+
clearPause(root); // the pause it stamped is over; the next step's pause renders whole.
|
|
119
120
|
// The whole ending, one block: the lead line, the Verdict folded over what
|
|
120
121
|
// just landed, the advisories this checkpoint alone can state, and the
|
|
121
122
|
// pointer. Read after the flip and the ledger append, so the fold and the
|
|
122
123
|
// pointer both see the boundary the human is standing at.
|
|
123
124
|
const landed = boundaryEnding(root, activeBuild(root), step);
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
125
|
+
printEnding(root, {
|
|
126
|
+
tier: 'orientation',
|
|
127
|
+
render: 'checkpoint',
|
|
128
|
+
text: ending({
|
|
129
|
+
lead: transition({ label: 'Checkpoint', fact: `Step ${step} complete`, detail: leadDetail(sha, pointer) }),
|
|
130
|
+
verdict: landed.verdict,
|
|
131
|
+
advisories: [drift, flipNotice],
|
|
132
|
+
pointer: landed.pointer,
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
130
135
|
return 0;
|
|
131
136
|
}
|
|
132
137
|
/**
|
|
@@ -172,19 +177,23 @@ function checkpointPlan(root, args) {
|
|
|
172
177
|
clearTick(root);
|
|
173
178
|
// No Verdict: the plan commit measures nothing. The pointer aims at the step
|
|
174
179
|
// the build starts on, which is what `handoff` renders from a null step.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
:
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
180
|
+
printEnding(root, {
|
|
181
|
+
tier: 'orientation',
|
|
182
|
+
render: 'plan-commit',
|
|
183
|
+
text: ending({
|
|
184
|
+
lead: transition({ label: 'Plan', fact: 'committed', detail: leadDetail(sha, pointer) }),
|
|
185
|
+
advisories: staged
|
|
186
|
+
? []
|
|
187
|
+
: [
|
|
188
|
+
advisory({
|
|
189
|
+
fact: 'the plan rides the commit message',
|
|
190
|
+
detail: ['record-only', '.plumbbob/ is gitignored, so the files stay untracked'],
|
|
191
|
+
remedy: 'unignore .plumbbob/builds/ to keep the record in the tree',
|
|
192
|
+
}),
|
|
193
|
+
],
|
|
194
|
+
pointer: boundaryEnding(root, activeBuild(root), null).pointer,
|
|
195
|
+
}),
|
|
196
|
+
});
|
|
188
197
|
return 0;
|
|
189
198
|
}
|
|
190
199
|
/**
|
package/dist/verbs/doctor.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 1. The global plugin link (read-only). After `plumbbob init`, plumbbob lives as a
|
|
4
4
|
// symlink at ~/.claude/skills/plumbbob pointing at the installed package; Claude
|
|
5
|
-
// Code loads it in place as a plugin (skills `/plumbbob:*`, the
|
|
5
|
+
// Code loads it in place as a plugin (skills `/plumbbob:*`, the hooks from
|
|
6
6
|
// hooks.json). doctor verifies the link resolves to a package carrying the manifest,
|
|
7
|
-
// the skills, and the
|
|
7
|
+
// the skills, and the hooks, and names the fix for anything missing. The failure
|
|
8
8
|
// class it exists for is SILENT (a `/plumbbob:status` that opens an empty dashboard
|
|
9
9
|
// because the plugin never linked).
|
|
10
10
|
//
|
|
@@ -80,7 +80,7 @@ function linkedPackage(link) {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
/**
|
|
83
|
-
* The link-install checks: manifest, skill set, and
|
|
83
|
+
* The link-install checks: manifest, skill set, and hooks, each with its fix.
|
|
84
84
|
*
|
|
85
85
|
* `shipped` is the canonical skill list read from the package itself; an installed
|
|
86
86
|
* count below it means a stale or partial link, and every fix is the same re-link.
|
|
@@ -96,10 +96,29 @@ function buildChecks(link, pkg, shipped) {
|
|
|
96
96
|
? { ok: true, label: `skills present (${installed.length}) — load as /plumbbob:*` }
|
|
97
97
|
: { ok: false, label: `skills incomplete (${installed.length}/${shipped.length})`, fix: 're-link: plumbbob init' },
|
|
98
98
|
existsSync(join(pkg, 'hooks', 'hooks.json'))
|
|
99
|
-
? { ok: true, label: '
|
|
99
|
+
? { ok: true, label: hooksLabel(wiredEvents(join(pkg, 'hooks', 'hooks.json'))) }
|
|
100
100
|
: { ok: false, label: 'hook missing (hooks/hooks.json)', fix: 're-link: plumbbob init' },
|
|
101
101
|
];
|
|
102
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* The hooks line's label: every event the package wires, so a link that
|
|
105
|
+
* predates a hook reads as one at a glance. A file whose events cannot be read
|
|
106
|
+
* still counts as present, the way the line read before it named any.
|
|
107
|
+
*/
|
|
108
|
+
function hooksLabel(events) {
|
|
109
|
+
const named = events.length === 0 ? '' : `: ${events.join(', ')}`;
|
|
110
|
+
return `hooks present (hooks/hooks.json${named}; auto-registers)`;
|
|
111
|
+
}
|
|
112
|
+
/** The events a hooks.json wires, in file order, or none when it does not parse. */
|
|
113
|
+
function wiredEvents(path) {
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
116
|
+
return typeof parsed.hooks === 'object' && parsed.hooks !== null ? Object.keys(parsed.hooks) : [];
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
103
122
|
/**
|
|
104
123
|
* The plugin-link diagnostic: how plumbbob is installed into Claude Code, as checks.
|
|
105
124
|
*
|