plumbbob 0.8.17 → 0.9.1
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 +56 -44
- package/dist/cli-core.js +35 -15
- package/dist/lib/agents.js +230 -112
- package/dist/lib/buildlog.js +50 -30
- package/dist/lib/buildlogsync.js +21 -12
- package/dist/lib/check.js +69 -35
- package/dist/lib/commitmsg.js +53 -32
- package/dist/lib/git.js +86 -31
- package/dist/lib/intent.js +91 -31
- package/dist/lib/latch.js +70 -39
- package/dist/lib/orient.js +78 -39
- package/dist/lib/plugins.js +11 -8
- package/dist/lib/settings.js +65 -32
- package/dist/lib/sidecar.js +320 -150
- package/dist/lib/templates.js +17 -6
- package/dist/verbs/agent.js +116 -55
- package/dist/verbs/build.js +27 -12
- package/dist/verbs/check.js +33 -7
- package/dist/verbs/checkpoint.js +153 -78
- package/dist/verbs/doctor.js +192 -95
- package/dist/verbs/finish.js +87 -44
- package/dist/verbs/handoff.js +56 -20
- package/dist/verbs/init.js +37 -12
- package/dist/verbs/park.js +14 -3
- package/dist/verbs/revert.js +64 -24
- package/dist/verbs/spike.js +89 -30
- package/dist/verbs/start.js +72 -38
- package/dist/verbs/status.js +44 -17
- package/dist/verbs/turn.js +88 -45
- package/dist/verbs/use.js +20 -9
- package/package.json +2 -2
- package/skills/{pb-build → build}/SKILL.md +25 -25
- package/skills/{pb-doctor → doctor}/SKILL.md +1 -1
- package/skills/{pb-finish → finish}/SKILL.md +18 -5
- package/skills/{pb-harvest → harvest}/SKILL.md +4 -4
- package/skills/{pb-park → park}/SKILL.md +2 -2
- package/skills/{pb-plan → plan}/SKILL.md +11 -11
- package/skills/{pb-refine → refine}/SKILL.md +7 -7
- package/skills/{pb-revert → revert}/SKILL.md +2 -2
- package/skills/{pb-spike → spike}/SKILL.md +4 -4
- package/skills/{pb-status → status}/SKILL.md +3 -3
- package/skills/{pb-step → step}/SKILL.md +9 -9
- package/skills/{pb-verify → verify}/SKILL.md +8 -8
- package/templates/build-log.md +7 -7
- package/templates/intent.md +8 -8
- package/templates/spike-report.md +1 -1
package/dist/verbs/finish.js
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
|
-
// `plumbbob finish`
|
|
2
|
-
// report,
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
1
|
+
// `plumbbob finish` — the close-out. It appends the checkpoint SHAs and per-step
|
|
2
|
+
// stats to report.md, makes the final commit, and clears the control state (the
|
|
3
|
+
// untracked session and step markers under `.plumbbob/`). The build folder is NOT
|
|
4
|
+
// deleted — the tracked `.plumbbob/builds/<slug>/` folder IS the archive: it
|
|
5
|
+
// merges with the branch and shows up in the PR, so nothing is copied into a
|
|
6
|
+
// separate local archive. A missing report never blocks the exit — guidance
|
|
7
|
+
// offers the artifact, it does not wall the door. The git footprint stays
|
|
8
|
+
// additive: one forward commit under the Conventional `chore(<scope>): finish`
|
|
9
|
+
// subject, with the `plumbbob finish` identifier riding a marker line at the head
|
|
10
|
+
// of the body so `git log --grep plumbbob` still finds it.
|
|
9
11
|
import { appendFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
|
|
10
12
|
import { join } from 'node:path';
|
|
11
13
|
import { commit, findRepoRoot, isDirty, stageAll } from "../lib/git.js";
|
|
12
14
|
import { buildScope, checkpointsPath, clearTick, hasSession, intentPath, readStats, reportPath, resolveBuild, seamPath, setGrant, sidecarDir, spikePath, stepPath, } from "../lib/sidecar.js";
|
|
13
15
|
import { conventionalSubject, withMarker } from "../lib/commitmsg.js";
|
|
14
16
|
import { parseBuildScope } from "../lib/intent.js";
|
|
17
|
+
/**
|
|
18
|
+
* Close out the active build: report tail, final commit, control-state cleanup.
|
|
19
|
+
*
|
|
20
|
+
* Requires an active session (the `.plumbbob/STATE` sentinel). When report.md
|
|
21
|
+
* exists it gains a `## Checkpoints` SHA list and a `## Stats` table first; when
|
|
22
|
+
* it doesn't, finish notes the absence and proceeds — the exit is never gated on
|
|
23
|
+
* a report.
|
|
24
|
+
*/
|
|
15
25
|
export function finish(cwd, args = []) {
|
|
16
26
|
const root = findRepoRoot(cwd);
|
|
17
27
|
if (root === null || !hasSession(root)) {
|
|
@@ -25,46 +35,56 @@ export function finish(cwd, args = []) {
|
|
|
25
35
|
}
|
|
26
36
|
else {
|
|
27
37
|
process.stderr.write('plumbbob: note — no report.md found; finishing without one ' +
|
|
28
|
-
'(/
|
|
38
|
+
'(/plumbbob:finish normally writes the report first). No gate (D9).\n');
|
|
29
39
|
}
|
|
30
|
-
// The final commit
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// the
|
|
40
|
+
// The final commit: stage the report just written plus the build folder's tail —
|
|
41
|
+
// a step's commit sweeps its own bookkeeping along with the work, so the last
|
|
42
|
+
// step's `checkpoints` line lands one commit late and finish absorbs it — and
|
|
43
|
+
// commit under the `finish` subject. `--allow-empty` (via `commit`) still marks
|
|
44
|
+
// the narrative endpoint when the tree is already clean, or under `--local`,
|
|
45
|
+
// where the whole sidecar is git-excluded and there is nothing tracked to stage.
|
|
35
46
|
if (isDirty(root)) {
|
|
36
47
|
stageAll(root);
|
|
37
48
|
}
|
|
38
49
|
const sha = commit(root, subject(root, slug), withMarker('plumbbob finish', bodyArg(args) ?? undefined));
|
|
39
|
-
// Clear the control state: the in-flight markers first, then the session
|
|
40
|
-
// last (so "no session" flips exactly at the end). Deleting STATE also
|
|
41
|
-
// cursor
|
|
42
|
-
// both. The tracked artifacts stay in place — only the
|
|
50
|
+
// Clear the control state: the in-flight markers first, then the session
|
|
51
|
+
// sentinel last (so "no session" flips exactly at the end). Deleting STATE also
|
|
52
|
+
// drops the active-build cursor — cursor and session share that one file, so a
|
|
53
|
+
// single delete does both. The tracked artifacts stay in place — only the
|
|
54
|
+
// ephemera go.
|
|
43
55
|
rmSync(seamPath(root, slug), { force: true });
|
|
44
56
|
rmSync(stepPath(root, slug), { force: true });
|
|
45
57
|
rmSync(spikePath(root, slug), { force: true });
|
|
46
|
-
// The latch's per-build entry stamp and the one-turn
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// the next session's first
|
|
58
|
+
// The checkpoint latch's per-build entry stamp and the one-turn self-approval
|
|
59
|
+
// grant go with the session: a grant lives one turn by construction, but only
|
|
60
|
+
// because every tick rewrites it — the session's last tick was the last rewrite,
|
|
61
|
+
// so left behind, a stale `auto` could self-approve the next session's first
|
|
62
|
+
// landing.
|
|
50
63
|
clearTick(root, slug);
|
|
51
64
|
setGrant(root, null);
|
|
52
65
|
rmSync(join(sidecarDir(root), 'STATE'), { force: true });
|
|
53
66
|
const where = slug === null ? '.plumbbob/' : `.plumbbob/builds/${slug}/`;
|
|
54
67
|
process.stdout.write(`plumbbob: finished — ${sha.slice(0, 9)}. ${where} rides your branch into the PR. ` +
|
|
55
|
-
'Run `/
|
|
68
|
+
'Run `/plumbbob:plan` (or `plumbbob start "<title>"`) to frame the next goal.\n');
|
|
56
69
|
return 0;
|
|
57
70
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
/**
|
|
72
|
+
* The CLI-owned final-commit subject: `chore(<scope>): finish`.
|
|
73
|
+
*
|
|
74
|
+
* The scope resolves through the same build-default fallback chain as the plan
|
|
75
|
+
* and step subjects: the intent.md `**Scope:**` header field, else the build
|
|
76
|
+
* slug, else bare (e.g. `--local`). The `plumbbob finish` identifier rides the
|
|
77
|
+
* body marker, not the subject.
|
|
78
|
+
*/
|
|
63
79
|
function subject(root, slug) {
|
|
64
80
|
return conventionalSubject({ type: 'chore', scope: buildDefaultScope(root, slug), description: 'finish' });
|
|
65
81
|
}
|
|
66
|
-
|
|
67
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the commit scope: the intent.md `**Scope:**` header, else the slug.
|
|
84
|
+
*
|
|
85
|
+
* Twin of checkpoint.ts's helper — same fallback chain, but with this verb's own
|
|
86
|
+
* `slug` resolution instead of the active-build cursor.
|
|
87
|
+
*/
|
|
68
88
|
function buildDefaultScope(root, slug) {
|
|
69
89
|
try {
|
|
70
90
|
const fromHeader = parseBuildScope(readFileSync(intentPath(root, slug), 'utf8'));
|
|
@@ -77,12 +97,19 @@ function buildDefaultScope(root, slug) {
|
|
|
77
97
|
}
|
|
78
98
|
return buildScope(slug);
|
|
79
99
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Read the `--body` final-commit body from stdin, or null.
|
|
102
|
+
*
|
|
103
|
+
* The body arrives as a single-quoted stdin heredoc — the CLI owns every commit
|
|
104
|
+
* subject, but the finish skill composes a proportional close-out body this
|
|
105
|
+
* way. Returns null when the flag is absent or stdin is empty; the commit then
|
|
106
|
+
* carries subject only. Reading fd 0 blocks until EOF, which the heredoc
|
|
107
|
+
* supplies; a read error (no stdin attached) degrades to null, and an
|
|
108
|
+
* interactive TTY — which would never send EOF — skips the read instead of
|
|
109
|
+
* hanging (twin of checkpoint.ts's guard).
|
|
110
|
+
*/
|
|
84
111
|
function bodyArg(args) {
|
|
85
|
-
if (!args.includes('--body')) {
|
|
112
|
+
if (!args.includes('--body') || process.stdin.isTTY === true) {
|
|
86
113
|
return null;
|
|
87
114
|
}
|
|
88
115
|
try {
|
|
@@ -93,10 +120,14 @@ function bodyArg(args) {
|
|
|
93
120
|
return null;
|
|
94
121
|
}
|
|
95
122
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Roll the per-step receipts into report.md as a `## Stats` table.
|
|
125
|
+
*
|
|
126
|
+
* One row per step plus totals — red checks, drift warnings, reverts,
|
|
127
|
+
* wall-clock — so "is the loop worth it?" is a table, not a feeling. Silently
|
|
128
|
+
* skipped when nothing accrued (a build with no red/revert/drift and no
|
|
129
|
+
* `build <n>` stamps has nothing to say).
|
|
130
|
+
*/
|
|
100
131
|
function appendStats(root, slug) {
|
|
101
132
|
const stats = readStats(root, slug);
|
|
102
133
|
const steps = Object.keys(stats)
|
|
@@ -127,6 +158,10 @@ function appendStats(root, slug) {
|
|
|
127
158
|
'',
|
|
128
159
|
].join('\n'));
|
|
129
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Milliseconds from a step's entry stamp to its landing stamp, or null when
|
|
163
|
+
* either timestamp is missing or malformed.
|
|
164
|
+
*/
|
|
130
165
|
function wallClockMs(s) {
|
|
131
166
|
if (s.startedAt === undefined || s.landedAt === undefined) {
|
|
132
167
|
return null;
|
|
@@ -134,17 +169,25 @@ function wallClockMs(s) {
|
|
|
134
169
|
const ms = Date.parse(s.landedAt) - Date.parse(s.startedAt);
|
|
135
170
|
return Number.isFinite(ms) && ms >= 0 ? ms : null;
|
|
136
171
|
}
|
|
137
|
-
|
|
138
|
-
|
|
172
|
+
/**
|
|
173
|
+
* Format a wall-clock duration for the stats table.
|
|
174
|
+
*
|
|
175
|
+
* `—` when unknown (a hand-built step never ran `build <n>`, so it has no
|
|
176
|
+
* startedAt), `<1m` under a minute, whole minutes otherwise.
|
|
177
|
+
*/
|
|
139
178
|
function formatWall(ms) {
|
|
140
179
|
if (ms === null) {
|
|
141
180
|
return '—';
|
|
142
181
|
}
|
|
143
182
|
return ms < 60_000 ? '<1m' : `${Math.round(ms / 60_000)}m`;
|
|
144
183
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Append the recorded checkpoints (baseline + each `step n <sha>`) to report.md
|
|
186
|
+
* as a `## Checkpoints` section.
|
|
187
|
+
*
|
|
188
|
+
* The report rides the branch into the PR, so it carries the SHAs with it.
|
|
189
|
+
* Best-effort: an unreadable checkpoints file yields an empty list.
|
|
190
|
+
*/
|
|
148
191
|
function appendCheckpointShas(root, slug) {
|
|
149
192
|
let raw = '';
|
|
150
193
|
try {
|
package/dist/verbs/handoff.js
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
|
-
// `plumbbob handoff` — print the standardized build hand-off block
|
|
1
|
+
// `plumbbob handoff` — print the standardized build hand-off block: the
|
|
2
2
|
// "state / choice / what's next" the human sees at each step boundary. Read-only,
|
|
3
3
|
// no state change. It derives the moment from the session — a step in flight ⇒ the
|
|
4
4
|
// pause block (built → looks-good/needs-work → next), none ⇒ the post-checkpoint
|
|
5
5
|
// boundary block — and renders the next undone step plus its advisory `- model:`
|
|
6
|
-
// recommendation straight from intent.md
|
|
7
|
-
//
|
|
8
|
-
// which renders the same next-step detail
|
|
6
|
+
// recommendation straight from intent.md, the build's plan document. Owning the
|
|
7
|
+
// block here, rather than as prose in the build skill, keeps the skill from
|
|
8
|
+
// drifting out of sync with `status`, which renders the same next-step detail
|
|
9
|
+
// (both read `parseSteps`).
|
|
9
10
|
import { readFileSync } from 'node:fs';
|
|
10
11
|
import { findRepoRoot } from "../lib/git.js";
|
|
11
12
|
import { checkpointsPath, hasSession, intentPath, resolveBuild, stepPath } from "../lib/sidecar.js";
|
|
12
13
|
import { parseLastCheckpoint, parseSteps } from "../lib/orient.js";
|
|
14
|
+
/**
|
|
15
|
+
* Print the hand-off block for the resolved build; return the exit code.
|
|
16
|
+
*
|
|
17
|
+
* Requires an active session (the STATE sentinel under `.plumbbob/`). Which
|
|
18
|
+
* moment to render is derived, not passed: a step in flight yields the pause
|
|
19
|
+
* block, none yields the boundary block, and a fresh session with nothing to
|
|
20
|
+
* report yields only the forward pointer.
|
|
21
|
+
*/
|
|
13
22
|
export function handoff(cwd, args = []) {
|
|
14
23
|
const root = findRepoRoot(cwd);
|
|
15
24
|
if (root === null || !hasSession(root)) {
|
|
@@ -40,9 +49,12 @@ export function handoff(cwd, args = []) {
|
|
|
40
49
|
process.stdout.write(`${block}\n`);
|
|
41
50
|
return 0;
|
|
42
51
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
/**
|
|
53
|
+
* The pause block: the step is built and awaiting approval.
|
|
54
|
+
*
|
|
55
|
+
* The opener is neutral (the CLI cannot vouch for the diff), pointing at the
|
|
56
|
+
* diff and self-review the skill shows above it.
|
|
57
|
+
*/
|
|
46
58
|
function pauseBlock(step, title, nextUp) {
|
|
47
59
|
return [
|
|
48
60
|
`Step ${step}${paren(title)} is built. Review the diff and self-review above, then:`,
|
|
@@ -52,38 +64,57 @@ function pauseBlock(step, title, nextUp) {
|
|
|
52
64
|
nextUpLine(nextUp),
|
|
53
65
|
].join('\n');
|
|
54
66
|
}
|
|
55
|
-
|
|
56
|
-
|
|
67
|
+
/**
|
|
68
|
+
* The boundary block: the checkpoint has landed and the loop is back at the
|
|
69
|
+
* step boundary, pointing at what to build next.
|
|
70
|
+
*/
|
|
57
71
|
function boundaryBlock(step, title, nextUp) {
|
|
58
72
|
return [`Step ${step}${paren(title)} checkpointed — back at the boundary.`, '', nextUpLine(nextUp)].join('\n');
|
|
59
73
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
74
|
+
/**
|
|
75
|
+
* The forward pointer, shared by both blocks: the next undone step, its title,
|
|
76
|
+
* and its advisory `- model:` recommendation.
|
|
77
|
+
*
|
|
78
|
+
* The recommendation renders as just the model token for the `/model` call —
|
|
79
|
+
* the full rationale lives on the `status` dashboard. No next step ⇒ the
|
|
80
|
+
* finish/step nudge instead.
|
|
81
|
+
*/
|
|
64
82
|
function nextUpLine(nextUp) {
|
|
65
83
|
if (nextUp === undefined) {
|
|
66
|
-
return 'No planned steps remain — /
|
|
84
|
+
return 'No planned steps remain — /plumbbob:step to add an increment, or /plumbbob:finish.';
|
|
67
85
|
}
|
|
68
86
|
const head = `Next up: step ${nextUp.n}${paren(nextUp.title)}`;
|
|
69
87
|
const token = modelToken(nextUp.model);
|
|
70
|
-
return token === null ? `${head} — /
|
|
88
|
+
return token === null ? `${head} — /plumbbob:build to start it.` : `${head} · model: ${token} — /model ${token} then /plumbbob:build.`;
|
|
71
89
|
}
|
|
72
|
-
|
|
90
|
+
/**
|
|
91
|
+
* A step's title in parentheses, or nothing when the title is empty.
|
|
92
|
+
*/
|
|
73
93
|
function paren(title) {
|
|
74
94
|
return title !== null && title.length > 0 ? ` (${title})` : '';
|
|
75
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* The title of step `n` from the parsed steps, or null when the step is
|
|
98
|
+
* unknown or untitled.
|
|
99
|
+
*/
|
|
76
100
|
function titleOf(steps, n) {
|
|
77
101
|
return steps.find((s) => s.n === n)?.title ?? null;
|
|
78
102
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
103
|
+
/**
|
|
104
|
+
* The model token from a `- model:` recommendation — the first word, so a
|
|
105
|
+
* `model: opus — <rationale>` line yields `opus` for the `/model` call.
|
|
106
|
+
*
|
|
107
|
+
* Null when there is no recommendation, or it degraded to whitespace (folding
|
|
108
|
+
* the null model into the same guard keeps both branches live).
|
|
109
|
+
*/
|
|
83
110
|
function modelToken(model) {
|
|
84
111
|
const first = model?.trim().split(/\s+/)[0];
|
|
85
112
|
return first !== undefined && first.length > 0 ? first : null;
|
|
86
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Read a file as UTF-8, or return '' when it is missing or unreadable — an
|
|
116
|
+
* absent sidecar file reads as empty rather than throwing.
|
|
117
|
+
*/
|
|
87
118
|
function readOr(path) {
|
|
88
119
|
try {
|
|
89
120
|
return readFileSync(path, 'utf8');
|
|
@@ -92,6 +123,11 @@ function readOr(path) {
|
|
|
92
123
|
return '';
|
|
93
124
|
}
|
|
94
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* The in-flight step number from the STEP marker — the flat one-line file in
|
|
128
|
+
* the build folder that records which step is open. Null when no step is in
|
|
129
|
+
* flight or the file holds anything but a bare number.
|
|
130
|
+
*/
|
|
95
131
|
function readStep(path) {
|
|
96
132
|
try {
|
|
97
133
|
const raw = readFileSync(path, 'utf8').trim();
|
package/dist/verbs/init.js
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
// in-place skills-directory plugin. It symlinks the installed package into
|
|
3
3
|
// ~/.claude/skills/plumbbob, where Claude Code discovers it as `plumbbob@skills-dir`
|
|
4
4
|
// — the skills load namespaced (`/plumbbob:*`) and the post-edit hook auto-registers
|
|
5
|
-
// from hooks/hooks.json. This
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// from hooks/hooks.json. This non-marketplace install path is first-class by design:
|
|
6
|
+
// it serves an `npm i -g` global, local dev, and clients predating plugins, and it
|
|
7
|
+
// keeps plumbbob usable as a standalone CLI outside any one host rather than
|
|
8
|
+
// Claude-marketplace-only. The marketplace plugin is the
|
|
9
9
|
// co-equal self-contained alternative (it ships skills AND the `plumbbob` CLI on PATH
|
|
10
10
|
// via bin/, so it needs neither `npm i -g` nor `init`). The two are mutually
|
|
11
11
|
// exclusive: both register a plugin named `plumbbob`, and a double-install collides
|
|
12
|
-
// over the /plumbbob:* namespace (skills can drop to flat `/
|
|
12
|
+
// over the /plumbbob:* namespace (skills can drop to flat `/status` names). So init
|
|
13
13
|
// REFUSES when a marketplace plumbbob is already installed — `--force` overrides
|
|
14
14
|
// (the dev-install path uses it). Global-only by design: install scope is NOT session
|
|
15
15
|
// scope — sessions stay per-project via `plumbbob start`. Idempotent + reversible
|
|
@@ -19,17 +19,28 @@ import { homedir } from 'node:os';
|
|
|
19
19
|
import { join } from 'node:path';
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
21
|
import { marketplacePlumbbob } from "../lib/plugins.js";
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
/**
|
|
23
|
+
* The installed package root — parent of .claude-plugin/, skills/, hooks/, dist/.
|
|
24
|
+
*
|
|
25
|
+
* Derived from this module's URL: the global install when run as the published
|
|
26
|
+
* bin, the checkout in dev. No trailing slash, so it compares clean against a
|
|
27
|
+
* readlink.
|
|
28
|
+
*/
|
|
25
29
|
function packageRoot() {
|
|
26
30
|
return fileURLToPath(new URL('../../', import.meta.url)).replace(/\/+$/, '');
|
|
27
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* The symlink location Claude Code scans for skills-directory plugins.
|
|
34
|
+
*/
|
|
28
35
|
function linkPath(home) {
|
|
29
36
|
return join(home, '.claude', 'skills', 'plumbbob');
|
|
30
37
|
}
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Whether anything exists at `path`, including a broken symlink.
|
|
40
|
+
*
|
|
41
|
+
* lstat-based: existsSync follows the link and would miss one whose target
|
|
42
|
+
* moved.
|
|
43
|
+
*/
|
|
33
44
|
function present(path) {
|
|
34
45
|
try {
|
|
35
46
|
lstatSync(path);
|
|
@@ -39,7 +50,9 @@ function present(path) {
|
|
|
39
50
|
return false;
|
|
40
51
|
}
|
|
41
52
|
}
|
|
42
|
-
|
|
53
|
+
/**
|
|
54
|
+
* The symlink's target (no trailing slash), or null if `path` is not a symlink.
|
|
55
|
+
*/
|
|
43
56
|
function symlinkTarget(path) {
|
|
44
57
|
try {
|
|
45
58
|
return lstatSync(path).isSymbolicLink() ? readlinkSync(path).replace(/\/+$/, '') : null;
|
|
@@ -48,6 +61,14 @@ function symlinkTarget(path) {
|
|
|
48
61
|
return null;
|
|
49
62
|
}
|
|
50
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Link the installed package into ~/.claude/skills/plumbbob, or `--uninstall` it.
|
|
66
|
+
*
|
|
67
|
+
* Refuses when a marketplace plumbbob plugin is already installed — two plugins
|
|
68
|
+
* named `plumbbob` collide over the /plumbbob:* namespace — unless `--force`.
|
|
69
|
+
* Repoints a stale link from an earlier install location; already-linked is a
|
|
70
|
+
* clean exit 0.
|
|
71
|
+
*/
|
|
51
72
|
export function init(args) {
|
|
52
73
|
const home = process.env.HOME ?? homedir();
|
|
53
74
|
const link = linkPath(home);
|
|
@@ -59,7 +80,7 @@ export function init(args) {
|
|
|
59
80
|
if (market.length > 0 && !args.includes('--force')) {
|
|
60
81
|
process.stderr.write(`plumbbob: a marketplace plumbbob plugin is already installed (${market.join(', ')}).\n` +
|
|
61
82
|
'plumbbob: it already provides the skills (`/plumbbob:*`) and the `plumbbob` CLI on PATH (the plugin bin/) — no `plumbbob init` needed.\n' +
|
|
62
|
-
`plumbbob: linking ${link} would register a SECOND plugin named \`plumbbob\`; the two collide over the /plumbbob:* namespace and skills can drop to flat names (\`/
|
|
83
|
+
`plumbbob: linking ${link} would register a SECOND plugin named \`plumbbob\`; the two collide over the /plumbbob:* namespace and skills can drop to flat names (\`/status\`).\n` +
|
|
63
84
|
'plumbbob: to use the skills-dir link instead, first remove the marketplace one (`/plugin uninstall plumbbob@<marketplace>`), or re-run with `--force` if you know what you are doing.\n');
|
|
64
85
|
return 1;
|
|
65
86
|
}
|
|
@@ -82,6 +103,10 @@ export function init(args) {
|
|
|
82
103
|
'plumbbob: nothing else under ~ is touched and settings.json is left alone. Sessions are per-project — run `plumbbob start "<goal>"` in any repo, then `plumbbob doctor` to verify.\n');
|
|
83
104
|
return 0;
|
|
84
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Remove the skills-directory link, leaving anything that is not a plumbbob
|
|
108
|
+
* symlink untouched.
|
|
109
|
+
*/
|
|
85
110
|
function uninstall(link) {
|
|
86
111
|
if (!present(link)) {
|
|
87
112
|
process.stdout.write(`plumbbob: nothing to uninstall — no link at ${link}.\n`);
|
package/dist/verbs/park.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
// `plumbbob park "<text>"` — the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// `plumbbob park "<text>"` — the raw capture path for a thought you don't want
|
|
2
|
+
// to chase mid-step: append one flat line under the "## Park list" section of
|
|
3
|
+
// build-log.md (the build's human-facing ledger). Capture stays dumb by design —
|
|
4
|
+
// a grep-readable append, no markdown parsing, no model turn (wording the line
|
|
5
|
+
// well is /plumbbob:park's job); triage waits for a step boundary, via harvest.
|
|
6
|
+
// Capture is not a state transition, so it runs in any context (terminal or
|
|
7
|
+
// in-session).
|
|
4
8
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
5
9
|
import { findRepoRoot } from "../lib/git.js";
|
|
6
10
|
import { hasSession, buildLogPath } from "../lib/sidecar.js";
|
|
7
11
|
import { appendToSection } from "../lib/buildlog.js";
|
|
12
|
+
/**
|
|
13
|
+
* Append the given text as one unchecked `- [ ]` line to the build-log's Park list.
|
|
14
|
+
*
|
|
15
|
+
* All non-flag args join into a single line. Refuses when there is no active
|
|
16
|
+
* session, when the text is empty, or when build-log.md lacks a "## Park list"
|
|
17
|
+
* section — a park line must never vanish silently.
|
|
18
|
+
*/
|
|
8
19
|
export function park(cwd, args) {
|
|
9
20
|
const root = findRepoRoot(cwd);
|
|
10
21
|
if (root === null || !hasSession(root)) {
|
package/dist/verbs/revert.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// `plumbbob revert [--to n]` — git reset --hard to a checkpoint SHA (the most
|
|
2
2
|
// recent step, or `--to n`, with the baseline as fallback), then remove untracked
|
|
3
|
-
// files under the SEAM only
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
3
|
+
// files under the SEAM only (the seam is the in-flight step's flat list of
|
|
4
|
+
// granted edit paths). The artifact plane — the tracked `.plumbbob/builds/<slug>/`
|
|
5
|
+
// folder holding intent, build-log, checkpoints, and park lines — would not
|
|
6
|
+
// survive a bare reset: it would discard park lines and intent edits, or, when
|
|
7
|
+
// reverting to a baseline that predates the build folder, delete the folder
|
|
8
|
+
// wholesale. So revert snapshots the sidecar to temp and restores it as
|
|
9
|
+
// uncommitted changes after the reset — a rewind must never destroy recorded
|
|
10
|
+
// work, in either case. The untracked cleanup additionally whitelists the
|
|
11
|
+
// artifact plane, so no seam pattern can ever sweep away a build's own files.
|
|
10
12
|
//
|
|
11
13
|
// Plumbbob also installs its driver skills INTO the repo (.claude/skills/<driver>/
|
|
12
14
|
// for a self-contained install), so a blunt reset would discard an out-of-seam
|
|
@@ -21,6 +23,15 @@ import { findRepoRoot, resetHard, untrackedPaths } from "../lib/git.js";
|
|
|
21
23
|
import { bumpStepStat, checkpointsPath, hasSession, resolveBuild, seamPath, sidecarDir, stepPath } from "../lib/sidecar.js";
|
|
22
24
|
import { isArtifactPath, matchesSeam } from "../lib/intent.js";
|
|
23
25
|
import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
|
|
26
|
+
/**
|
|
27
|
+
* Rewind to a recorded checkpoint and return the build to the boundary.
|
|
28
|
+
*
|
|
29
|
+
* Requires an active session (the `.plumbbob/STATE` sentinel). Resolves the
|
|
30
|
+
* target SHA from the checkpoints ledger (latest step, `--to n`, or the
|
|
31
|
+
* baseline), resets hard while preserving plumbbob-owned paths, removes
|
|
32
|
+
* untracked files inside the seam, clears the in-flight STEP/SEAM markers, and
|
|
33
|
+
* re-renders the build-log. Exit 1 with a hint on any refusal.
|
|
34
|
+
*/
|
|
24
35
|
export function revert(cwd, args) {
|
|
25
36
|
const root = findRepoRoot(cwd);
|
|
26
37
|
if (root === null || !hasSession(root)) {
|
|
@@ -54,9 +65,9 @@ export function revert(cwd, args) {
|
|
|
54
65
|
// ignored files alone, so they must be removed explicitly afterward).
|
|
55
66
|
const seam = readSeamTokens(root, slug);
|
|
56
67
|
const toRemove = untrackedPaths(root).filter((p) => matchesSeam(p, seam) && !isArtifactPath(p));
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// is preserved through it, so the write survives).
|
|
68
|
+
// A revert against an in-flight step is a datapoint the finish report's stats
|
|
69
|
+
// table wants — read the STEP marker before it goes, bump the counter after
|
|
70
|
+
// the reset (the sidecar is preserved through it, so the write survives).
|
|
60
71
|
const inFlight = readInFlightStep(root, slug);
|
|
61
72
|
resetPreserving(root, sha, plumbbobOwnedPaths(root));
|
|
62
73
|
for (const rel of toRemove) {
|
|
@@ -67,19 +78,26 @@ export function revert(cwd, args) {
|
|
|
67
78
|
if (inFlight !== null) {
|
|
68
79
|
bumpStepStat(root, slug, inFlight, 'reverts');
|
|
69
80
|
}
|
|
70
|
-
// The step is abandoned: the build-log returns to the
|
|
71
|
-
// re-renders from the preserved intent.md
|
|
72
|
-
// so intent's checkboxes stay the truth to reflect.
|
|
81
|
+
// The step is abandoned: the build-log's Current-step line returns to the
|
|
82
|
+
// boundary and its Steps mirror re-renders from the preserved intent.md —
|
|
83
|
+
// revert keeps intent edits, so intent's checkboxes stay the truth to reflect.
|
|
84
|
+
// Best-effort, like every build-log write.
|
|
73
85
|
syncBuildLogState(root, slug, AT_BOUNDARY);
|
|
74
86
|
process.stdout.write(`plumbbob: reverted to ${sha.slice(0, 9)} — back at the boundary. Park lines and intent edits were preserved.\n`);
|
|
75
87
|
return 0;
|
|
76
88
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
89
|
+
/**
|
|
90
|
+
* The repo paths that belong to plumbbob, not to the work being reverted.
|
|
91
|
+
*
|
|
92
|
+
* They are the sidecar (already git-excluded, listed so revert is robust even
|
|
93
|
+
* where `.plumbbob/` was tracked by mistake) and each installed driver skill
|
|
94
|
+
* under .claude/skills/, named from plumbbob's own bundled `skills/` dir.
|
|
95
|
+
*
|
|
96
|
+
* The match is by name alone, and since the skills dropped their `pb-` prefix
|
|
97
|
+
* those names are ordinary words — a user's own `.claude/skills/plan/` is
|
|
98
|
+
* carried across the reset too. That errs toward keeping work (C4), so it is
|
|
99
|
+
* the safe direction to be wrong in. Only paths that currently exist are returned.
|
|
100
|
+
*/
|
|
83
101
|
function plumbbobOwnedPaths(root) {
|
|
84
102
|
const paths = [sidecarDir(root)];
|
|
85
103
|
try {
|
|
@@ -92,9 +110,13 @@ function plumbbobOwnedPaths(root) {
|
|
|
92
110
|
}
|
|
93
111
|
return paths.filter((p) => existsSync(p));
|
|
94
112
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
113
|
+
/**
|
|
114
|
+
* `git reset --hard <sha>` with the given paths carried across it.
|
|
115
|
+
*
|
|
116
|
+
* The reset is repo-wide, so paths that must survive it are copied to a temp
|
|
117
|
+
* snapshot first, then copied back over whatever the reset produced. Restoring
|
|
118
|
+
* on top (no pre-delete) keeps the live sidecar safe if a copy throws.
|
|
119
|
+
*/
|
|
98
120
|
function resetPreserving(root, sha, preserve) {
|
|
99
121
|
const snap = mkdtempSync(join(tmpdir(), 'plumbbob-revert-'));
|
|
100
122
|
try {
|
|
@@ -106,6 +128,10 @@ function resetPreserving(root, sha, preserve) {
|
|
|
106
128
|
rmSync(snap, { force: true, recursive: true });
|
|
107
129
|
}
|
|
108
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Parse `--to <n>`: the step number, null when the flag is absent, or
|
|
133
|
+
* 'invalid' when the flag lacks a numeric argument.
|
|
134
|
+
*/
|
|
109
135
|
function parseTo(args) {
|
|
110
136
|
const idx = args.indexOf('--to');
|
|
111
137
|
if (idx === -1) {
|
|
@@ -117,6 +143,12 @@ function parseTo(args) {
|
|
|
117
143
|
}
|
|
118
144
|
return Number(raw);
|
|
119
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Parse the build's CHECKPOINTS record into a baseline sha and per-step shas.
|
|
148
|
+
*
|
|
149
|
+
* A missing or unreadable file yields an empty record — the caller then refuses
|
|
150
|
+
* with its own message rather than crashing here.
|
|
151
|
+
*/
|
|
120
152
|
function readCheckpoints(root, slug) {
|
|
121
153
|
let content = '';
|
|
122
154
|
try {
|
|
@@ -140,6 +172,11 @@ function readCheckpoints(root, slug) {
|
|
|
140
172
|
}
|
|
141
173
|
return { baseline, steps };
|
|
142
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Read the in-flight step's seam — its edit-grant paths, one per line.
|
|
177
|
+
*
|
|
178
|
+
* A missing or unreadable SEAM contributes nothing: no tokens, never an error.
|
|
179
|
+
*/
|
|
143
180
|
function readSeamTokens(root, slug) {
|
|
144
181
|
try {
|
|
145
182
|
return readFileSync(seamPath(root, slug), 'utf8')
|
|
@@ -151,8 +188,11 @@ function readSeamTokens(root, slug) {
|
|
|
151
188
|
return [];
|
|
152
189
|
}
|
|
153
190
|
}
|
|
154
|
-
|
|
155
|
-
|
|
191
|
+
/**
|
|
192
|
+
* The in-flight STEP number, or null when none.
|
|
193
|
+
*
|
|
194
|
+
* Read for the revert receipt before the marker is cleared.
|
|
195
|
+
*/
|
|
156
196
|
function readInFlightStep(root, slug) {
|
|
157
197
|
try {
|
|
158
198
|
const raw = readFileSync(stepPath(root, slug), 'utf8').trim();
|