plumbbob 0.2.3 → 0.3.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/README.md +69 -141
- package/dist/cli.js +22 -37
- package/dist/lib/archive.js +6 -3
- package/dist/lib/git.js +0 -4
- package/dist/lib/intent.js +1 -1
- package/dist/lib/orient.js +160 -0
- package/dist/lib/settings.js +12 -8
- package/dist/lib/sidecar.js +0 -3
- package/dist/verbs/build.js +1 -1
- package/dist/verbs/check.js +17 -0
- package/dist/verbs/checkpoint.js +83 -0
- package/dist/verbs/doctor.js +172 -0
- package/dist/verbs/reset.js +54 -0
- package/dist/verbs/setup.js +31 -9
- package/dist/verbs/status.js +23 -4
- package/package.json +2 -2
- package/skills/pb-build/SKILL.md +40 -10
- package/skills/pb-harvest/SKILL.md +47 -0
- package/skills/pb-park/SKILL.md +40 -0
- package/skills/pb-plan/SKILL.md +38 -0
- package/skills/pb-reset/SKILL.md +39 -0
- package/skills/pb-revert/SKILL.md +1 -1
- package/skills/pb-spike/SKILL.md +1 -1
- package/skills/pb-status/SKILL.md +22 -0
- package/skills/pb-step/SKILL.md +37 -0
- package/skills/pb-verify/SKILL.md +47 -0
- package/skills/plumbbob-interrogate/SKILL.md +1 -1
- package/dist/verbs/done.js +0 -63
- package/dist/verbs/finish.js +0 -54
- package/dist/verbs/mode.js +0 -26
- package/dist/verbs/review.js +0 -24
- package/dist/verbs/wrap.js +0 -28
- package/hooks/bash-guard.sh +0 -62
- package/hooks/pre-edit.sh +0 -112
- package/skills/park/SKILL.md +0 -26
- package/skills/pb-done/SKILL.md +0 -18
- package/skills/pb-finish/SKILL.md +0 -18
- package/skills/pb-review/SKILL.md +0 -18
- package/skills/pb-start/SKILL.md +0 -19
- package/skills/pb-wrap/SKILL.md +0 -18
- package/skills/plumbbob-docs/SKILL.md +0 -25
- package/skills/plumbbob-report/SKILL.md +0 -35
- package/skills/plumbbob-triage/SKILL.md +0 -38
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// `plumbbob check` — run the heavy gate (D16/D24) and report, with NO state
|
|
2
|
+
// change. The read-only half of the verify tick: `/pb-verify` runs this before the
|
|
3
|
+
// pause so the human approves on a known-green check. Exits with the check's own
|
|
4
|
+
// code (0 = green).
|
|
5
|
+
import { findRepoRoot } from "../lib/git.js";
|
|
6
|
+
import { hasSession } from "../lib/sidecar.js";
|
|
7
|
+
import { runCheck } from "../lib/check.js";
|
|
8
|
+
export function check(cwd) {
|
|
9
|
+
const root = findRepoRoot(cwd);
|
|
10
|
+
if (root === null || !hasSession(root)) {
|
|
11
|
+
process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
|
|
12
|
+
return 1;
|
|
13
|
+
}
|
|
14
|
+
const code = runCheck(root);
|
|
15
|
+
process.stdout.write(code === 0 ? '\nplumbbob: check green.\n' : '\nplumbbob: check RED — fix it before checkpointing.\n');
|
|
16
|
+
return code;
|
|
17
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// `plumbbob checkpoint [<n>] [-m <msg>]` — the executor-agnostic commit tick (D3).
|
|
2
|
+
// Unlike v1 `done`, it does NOT require BUILD state or a STEP file: the step is
|
|
3
|
+
// whatever you pass, else the in-flight STEP, else the next undone step in intent.
|
|
4
|
+
// It gates on a green check, then commits any pending work (or records the existing
|
|
5
|
+
// HEAD when the tree is already clean — the human's commit skill may have committed
|
|
6
|
+
// first), records the SHA, flips the intent checkbox to `[x]`, clears any STEP/SEAM,
|
|
7
|
+
// and returns to DESIGN. The diff's author is irrelevant: `/pb-build`, your hands,
|
|
8
|
+
// a vibe session, or another harness all checkpoint the same way.
|
|
9
|
+
import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { commit, findRepoRoot, headSha, isDirty, stageAll } from "../lib/git.js";
|
|
11
|
+
import { checkpointsPath, hasSession, intentPath, seamPath, stepPath, writeState } from "../lib/sidecar.js";
|
|
12
|
+
import { runCheck } from "../lib/check.js";
|
|
13
|
+
import { markStepDone, parseSteps } from "../lib/orient.js";
|
|
14
|
+
export function checkpoint(cwd, args) {
|
|
15
|
+
const root = findRepoRoot(cwd);
|
|
16
|
+
if (root === null || !hasSession(root)) {
|
|
17
|
+
process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
|
|
18
|
+
return 1;
|
|
19
|
+
}
|
|
20
|
+
const step = resolveStep(root, args);
|
|
21
|
+
if (step === null) {
|
|
22
|
+
process.stderr.write('plumbbob: no step to checkpoint — pass a number, or plan a step in intent.md first.\n');
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
|
25
|
+
if (runCheck(root) !== 0) {
|
|
26
|
+
process.stderr.write('plumbbob: check failed (red) — checkpoint refuses on red. Fix it and re-run.\n');
|
|
27
|
+
return 1;
|
|
28
|
+
}
|
|
29
|
+
let sha;
|
|
30
|
+
if (isDirty(root)) {
|
|
31
|
+
stageAll(root);
|
|
32
|
+
sha = commit(root, messageArg(args) ?? `plumbbob: step ${step} done`);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
sha = headSha(root);
|
|
36
|
+
}
|
|
37
|
+
appendFileSync(checkpointsPath(root), `step ${step} ${sha}\n`);
|
|
38
|
+
flipIntent(root, step);
|
|
39
|
+
rmSync(seamPath(root), { force: true });
|
|
40
|
+
rmSync(stepPath(root), { force: true });
|
|
41
|
+
writeState(root, 'DESIGN');
|
|
42
|
+
process.stdout.write(`plumbbob: step ${step} checkpointed — ${sha.slice(0, 9)}. STATE=DESIGN.\n`);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
// Step resolution (D3): explicit arg > in-flight STEP file > first undone step in
|
|
46
|
+
// intent.md. Returns null when none can be determined.
|
|
47
|
+
function resolveStep(root, args) {
|
|
48
|
+
const explicit = args.find((a) => /^\d+$/.test(a));
|
|
49
|
+
if (explicit !== undefined) {
|
|
50
|
+
return Number(explicit);
|
|
51
|
+
}
|
|
52
|
+
const inFlight = readStep(root);
|
|
53
|
+
if (inFlight !== null) {
|
|
54
|
+
return inFlight;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
return parseSteps(readFileSync(intentPath(root), 'utf8')).find((s) => !s.done)?.n ?? null;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function flipIntent(root, step) {
|
|
64
|
+
try {
|
|
65
|
+
writeFileSync(intentPath(root), markStepDone(readFileSync(intentPath(root), 'utf8'), step));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// best-effort bookkeeping; the checkpoint SHA is the source of truth.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function messageArg(args) {
|
|
72
|
+
const i = args.indexOf('-m');
|
|
73
|
+
return i !== -1 && i + 1 < args.length ? (args[i + 1] ?? null) : null;
|
|
74
|
+
}
|
|
75
|
+
function readStep(root) {
|
|
76
|
+
try {
|
|
77
|
+
const raw = readFileSync(stepPath(root), 'utf8').trim();
|
|
78
|
+
return /^\d+$/.test(raw) ? Number(raw) : null;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// `plumbbob doctor` — diagnose a project's install end to end and print the fix
|
|
2
|
+
// for anything broken. Read-only: it inspects, it never writes. The failure
|
|
3
|
+
// class it exists for is SILENT — a skill's status pre-injection resolving to an
|
|
4
|
+
// empty/garbled bin (e.g. the pre-0.3 `$CLAUDE_PROJECT_DIR` form, a hooks-only
|
|
5
|
+
// variable that expands empty in a skill's bash) leaves an empty dashboard with
|
|
6
|
+
// no error. doctor names the problem and the remedy instead. Functional,
|
|
7
|
+
// node builtins only (C1/C2).
|
|
8
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { basename, join } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { findRepoRoot } from "../lib/git.js";
|
|
13
|
+
import { readSettings } from "../lib/settings.js";
|
|
14
|
+
// The plumbbob package's own skills/ dir (the canonical set setup installs). Off
|
|
15
|
+
// this module's URL so it resolves the same from src/ (dev) and dist/ (published).
|
|
16
|
+
function packageDir(name) {
|
|
17
|
+
return fileURLToPath(new URL(`../../${name}/`, import.meta.url));
|
|
18
|
+
}
|
|
19
|
+
// The pb-* skill directories under `dir` that actually carry a SKILL.md.
|
|
20
|
+
function listSkills(dir) {
|
|
21
|
+
try {
|
|
22
|
+
return readdirSync(dir).filter((n) => existsSync(join(dir, n, 'SKILL.md')));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Pull the bin token out of a skill's status pre-injection — the text between
|
|
29
|
+
// `!`` and ` status`. Returns null if the skill carries no injection line.
|
|
30
|
+
function injectionBin(body) {
|
|
31
|
+
const m = body.match(/!`(.+?) status\b/);
|
|
32
|
+
return m === null ? null : (m[1] ?? null);
|
|
33
|
+
}
|
|
34
|
+
// The distinct bin tokens the installed skills inject. They should be uniform; a
|
|
35
|
+
// set surfaces a partial/failed setup that left some skills on the old token.
|
|
36
|
+
function installedBins(skillsDir, skills) {
|
|
37
|
+
const bins = skills
|
|
38
|
+
.map((n) => injectionBin(readFileSync(join(skillsDir, n, 'SKILL.md'), 'utf8')))
|
|
39
|
+
.filter((b) => b !== null);
|
|
40
|
+
return [...new Set(bins)];
|
|
41
|
+
}
|
|
42
|
+
// Classify the injected bins: ok, or a list of human-readable problems. Catches
|
|
43
|
+
// the unresolved placeholder (setup never substituted) and the legacy
|
|
44
|
+
// $CLAUDE_PROJECT_DIR form (empty in a skill), and verifies an absolute bin
|
|
45
|
+
// actually exists on disk. A bare `plumbbob` resolves from PATH — accepted.
|
|
46
|
+
function binProblems(bins) {
|
|
47
|
+
const problems = [];
|
|
48
|
+
for (const bin of bins) {
|
|
49
|
+
if (bin.includes('__PLUMBBOB_BIN__')) {
|
|
50
|
+
problems.push('placeholder not substituted (setup did not finish)');
|
|
51
|
+
}
|
|
52
|
+
else if (bin.includes('$CLAUDE_PROJECT_DIR')) {
|
|
53
|
+
problems.push('legacy $CLAUDE_PROJECT_DIR bin — expands empty in a skill (pre-0.3 install)');
|
|
54
|
+
}
|
|
55
|
+
else if (bin.startsWith('/') && !existsSync(bin)) {
|
|
56
|
+
problems.push(`absolute bin not found on disk: ${bin}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return problems;
|
|
60
|
+
}
|
|
61
|
+
// True iff any registered hook command points into our hooks dir (the marker the
|
|
62
|
+
// settings module also uses for strip/merge).
|
|
63
|
+
function hasOurHook(settings) {
|
|
64
|
+
const hooks = settings.hooks ?? {};
|
|
65
|
+
return Object.values(hooks).some((list) => (list ?? []).some((e) => (e.hooks ?? []).some((h) => (h.command ?? '').includes('plumbbob/hooks/'))));
|
|
66
|
+
}
|
|
67
|
+
// The repo-scoped (self-contained) install: skills under <repo>/.claude, the CLI
|
|
68
|
+
// and hook in <repo>/node_modules, registration in either settings file.
|
|
69
|
+
function selfChecks(repoRoot, shipped) {
|
|
70
|
+
const skillsDir = join(repoRoot, '.claude', 'skills');
|
|
71
|
+
const installed = listSkills(skillsDir);
|
|
72
|
+
const bins = installedBins(skillsDir, installed);
|
|
73
|
+
const problems = binProblems(bins);
|
|
74
|
+
const localFile = join(repoRoot, '.claude', 'settings.local.json');
|
|
75
|
+
const projFile = join(repoRoot, '.claude', 'settings.json');
|
|
76
|
+
const registeredIn = [localFile, projFile].filter((f) => hasOurHook(readSettings(f)));
|
|
77
|
+
const hookScript = join(repoRoot, 'node_modules', 'plumbbob', 'hooks', 'post-edit.sh');
|
|
78
|
+
return [
|
|
79
|
+
installed.length >= shipped.length
|
|
80
|
+
? { ok: true, label: `skills installed (${installed.length}) in .claude/skills` }
|
|
81
|
+
: {
|
|
82
|
+
ok: false,
|
|
83
|
+
label: `skills incomplete (${installed.length}/${shipped.length}) in .claude/skills`,
|
|
84
|
+
fix: 'rerun: pnpm exec plumbbob setup --local',
|
|
85
|
+
},
|
|
86
|
+
problems.length === 0
|
|
87
|
+
? { ok: true, label: `skill bin resolves (${bins.join(', ') || 'n/a'})` }
|
|
88
|
+
: { ok: false, label: `skill bin broken — ${problems.join('; ')}`, fix: 'rerun: pnpm exec plumbbob setup --local' },
|
|
89
|
+
existsSync(join(repoRoot, 'node_modules', 'plumbbob'))
|
|
90
|
+
? { ok: true, label: 'plumbbob dependency present (node_modules/plumbbob)' }
|
|
91
|
+
: { ok: false, label: 'plumbbob is not a project dependency', fix: 'add it: pnpm add -D plumbbob' },
|
|
92
|
+
existsSync(join(repoRoot, 'node_modules', '.bin', 'plumbbob'))
|
|
93
|
+
? { ok: true, label: 'CLI shim present (node_modules/.bin/plumbbob)' }
|
|
94
|
+
: { ok: false, label: 'CLI shim missing (node_modules/.bin/plumbbob)', fix: 'reinstall deps: pnpm install' },
|
|
95
|
+
registeredIn.length > 0
|
|
96
|
+
? { ok: true, label: `post-edit hook registered (${registeredIn.map((f) => basename(f)).join(', ')})` }
|
|
97
|
+
: {
|
|
98
|
+
ok: false,
|
|
99
|
+
label: 'post-edit hook not registered in .claude/settings*.json',
|
|
100
|
+
fix: 'rerun: pnpm exec plumbbob setup --local',
|
|
101
|
+
},
|
|
102
|
+
existsSync(hookScript)
|
|
103
|
+
? { ok: true, label: 'hook script resolves (node_modules/plumbbob/hooks/post-edit.sh)' }
|
|
104
|
+
: {
|
|
105
|
+
ok: false,
|
|
106
|
+
label: 'hook script missing (node_modules/plumbbob/hooks/post-edit.sh)',
|
|
107
|
+
fix: 'reinstall the dep so the registered hook resolves: pnpm add -D plumbbob',
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
// The global install: skills + hook copied under ~/.claude, the CLI on PATH from
|
|
112
|
+
// `npm i -g`. The bare on-PATH bin can't be probed from here without spawning, so
|
|
113
|
+
// this scope verifies the copied artifacts and the registration.
|
|
114
|
+
function globalChecks(home, shipped) {
|
|
115
|
+
const skillsDir = join(home, '.claude', 'skills');
|
|
116
|
+
const installed = listSkills(skillsDir);
|
|
117
|
+
const problems = binProblems(installedBins(skillsDir, installed));
|
|
118
|
+
const hookScript = join(home, '.claude', 'plumbbob', 'hooks', 'post-edit.sh');
|
|
119
|
+
return [
|
|
120
|
+
installed.length >= shipped.length
|
|
121
|
+
? { ok: true, label: `skills installed (${installed.length}) in ~/.claude/skills` }
|
|
122
|
+
: { ok: false, label: `skills incomplete (${installed.length}/${shipped.length})`, fix: 'rerun: plumbbob setup --global' },
|
|
123
|
+
problems.length === 0
|
|
124
|
+
? { ok: true, label: 'skill bin resolves (plumbbob, on PATH from the global install)' }
|
|
125
|
+
: { ok: false, label: `skill bin broken — ${problems.join('; ')}`, fix: 'rerun: plumbbob setup --global' },
|
|
126
|
+
existsSync(hookScript)
|
|
127
|
+
? { ok: true, label: 'hook script present (~/.claude/plumbbob/hooks/post-edit.sh)' }
|
|
128
|
+
: { ok: false, label: 'hook script missing under ~/.claude/plumbbob/hooks', fix: 'rerun: plumbbob setup --global' },
|
|
129
|
+
hasOurHook(readSettings(join(home, '.claude', 'settings.json')))
|
|
130
|
+
? { ok: true, label: 'post-edit hook registered (~/.claude/settings.json)' }
|
|
131
|
+
: { ok: false, label: 'post-edit hook not registered (~/.claude/settings.json)', fix: 'rerun: plumbbob setup --global' },
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
export function doctor(cwd) {
|
|
135
|
+
const home = process.env.HOME ?? homedir();
|
|
136
|
+
const shipped = listSkills(packageDir('skills'));
|
|
137
|
+
const repoRoot = findRepoRoot(cwd);
|
|
138
|
+
const repoSkills = repoRoot === null ? [] : listSkills(join(repoRoot, '.claude', 'skills'));
|
|
139
|
+
const globalSkills = listSkills(join(home, '.claude', 'skills'));
|
|
140
|
+
const out = [`plumbbob doctor — ${repoRoot ?? cwd}`];
|
|
141
|
+
let checks;
|
|
142
|
+
if (repoSkills.length > 0 && repoRoot !== null) {
|
|
143
|
+
out.push('install shape: self-contained (<repo>/.claude)');
|
|
144
|
+
checks = selfChecks(repoRoot, shipped);
|
|
145
|
+
}
|
|
146
|
+
else if (globalSkills.length > 0) {
|
|
147
|
+
out.push('install shape: global (~/.claude)');
|
|
148
|
+
checks = globalChecks(home, shipped);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
out.push('install shape: none detected');
|
|
152
|
+
checks = [
|
|
153
|
+
{
|
|
154
|
+
ok: false,
|
|
155
|
+
label: 'no pb-* skills found under <repo>/.claude/skills or ~/.claude/skills',
|
|
156
|
+
fix: repoRoot === null
|
|
157
|
+
? 'global install: npm i -g plumbbob && plumbbob setup --global'
|
|
158
|
+
: 'project install: pnpm add -D plumbbob && pnpm exec plumbbob setup --local',
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
for (const c of checks) {
|
|
163
|
+
out.push(c.ok ? ` ✓ ${c.label}` : ` ✗ ${c.label}\n → ${c.fix}`);
|
|
164
|
+
}
|
|
165
|
+
const failed = checks.filter((c) => !c.ok).length;
|
|
166
|
+
out.push('');
|
|
167
|
+
out.push(failed === 0
|
|
168
|
+
? 'plumbbob: all checks passed. If a skill still misbehaves, restart Claude Code to reload settings.'
|
|
169
|
+
: `plumbbob: ${failed} problem(s) found — apply the → fixes above, then restart Claude Code.`);
|
|
170
|
+
process.stdout.write(`${out.join('\n')}\n`);
|
|
171
|
+
return failed === 0 ? 0 : 1;
|
|
172
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// `plumbbob reset` (D9) — the v2 close-out, replacing the v1
|
|
2
|
+
// wrap → report → docs → finish ceremony. It archives intent + build-log + report
|
|
3
|
+
// (the `/pb-reset` skill writes the report by default) under .plumbbob/archive/,
|
|
4
|
+
// clears the active files, and deletes the control state (STATE last). Unlike v1
|
|
5
|
+
// `finish` there is NO refuse-without-report gate — guidance offers the artifact, it
|
|
6
|
+
// does not wall the exit. Archive-then-clear, never destroy (C4); git untouched (C5).
|
|
7
|
+
import { appendFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
|
|
8
|
+
import { join, relative } from 'node:path';
|
|
9
|
+
import { findRepoRoot } from "../lib/git.js";
|
|
10
|
+
import { buildLogPath, checkpointsPath, hasSession, intentPath, seamPath, sidecarDir, stepPath } from "../lib/sidecar.js";
|
|
11
|
+
import { archiveSession, reportPath } from "../lib/archive.js";
|
|
12
|
+
export function reset(cwd) {
|
|
13
|
+
const root = findRepoRoot(cwd);
|
|
14
|
+
if (root === null || !hasSession(root)) {
|
|
15
|
+
process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
|
|
16
|
+
return 1;
|
|
17
|
+
}
|
|
18
|
+
if (existsSync(reportPath(root))) {
|
|
19
|
+
appendCheckpointShas(root);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
process.stderr.write('plumbbob: note — no report.md found; archiving intent + build-log without one ' +
|
|
23
|
+
'(/pb-reset normally writes the report first). No gate (D9).\n');
|
|
24
|
+
}
|
|
25
|
+
const archived = archiveSession(root);
|
|
26
|
+
// Clear the active files — now safely archived — then the control state, STATE
|
|
27
|
+
// last so "no session" flips exactly at the end.
|
|
28
|
+
rmSync(intentPath(root), { force: true });
|
|
29
|
+
rmSync(buildLogPath(root), { force: true });
|
|
30
|
+
rmSync(reportPath(root), { force: true });
|
|
31
|
+
rmSync(seamPath(root), { force: true });
|
|
32
|
+
rmSync(stepPath(root), { force: true });
|
|
33
|
+
rmSync(join(sidecarDir(root), 'STATE'), { force: true });
|
|
34
|
+
process.stdout.write(`plumbbob: reset — archived to ${relative(root, archived)}. Sidecar cleared. ` +
|
|
35
|
+
'Run `/pb-plan` (or `plumbbob start "<title>"`) to frame the next goal.\n');
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
// Append the recorded checkpoints (baseline + each `step n <sha>`) to the report so
|
|
39
|
+
// the archived report lists the SHAs.
|
|
40
|
+
function appendCheckpointShas(root) {
|
|
41
|
+
let raw = '';
|
|
42
|
+
try {
|
|
43
|
+
raw = readFileSync(checkpointsPath(root), 'utf8');
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
raw = '';
|
|
47
|
+
}
|
|
48
|
+
const lines = raw
|
|
49
|
+
.split('\n')
|
|
50
|
+
.map((l) => l.trim())
|
|
51
|
+
.filter((l) => l.length > 0)
|
|
52
|
+
.map((l) => `- ${l}`);
|
|
53
|
+
appendFileSync(reportPath(root), ['', '## Checkpoints', '', ...lines, ''].join('\n'));
|
|
54
|
+
}
|
package/dist/verbs/setup.js
CHANGED
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
// self-contained (default when plumbbob is a project-local dep; --local /
|
|
4
4
|
// --project to force) — NOTHING is written under ~/.claude. The hooks are
|
|
5
5
|
// referenced in place at $CLAUDE_PROJECT_DIR/node_modules/plumbbob/hooks/,
|
|
6
|
-
// and the skills are copied into <repo>/.claude/skills
|
|
7
|
-
// invocation
|
|
8
|
-
//
|
|
6
|
+
// and the skills are copied into <repo>/.claude/skills/. A skill's bin
|
|
7
|
+
// invocation can NOT use $CLAUDE_PROJECT_DIR — that variable is defined only
|
|
8
|
+
// in Claude Code's hook context and expands EMPTY in a skill's bash — so
|
|
9
|
+
// setup resolves the bin at install time (see selfBin): --local bakes the
|
|
10
|
+
// absolute path to <repo>/node_modules/.bin/plumbbob (personal + untracked,
|
|
11
|
+
// so a machine path is fine); --project uses a bare `plumbbob` (committed +
|
|
12
|
+
// shared, so it stays portable — Claude Code resolves it from the project's
|
|
13
|
+
// node_modules/.bin, which it prepends to PATH). The registration lands in:
|
|
9
14
|
// --local (default) <repo>/.claude/settings.local.json — personal, untracked
|
|
10
15
|
// --project <repo>/.claude/settings.json — committable, enrolls the team
|
|
11
16
|
// This is the "run it from the project root, no global install" shape.
|
|
@@ -24,14 +29,15 @@ import { join, sep } from 'node:path';
|
|
|
24
29
|
import { fileURLToPath } from 'node:url';
|
|
25
30
|
import { findRepoRoot } from "../lib/git.js";
|
|
26
31
|
import { mergeRegistration, readSettings, stripRegistration, writeSettings } from "../lib/settings.js";
|
|
27
|
-
const HOOK_FILES = ['
|
|
32
|
+
const HOOK_FILES = ['post-edit.sh'];
|
|
28
33
|
// The placeholder every skill carries in place of its `plumbbob` invocation;
|
|
29
34
|
// setup substitutes it with the form the chosen install shape resolves.
|
|
30
35
|
const BIN_PLACEHOLDER = '__PLUMBBOB_BIN__';
|
|
31
|
-
//
|
|
32
|
-
//
|
|
36
|
+
// The hook is registered in settings.json, whose `command` runs in Claude Code's
|
|
37
|
+
// hook context — the one place $CLAUDE_PROJECT_DIR is defined — so the portable
|
|
38
|
+
// project-root variable resolves correctly here. (A skill's bash does NOT get
|
|
39
|
+
// that variable; the skill bin is resolved separately, by selfBin.)
|
|
33
40
|
const SELF_HOOKS_DIR = '$CLAUDE_PROJECT_DIR/node_modules/plumbbob/hooks';
|
|
34
|
-
const SELF_BIN = '$CLAUDE_PROJECT_DIR/node_modules/.bin/plumbbob';
|
|
35
41
|
export function setup(cwd, args) {
|
|
36
42
|
const home = process.env.HOME ?? homedir();
|
|
37
43
|
const mode = resolveMode(cwd, home, args);
|
|
@@ -72,20 +78,36 @@ function installGlobal(home, settingsFile) {
|
|
|
72
78
|
// their bin invocation rewritten to the project-local binary.
|
|
73
79
|
function installSelfContained(mode) {
|
|
74
80
|
const skillsDir = join(mode.repoRoot, '.claude', 'skills');
|
|
81
|
+
const bin = selfBin(mode);
|
|
75
82
|
cpSync(packageDir('skills'), skillsDir, { recursive: true });
|
|
76
|
-
substituteSkillBins(skillsDir,
|
|
83
|
+
substituteSkillBins(skillsDir, bin);
|
|
77
84
|
writeSettings(mode.settingsFile, mergeRegistration(readSettings(mode.settingsFile), SELF_HOOKS_DIR, true));
|
|
78
85
|
const note = existsSync(join(mode.repoRoot, 'node_modules', 'plumbbob', 'hooks'))
|
|
79
86
|
? ''
|
|
80
87
|
: 'plumbbob: note — node_modules/plumbbob/ is not present in this repo yet. Add plumbbob as a dependency (e.g. `pnpm add -D plumbbob`) so the registered hooks resolve.\n';
|
|
81
88
|
const scope = mode.settingsFile.endsWith('settings.local.json') ? 'local' : 'project';
|
|
82
|
-
process.stdout.write(`plumbbob: copied skills → ${skillsDir} (bin → ${
|
|
89
|
+
process.stdout.write(`plumbbob: copied skills → ${skillsDir} (bin → ${bin}). Nothing was written under ~/.claude.\n` +
|
|
83
90
|
`plumbbob: referenced the hooks at ${SELF_HOOKS_DIR} and registered them in ${mode.settingsFile} (${scope} scope).\n` +
|
|
84
91
|
note +
|
|
85
92
|
'plumbbob: restart Claude Code (or reload settings) for the hooks to take effect.\n' +
|
|
86
93
|
'plumbbob: drive the workflow from the chat with the `pb-*` driver skills. Keep the transition verbs OUT of your settings allowlist — each driver skill self-authorizes its own verb, so a stray model-initiated transition still surfaces a permission prompt.\n');
|
|
87
94
|
return 0;
|
|
88
95
|
}
|
|
96
|
+
// Resolve the bin string baked into the copied skills. A skill's `!`...``
|
|
97
|
+
// injection and its allowed-tools prefix can NOT rely on $CLAUDE_PROJECT_DIR (a
|
|
98
|
+
// hooks-only variable that expands empty in a skill's bash context), so we
|
|
99
|
+
// resolve at install time. --local is personal and untracked, so bake the
|
|
100
|
+
// absolute path to the project-local binary — fully resolved, no runtime
|
|
101
|
+
// lookup. --project is committed and shared, so a machine-absolute path can't
|
|
102
|
+
// travel; fall back to a bare `plumbbob`, which Claude Code resolves from the
|
|
103
|
+
// project's node_modules/.bin (it prepends that to PATH). A repo path with
|
|
104
|
+
// whitespace can't sit unquoted in the injection or an allowed-tools prefix
|
|
105
|
+
// either, so use the PATH-resolved bare form there too.
|
|
106
|
+
function selfBin(mode) {
|
|
107
|
+
const committed = !mode.settingsFile.endsWith('settings.local.json');
|
|
108
|
+
const abs = join(mode.repoRoot, 'node_modules', '.bin', 'plumbbob');
|
|
109
|
+
return committed || /\s/.test(abs) ? 'plumbbob' : abs;
|
|
110
|
+
}
|
|
89
111
|
// Resolve which install shape and settings file to use. Explicit flags win;
|
|
90
112
|
// with no flag, auto-detect — a project-local plumbbob installs self-contained
|
|
91
113
|
// (personal, into settings.local.json), anything else installs global. Returns
|
package/dist/verbs/status.js
CHANGED
|
@@ -1,13 +1,32 @@
|
|
|
1
|
-
// `plumbbob status` —
|
|
2
|
-
// always exits 0. Skills pre-inject this output to gate their own
|
|
1
|
+
// `plumbbob status` — the orientation dashboard (D8/D15), or NO ACTIVE SESSION.
|
|
2
|
+
// Read-only, always exits 0. Skills pre-inject this output to gate their own
|
|
3
|
+
// behavior, so the `NO ACTIVE SESSION` sentinel is kept exact.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
3
5
|
import { findRepoRoot } from "../lib/git.js";
|
|
4
|
-
import { hasSession, readState } from "../lib/sidecar.js";
|
|
6
|
+
import { buildLogPath, checkpointsPath, hasSession, intentPath, readState, stepPath } from "../lib/sidecar.js";
|
|
7
|
+
import { formatOrientation, orient } from "../lib/orient.js";
|
|
8
|
+
function readOr(path) {
|
|
9
|
+
try {
|
|
10
|
+
return readFileSync(path, 'utf8');
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return '';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
5
16
|
export function status(cwd) {
|
|
6
17
|
const root = findRepoRoot(cwd);
|
|
7
18
|
if (root === null || !hasSession(root)) {
|
|
8
19
|
process.stdout.write('NO ACTIVE SESSION\n');
|
|
9
20
|
return 0;
|
|
10
21
|
}
|
|
11
|
-
|
|
22
|
+
const inFlightRaw = readOr(stepPath(root)).trim();
|
|
23
|
+
const orientation = orient({
|
|
24
|
+
state: readState(root) ?? 'UNKNOWN',
|
|
25
|
+
intent: readOr(intentPath(root)),
|
|
26
|
+
buildLog: readOr(buildLogPath(root)),
|
|
27
|
+
checkpoints: readOr(checkpointsPath(root)),
|
|
28
|
+
inFlight: /^\d+$/.test(inFlightRaw) ? Number(inFlightRaw) : null,
|
|
29
|
+
});
|
|
30
|
+
process.stdout.write(`${formatOrientation(orientation)}\n`);
|
|
12
31
|
return 0;
|
|
13
32
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plumbbob",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Attention-first build process:
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Attention-first build process: eight skills + a CLI that keep you the decider — guidance, not enforcement.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
7
7
|
"claude",
|
package/skills/pb-build/SKILL.md
CHANGED
|
@@ -1,19 +1,49 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pb-build
|
|
3
|
-
description:
|
|
3
|
+
description: The optional engine — read the current planned step from intent, implement it (its done-when, seam, Decisions, Constraints), then verify it through to the approval pause. Skip it entirely to implement by hand, vibed, or with another harness.
|
|
4
4
|
disable-model-invocation: true
|
|
5
|
-
model:
|
|
6
|
-
allowed-tools: Bash(__PLUMBBOB_BIN__ status:*), Bash(__PLUMBBOB_BIN__ build:*)
|
|
5
|
+
model: opus
|
|
6
|
+
allowed-tools: Read, Edit, Write, Bash(__PLUMBBOB_BIN__ status:*), Bash(__PLUMBBOB_BIN__ build:*), Bash(__PLUMBBOB_BIN__ check:*), Bash(__PLUMBBOB_BIN__ checkpoint:*), Bash(git diff:*)
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
# Plumbbob — build a step (
|
|
9
|
+
# Plumbbob — build a step (the optional engine)
|
|
10
10
|
|
|
11
|
-
Current session state (injected when this skill runs): !`__PLUMBBOB_BIN__ status`
|
|
11
|
+
Current session state (injected when this skill runs): !`__PLUMBBOB_BIN__ status 2>/dev/null || echo "plumbbob CLI not found - install the dep and re-run: npx plumbbob setup"`
|
|
12
12
|
|
|
13
|
-
This is
|
|
13
|
+
This is the **bundled executor** — one way to turn a planned step into code. It is
|
|
14
|
+
**optional** (D3): you can implement any step by hand, in a vibe session, or with
|
|
15
|
+
another harness and go straight to `/pb-verify` instead — plumbbob does not care how
|
|
16
|
+
the diff appeared. When you do run it, it reads the plan, writes the step, and
|
|
17
|
+
carries straight through to the verify pause.
|
|
14
18
|
|
|
15
|
-
## What
|
|
19
|
+
## What this skill does, in order
|
|
16
20
|
|
|
17
|
-
1.
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
1. **Pick the step.** Use the number you were invoked with (e.g. `/pb-build 4`), else
|
|
22
|
+
the next undone, planned step in `.plumbbob/intent.md`. If there is no planned step
|
|
23
|
+
to build, stop and tell the human to `/pb-step` first.
|
|
24
|
+
2. **Enter the step.** Run `__PLUMBBOB_BIN__ build <n>` (records the in-flight STEP +
|
|
25
|
+
SEAM so `/pb-status` shows the step in flight; in v2 the seam is awareness, not a
|
|
26
|
+
lock).
|
|
27
|
+
3. **Read the plan.** Read the step's **done-when**, its **seam**, and the
|
|
28
|
+
**Decisions** and **Constraints** in `intent.md`. Build to *that* — the deciding
|
|
29
|
+
already happened, off the chat.
|
|
30
|
+
4. **Implement** the step, and only that step, staying within the declared seam. A
|
|
31
|
+
new problem or "ooh what if" that surfaces mid-build is a `/pb-park`, **not** an
|
|
32
|
+
edit — capture it and stay on the step. If you genuinely cannot finish without
|
|
33
|
+
touching more than the seam, that is scope drift: surface it to the human rather
|
|
34
|
+
than sprawling.
|
|
35
|
+
5. **Verify, through to the pause.** Run the verify tick exactly as `/pb-verify`
|
|
36
|
+
does: `__PLUMBBOB_BIN__ check` → self-review the diff against the done-when, the
|
|
37
|
+
Decisions, and the Constraints (a single structured read, D16) → validate → **PAUSE
|
|
38
|
+
for the human's approval** → only on approval, checkpoint with
|
|
39
|
+
`__PLUMBBOB_BIN__ checkpoint`. Do **not** bump the version or changelog — that is
|
|
40
|
+
the human's `/version` call.
|
|
41
|
+
|
|
42
|
+
## The hard contracts
|
|
43
|
+
|
|
44
|
+
- **Optional, never required.** The loop works without this skill; `/pb-verify`
|
|
45
|
+
checkpoints a hand-built or vibed diff just the same (D3).
|
|
46
|
+
- **Build the decided step, not a new one.** Implement what `intent.md` settled. A
|
|
47
|
+
new idea mid-build is a `/pb-park`, not an edit.
|
|
48
|
+
- **Always end at the pause.** Implement → verify → wait for approval. Never
|
|
49
|
+
checkpoint without it; the human is the clock.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pb-harvest
|
|
3
|
+
description: Triage the park list at a step boundary — propose one class (blocker/tangent/pivot) per parked item, write only after the human confirms each, record under ## Harvest, and fold a confirmed blocker into intent.
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
model: opus
|
|
6
|
+
allowed-tools: Read, Edit, Bash(__PLUMBBOB_BIN__ status:*)
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Plumbbob — harvest the park list
|
|
10
|
+
|
|
11
|
+
Current session state (injected when this skill runs): !`__PLUMBBOB_BIN__ status 2>/dev/null || echo "plumbbob CLI not found - install the dep and re-run: npx plumbbob setup"`
|
|
12
|
+
|
|
13
|
+
`/pb-harvest` is the complement of `/pb-park` (D7): you parked ideas as seeds during a
|
|
14
|
+
build; now, at a boundary, you harvest them — decide what each one is.
|
|
15
|
+
|
|
16
|
+
## When to run it — wrong-state refusal
|
|
17
|
+
|
|
18
|
+
Harvest at a **step boundary**: after a step is checkpointed and you are back in
|
|
19
|
+
DESIGN, not mid-step. Read the state injected above:
|
|
20
|
+
|
|
21
|
+
- `NO ACTIVE SESSION` — **refuse**; tell the human to `plumbbob start "<title>"` first.
|
|
22
|
+
- `BUILD` — a step is in flight; **refuse** and suggest finishing it with `/pb-verify`
|
|
23
|
+
before harvesting. Chasing parked items mid-step is the disease parking prevents.
|
|
24
|
+
- `DESIGN` (and other settled states) — go ahead.
|
|
25
|
+
|
|
26
|
+
## What this skill does
|
|
27
|
+
|
|
28
|
+
Walk the **Park list** in `build-log.md` item by item and, for each, **propose exactly
|
|
29
|
+
one class**:
|
|
30
|
+
|
|
31
|
+
- **blocker** — the plan was wrong or incomplete; can't proceed. Folds into `intent.md`
|
|
32
|
+
and is handled now.
|
|
33
|
+
- **tangent** — a different path, not clearly better. **The default** — defer or kill.
|
|
34
|
+
- **pivot signal** — real evidence the whole approach is wrong. Stop and replan.
|
|
35
|
+
|
|
36
|
+
## The one hard contract
|
|
37
|
+
|
|
38
|
+
You **propose**; the **human calls it**. For each item, state your proposed class and
|
|
39
|
+
one line of reasoning, then **wait for the human to confirm or override**. Write
|
|
40
|
+
**only after** per-item confirmation:
|
|
41
|
+
|
|
42
|
+
- Record each confirmed class in `build-log.md`'s `## Harvest` section.
|
|
43
|
+
- **Flip the harvested item** from `- [ ]` to `- [x]` in the Park list, so `/pb-status`
|
|
44
|
+
stops counting it as open.
|
|
45
|
+
- A confirmed **blocker** also folds its decision into `intent.md`.
|
|
46
|
+
- Never reclassify or resolve an item the human hasn't confirmed, and default every
|
|
47
|
+
uncertain item to **tangent**, never to blocker.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pb-park
|
|
3
|
+
description: Compose one tidy tagged park line, get the human's OK in-turn, then capture it by shelling `plumbbob park` — never by editing a file. The capture half of the park/harvest loop.
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
model: haiku
|
|
6
|
+
allowed-tools: Bash(__PLUMBBOB_BIN__ status:*), Bash(__PLUMBBOB_BIN__ park:*)
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Plumbbob — park an idea (capture, don't chase)
|
|
10
|
+
|
|
11
|
+
Current session state (injected when this skill runs): !`__PLUMBBOB_BIN__ status 2>/dev/null || echo "plumbbob CLI not found - install the dep and re-run: npx plumbbob setup"`
|
|
12
|
+
|
|
13
|
+
`/pb-park` is the **capture** half of the loop; `/pb-harvest` is where parked items
|
|
14
|
+
get triaged later (D7). Capturing the instant an idea arrives — instead of acting on
|
|
15
|
+
it — is the whole point: it protects the step in flight.
|
|
16
|
+
|
|
17
|
+
## Wrong-state refusal
|
|
18
|
+
|
|
19
|
+
Parking needs an **active session** to capture into. Read the state injected above: if
|
|
20
|
+
it is `NO ACTIVE SESSION`, **refuse** and tell the human to run `plumbbob start
|
|
21
|
+
"<title>"` first. Every active state (`DESIGN`, `BUILD`, `SPIKE`, `FINISH`) is fine —
|
|
22
|
+
capture is always available, which is the whole point of parking.
|
|
23
|
+
|
|
24
|
+
## What this skill does
|
|
25
|
+
|
|
26
|
+
Take the idea, problem, or "ooh what if" the human just had and **compose it into one
|
|
27
|
+
tidy, tagged line** — short, legible, self-contained, so it still reads cleanly weeks
|
|
28
|
+
later. Then:
|
|
29
|
+
|
|
30
|
+
1. **Show the composed line to the human** and wait for **in-turn approval** — they
|
|
31
|
+
confirm it as-is or edit the wording.
|
|
32
|
+
2. **Only after** that approval, capture it by running `__PLUMBBOB_BIN__ park "<the
|
|
33
|
+
approved line>"` via Bash.
|
|
34
|
+
|
|
35
|
+
## The one hard contract
|
|
36
|
+
|
|
37
|
+
The capture itself is the **dumb CLI**, never an edit. This skill carries **no Edit and
|
|
38
|
+
no Write tool** on purpose: you may not append to `build-log.md` yourself. Compose, get
|
|
39
|
+
approval, then shell `__PLUMBBOB_BIN__ park` — that is the only write path. If approval
|
|
40
|
+
never comes, capture nothing.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pb-plan
|
|
3
|
+
description: Frame a fresh goal — scaffold the session and author the intent's Frame, Decisions, and Constraints before any code. Steps stay empty; they come one at a time from /pb-step.
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
model: opus
|
|
6
|
+
allowed-tools: Read, Edit, Write, Bash(__PLUMBBOB_BIN__ status:*), Bash(__PLUMBBOB_BIN__ start:*)
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Plumbbob — frame a goal (the whole-goal move)
|
|
10
|
+
|
|
11
|
+
Current session state (injected when this skill runs): !`__PLUMBBOB_BIN__ status 2>/dev/null || echo "plumbbob CLI not found - install the dep and re-run: npx plumbbob setup"`
|
|
12
|
+
|
|
13
|
+
`/pb-plan` is the **whole-goal** move — it opens a session and gets the deciding out
|
|
14
|
+
of your head and onto `intent.md` *before* any code. (Planning a single increment is
|
|
15
|
+
the separate `/pb-step` move; do not confuse the two.)
|
|
16
|
+
|
|
17
|
+
## What this skill does
|
|
18
|
+
|
|
19
|
+
1. **Scaffold.** If there is no active session, run `__PLUMBBOB_BIN__ start "<title>"`
|
|
20
|
+
to create `.plumbbob/` (STATE=DESIGN, baseline recorded). If a session already
|
|
21
|
+
exists, say so and edit the existing `intent.md` rather than starting over.
|
|
22
|
+
2. **Frame** (`.plumbbob/intent.md`), with the human: the **Problem** in plain words,
|
|
23
|
+
the **smallest thing** that solves it, what **done looks like**, and what you are
|
|
24
|
+
**explicitly NOT doing**. This is the human's convergence — propose wording, but
|
|
25
|
+
the human decides every line.
|
|
26
|
+
3. **Decisions & Constraints.** Record the settled calls (one line each, with the
|
|
27
|
+
*because*) and the hard rules the build must honor. An unresolved hole goes to
|
|
28
|
+
**Open questions**, never guessed into a Decision.
|
|
29
|
+
4. **Leave `## Steps` empty.** Steps are planned just-in-time (D6) — one at a time
|
|
30
|
+
with `/pb-step` — so do not write the build plan here.
|
|
31
|
+
|
|
32
|
+
## The hard contracts
|
|
33
|
+
|
|
34
|
+
- **Deciding before code.** `/pb-plan` writes `intent.md` only — never source.
|
|
35
|
+
- **The human converges.** You surface options and draft wording; the human picks.
|
|
36
|
+
An unresolved hole is an Open question, not a guessed Decision.
|
|
37
|
+
- **Size to the work.** A small change fills Frame + a couple of Decisions and stops;
|
|
38
|
+
ceremony on a one-liner is the failure mode, not thoroughness.
|