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.
Files changed (43) hide show
  1. package/README.md +69 -141
  2. package/dist/cli.js +22 -37
  3. package/dist/lib/archive.js +6 -3
  4. package/dist/lib/git.js +0 -4
  5. package/dist/lib/intent.js +1 -1
  6. package/dist/lib/orient.js +160 -0
  7. package/dist/lib/settings.js +12 -8
  8. package/dist/lib/sidecar.js +0 -3
  9. package/dist/verbs/build.js +1 -1
  10. package/dist/verbs/check.js +17 -0
  11. package/dist/verbs/checkpoint.js +83 -0
  12. package/dist/verbs/doctor.js +172 -0
  13. package/dist/verbs/reset.js +54 -0
  14. package/dist/verbs/setup.js +31 -9
  15. package/dist/verbs/status.js +23 -4
  16. package/package.json +2 -2
  17. package/skills/pb-build/SKILL.md +40 -10
  18. package/skills/pb-harvest/SKILL.md +47 -0
  19. package/skills/pb-park/SKILL.md +40 -0
  20. package/skills/pb-plan/SKILL.md +38 -0
  21. package/skills/pb-reset/SKILL.md +39 -0
  22. package/skills/pb-revert/SKILL.md +1 -1
  23. package/skills/pb-spike/SKILL.md +1 -1
  24. package/skills/pb-status/SKILL.md +22 -0
  25. package/skills/pb-step/SKILL.md +37 -0
  26. package/skills/pb-verify/SKILL.md +47 -0
  27. package/skills/plumbbob-interrogate/SKILL.md +1 -1
  28. package/dist/verbs/done.js +0 -63
  29. package/dist/verbs/finish.js +0 -54
  30. package/dist/verbs/mode.js +0 -26
  31. package/dist/verbs/review.js +0 -24
  32. package/dist/verbs/wrap.js +0 -28
  33. package/hooks/bash-guard.sh +0 -62
  34. package/hooks/pre-edit.sh +0 -112
  35. package/skills/park/SKILL.md +0 -26
  36. package/skills/pb-done/SKILL.md +0 -18
  37. package/skills/pb-finish/SKILL.md +0 -18
  38. package/skills/pb-review/SKILL.md +0 -18
  39. package/skills/pb-start/SKILL.md +0 -19
  40. package/skills/pb-wrap/SKILL.md +0 -18
  41. package/skills/plumbbob-docs/SKILL.md +0 -25
  42. package/skills/plumbbob-report/SKILL.md +0 -35
  43. 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
+ }
@@ -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/ with their bin
7
- // invocation pointed at $CLAUDE_PROJECT_DIR/node_modules/.bin/plumbbob. The
8
- // registration lands in:
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 = ['pre-edit.sh', 'bash-guard.sh', 'post-edit.sh'];
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
- // Self-contained installs address everything through Claude Code's project-root
32
- // variable so committed/portable files carry no machine-absolute path.
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, SELF_BIN);
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 → ${SELF_BIN}). Nothing was written under ~/.claude.\n` +
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
@@ -1,13 +1,32 @@
1
- // `plumbbob status` — print the session state, or NO ACTIVE SESSION. Read-only,
2
- // always exits 0. Skills pre-inject this output to gate their own behavior.
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
- process.stdout.write(`STATE: ${readState(root) ?? 'UNKNOWN'}\n`);
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.2.3",
4
- "description": "Attention-first build process: a CLI + hooks that enforce the deciding/executing boundary.",
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",
@@ -1,19 +1,49 @@
1
1
  ---
2
2
  name: pb-build
3
- description: Human-triggered driver for `plumbbob build <n>` write the SEAM for a step and enter BUILD (edits unlocked to those paths only), from the chat.
3
+ description: The optional engineread 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: haiku
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 (driver)
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 a **driver skill** — a chat-side trigger for the mechanical `plumbbob build` verb, so the whole workflow runs from the agent window instead of a terminal. It is `disable-model-invocation: true`: only the human fires it. It carries **no Edit and no Write tool** — its only action is to shell the verb and report the verb's output verbatim, including any refusal. The CLI is the source of truth: never retry a refused transition, and never edit a file to work around one.
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 it does
19
+ ## What this skill does, in order
16
20
 
17
- 1. Read the step number from the way you were invoked (e.g. `/pb-build 3` → step `3`). If no number is present, ask for one and run nothing.
18
- 2. Run `__PLUMBBOB_BIN__ build <n>` via Bash.
19
- 3. Report the verb's output verbatim — the SEAM it wrote and the new state, or any refusal.
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.