plumbbob 0.1.3 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +69 -26
  2. package/dist/cli.js +99 -0
  3. package/dist/lib/archive.js +69 -0
  4. package/dist/lib/check.js +26 -0
  5. package/dist/lib/git.js +62 -0
  6. package/dist/lib/intent.js +88 -0
  7. package/dist/lib/settings.js +79 -0
  8. package/dist/lib/sidecar.js +69 -0
  9. package/dist/verbs/build.js +30 -0
  10. package/dist/verbs/done.js +63 -0
  11. package/dist/verbs/finish.js +54 -0
  12. package/dist/verbs/mode.js +26 -0
  13. package/dist/verbs/park.js +51 -0
  14. package/dist/verbs/revert.js +136 -0
  15. package/dist/verbs/review.js +24 -0
  16. package/dist/verbs/setup.js +141 -0
  17. package/dist/verbs/spike.js +113 -0
  18. package/dist/verbs/start.js +68 -0
  19. package/dist/verbs/status.js +13 -0
  20. package/dist/verbs/wrap.js +28 -0
  21. package/hooks/bash-guard.sh +5 -1
  22. package/hooks/pre-edit.sh +17 -0
  23. package/package.json +6 -4
  24. package/skills/park/SKILL.md +4 -4
  25. package/skills/pb-build/SKILL.md +19 -0
  26. package/skills/pb-done/SKILL.md +18 -0
  27. package/skills/pb-finish/SKILL.md +18 -0
  28. package/skills/pb-revert/SKILL.md +19 -0
  29. package/skills/pb-review/SKILL.md +18 -0
  30. package/skills/pb-spike/SKILL.md +19 -0
  31. package/skills/pb-start/SKILL.md +19 -0
  32. package/skills/pb-wrap/SKILL.md +18 -0
  33. package/skills/plumbbob-docs/SKILL.md +2 -2
  34. package/skills/plumbbob-interrogate/SKILL.md +2 -2
  35. package/skills/plumbbob-report/SKILL.md +2 -2
  36. package/skills/plumbbob-triage/SKILL.md +2 -2
  37. package/src/cli.ts +0 -121
  38. package/src/lib/archive.ts +0 -76
  39. package/src/lib/check.ts +0 -29
  40. package/src/lib/git.ts +0 -73
  41. package/src/lib/intent.ts +0 -103
  42. package/src/lib/settings.ts +0 -90
  43. package/src/lib/sidecar.ts +0 -82
  44. package/src/verbs/build.ts +0 -38
  45. package/src/verbs/done.ts +0 -71
  46. package/src/verbs/finish.ts +0 -72
  47. package/src/verbs/mode.ts +0 -27
  48. package/src/verbs/park.ts +0 -56
  49. package/src/verbs/revert.ts +0 -110
  50. package/src/verbs/review.ts +0 -30
  51. package/src/verbs/setup.ts +0 -95
  52. package/src/verbs/spike.ts +0 -129
  53. package/src/verbs/start.ts +0 -103
  54. package/src/verbs/status.ts +0 -15
  55. package/src/verbs/wrap.ts +0 -34
@@ -0,0 +1,30 @@
1
+ // `plumbbob build <n>` — read step n's seam from intent.md, write the normalized
2
+ // SEAM + STEP, and enter BUILD. Re-entering from REVIEW just re-derives the same
3
+ // seam and flips back to BUILD; it never checkpoints (only `done` commits).
4
+ import { readFileSync, writeFileSync } from 'node:fs';
5
+ import { findRepoRoot } from "../lib/git.js";
6
+ import { hasSession, intentPath, seamPath, stepPath, writeState } from "../lib/sidecar.js";
7
+ import { parseStepSeam } from "../lib/intent.js";
8
+ export function build(cwd, args) {
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 raw = args.find((a) => !a.startsWith('--'));
15
+ if (raw === undefined || !/^\d+$/.test(raw) || Number(raw) < 1) {
16
+ process.stderr.write('plumbbob: build needs a step number. Try: plumbbob build 2.\n');
17
+ return 1;
18
+ }
19
+ const step = Number(raw);
20
+ const parsed = parseStepSeam(readFileSync(intentPath(root), 'utf8'), step);
21
+ if (!parsed.ok) {
22
+ process.stderr.write(`plumbbob: ${parsed.error} Fix the step's seam in intent.md, then \`build ${step}\` again.\n`);
23
+ return 1;
24
+ }
25
+ writeFileSync(seamPath(root), `${parsed.seam.join('\n')}\n`);
26
+ writeFileSync(stepPath(root), `${step}\n`);
27
+ writeState(root, 'BUILD');
28
+ process.stdout.write(`plumbbob: building step ${step} — STATE=BUILD. Edits are limited to the seam:\n${parsed.seam.map((p) => ` ${p}`).join('\n')}\n`);
29
+ return 0;
30
+ }
@@ -0,0 +1,63 @@
1
+ // `plumbbob done` — the step gate. Refuses on a red check, then stages the whole
2
+ // step (D8: `git add -A`; the sidecar is git-excluded so it never lands), warns
3
+ // about anything committed outside the SEAM, takes the checkpoint commit, records
4
+ // its SHA, and returns to DESIGN.
5
+ import { appendFileSync, readFileSync, rmSync } from 'node:fs';
6
+ import { commit, findRepoRoot, stageAll, stagedPaths } from "../lib/git.js";
7
+ import { checkpointsPath, hasSession, readState, seamPath, stepPath, writeState } from "../lib/sidecar.js";
8
+ import { runCheck } from "../lib/check.js";
9
+ import { matchesSeam } from "../lib/intent.js";
10
+ export function done(cwd) {
11
+ const root = findRepoRoot(cwd);
12
+ if (root === null || !hasSession(root)) {
13
+ process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
14
+ return 1;
15
+ }
16
+ const state = readState(root);
17
+ if (state !== 'BUILD' && state !== 'REVIEW') {
18
+ process.stderr.write(`plumbbob: done runs from BUILD or REVIEW (current state is ${state ?? 'UNKNOWN'}). Run \`plumbbob build <n>\` first.\n`);
19
+ return 1;
20
+ }
21
+ const step = readStepNumber(root);
22
+ if (step === null) {
23
+ process.stderr.write('plumbbob: no in-flight step — run `plumbbob build <n>` first.\n');
24
+ return 1;
25
+ }
26
+ if (runCheck(root) !== 0) {
27
+ process.stderr.write('plumbbob: check failed (red) — done refuses on red. Fix it and re-run `done`.\n');
28
+ return 1;
29
+ }
30
+ stageAll(root);
31
+ const seam = readSeamTokens(root);
32
+ const outside = stagedPaths(root).filter((p) => !matchesSeam(p, seam));
33
+ if (outside.length > 0) {
34
+ process.stderr.write(`plumbbob: WARNING committed paths outside the SEAM: ${outside.join(', ')}. The checkpoint captures them, but scope drift may mean the plan needs revising.\n`);
35
+ }
36
+ const sha = commit(root, `plumbbob: step ${step} done`);
37
+ appendFileSync(checkpointsPath(root), `step ${step} ${sha}\n`);
38
+ rmSync(seamPath(root), { force: true });
39
+ rmSync(stepPath(root), { force: true });
40
+ writeState(root, 'DESIGN');
41
+ process.stdout.write(`plumbbob: step ${step} done — checkpoint ${sha.slice(0, 9)}. STATE=DESIGN.\n`);
42
+ return 0;
43
+ }
44
+ function readStepNumber(root) {
45
+ try {
46
+ const raw = readFileSync(stepPath(root), 'utf8').trim();
47
+ return /^\d+$/.test(raw) ? Number(raw) : null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ function readSeamTokens(root) {
54
+ try {
55
+ return readFileSync(seamPath(root), 'utf8')
56
+ .split('\n')
57
+ .map((l) => l.trim())
58
+ .filter((l) => l.length > 0);
59
+ }
60
+ catch {
61
+ return [];
62
+ }
63
+ }
@@ -0,0 +1,54 @@
1
+ // `plumbbob finish` (D19/D20) — the closing gate, symmetric with the step gate.
2
+ // Refuses unless a report exists, appends the checkpoint SHA list to it, archives
3
+ // intent + build-log + report under .plumbbob/archive/, clears the active files,
4
+ // and deletes SEAM, STEP, then STATE LAST (deleting STATE is what switches the
5
+ // muzzle off, so it happens exactly at session end). Never touches git (C5).
6
+ import { appendFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
7
+ import { join, relative } from 'node:path';
8
+ import { findRepoRoot } from "../lib/git.js";
9
+ import { buildLogPath, checkpointsPath, hasSession, intentPath, seamPath, sidecarDir, stepPath, } from "../lib/sidecar.js";
10
+ import { archiveSession, reportPath } from "../lib/archive.js";
11
+ export function finish(cwd) {
12
+ const root = findRepoRoot(cwd);
13
+ if (root === null || !hasSession(root)) {
14
+ process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
15
+ return 1;
16
+ }
17
+ if (!existsSync(reportPath(root))) {
18
+ process.stderr.write('plumbbob: finish refuses without a report — run `/plumbbob-report` first (it writes .plumbbob/report.md). ' +
19
+ 'The closing gate is symmetric with the step gate: you do not walk away without capturing what happened.\n');
20
+ return 1;
21
+ }
22
+ appendCheckpointShas(root);
23
+ const archived = archiveSession(root);
24
+ // Clear the active files — now safely archived.
25
+ rmSync(intentPath(root), { force: true });
26
+ rmSync(buildLogPath(root), { force: true });
27
+ rmSync(reportPath(root), { force: true });
28
+ // Delete the control files LAST, STATE the very last: while STATE exists the
29
+ // muzzle is live, so it comes off only once everything else is torn down.
30
+ rmSync(seamPath(root), { force: true });
31
+ rmSync(stepPath(root), { force: true });
32
+ rmSync(join(sidecarDir(root), 'STATE'), { force: true });
33
+ process.stdout.write(`plumbbob: finished — archived to ${relative(root, archived)}. STATE cleared (muzzle off). ` +
34
+ 'Run `plumbbob start "<title>"` to begin the next task.\n');
35
+ return 0;
36
+ }
37
+ // Append the recorded checkpoints (baseline + each `step n <sha>`) to the report,
38
+ // so the archived report lists the SHAs (spec: "finish lists the SHAs in the
39
+ // report").
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
+ }
@@ -0,0 +1,26 @@
1
+ // `plumbbob mode <x>` — the hidden escape hatch: set STATE directly when reality
2
+ // and the machine desync. Not part of the normal flow; the lone human-only verb,
3
+ // so the dispatch refuses it under CLAUDECODE and bash-guard blocks it from the
4
+ // model's shell (D21 revised).
5
+ import { findRepoRoot } from "../lib/git.js";
6
+ import { hasSession, readState, writeState, VALID_STATES } from "../lib/sidecar.js";
7
+ export function mode(cwd, args) {
8
+ const root = findRepoRoot(cwd);
9
+ if (root === null || !hasSession(root)) {
10
+ process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
11
+ return 1;
12
+ }
13
+ const target = args[0];
14
+ if (target === undefined) {
15
+ process.stderr.write(`plumbbob: mode needs a state — one of: ${VALID_STATES.join(', ')}.\n`);
16
+ return 1;
17
+ }
18
+ if (!VALID_STATES.includes(target)) {
19
+ process.stderr.write(`plumbbob: '${target}' is not a valid state — one of: ${VALID_STATES.join(', ')}.\n`);
20
+ return 1;
21
+ }
22
+ const previous = readState(root) ?? 'UNKNOWN';
23
+ writeState(root, target);
24
+ process.stdout.write(`STATE: ${previous} -> ${target} (escape hatch — prefer the normal verbs when you can).\n`);
25
+ return 0;
26
+ }
@@ -0,0 +1,51 @@
1
+ // `plumbbob park "<text>"` — the dumb capture path: append one raw line under
2
+ // the build-log's Park list. No model turn, no composition (that is /park's job).
3
+ // Capture is not a transition, so it runs in any context (terminal or in-session).
4
+ import { readFileSync, writeFileSync } from 'node:fs';
5
+ import { findRepoRoot } from "../lib/git.js";
6
+ import { hasSession, buildLogPath } from "../lib/sidecar.js";
7
+ export function park(cwd, args) {
8
+ const root = findRepoRoot(cwd);
9
+ if (root === null || !hasSession(root)) {
10
+ process.stderr.write('plumbbob: no active session — nothing to park to. Run `plumbbob start "<title>"` first.\n');
11
+ return 1;
12
+ }
13
+ const text = args
14
+ .filter((a) => !a.startsWith('--'))
15
+ .join(' ')
16
+ .trim();
17
+ if (text.length === 0) {
18
+ process.stderr.write('plumbbob: park needs text. Try: plumbbob park "the idea you do not want to chase right now".\n');
19
+ return 1;
20
+ }
21
+ const path = buildLogPath(root);
22
+ const updated = insertParkItem(readFileSync(path, 'utf8'), text);
23
+ if (updated === null) {
24
+ process.stderr.write('plumbbob: could not find a "## Park list" section in build-log.md.\n');
25
+ return 1;
26
+ }
27
+ writeFileSync(path, updated);
28
+ process.stdout.write(`parked: ${text}\n`);
29
+ return 0;
30
+ }
31
+ // Append after the last non-blank line of the Park list section (i.e. just before
32
+ // the next `## ` heading). Returns null if there is no Park list to append to.
33
+ function insertParkItem(content, text) {
34
+ const lines = content.split('\n');
35
+ const headingIdx = lines.findIndex((line) => line.trim() === '## Park list');
36
+ if (headingIdx === -1) {
37
+ return null;
38
+ }
39
+ let nextSection = lines.findIndex((line, i) => i > headingIdx && line.startsWith('## '));
40
+ if (nextSection === -1) {
41
+ nextSection = lines.length;
42
+ }
43
+ let insertAt = headingIdx + 1;
44
+ for (let i = headingIdx + 1; i < nextSection; i++) {
45
+ if ((lines[i] ?? '').trim() !== '') {
46
+ insertAt = i + 1;
47
+ }
48
+ }
49
+ lines.splice(insertAt, 0, `- [ ] ${text}`);
50
+ return lines.join('\n');
51
+ }
@@ -0,0 +1,136 @@
1
+ // `plumbbob revert [--to n]` — git reset --hard to a checkpoint SHA (the most
2
+ // recent step, or `--to n`, with the baseline as fallback), then remove untracked
3
+ // files under the SEAM only. The sidecar is git-excluded (D17), so the reset
4
+ // never touches it — park lines and intent edits survive the revert (C4).
5
+ //
6
+ // Plumbbob also installs its driver skills INTO the repo (.claude/skills/<driver>/
7
+ // for a self-contained install), so a blunt reset would discard an out-of-seam
8
+ // skill edit — or a `pnpm up plumbbob` re-setup — together with the half-done
9
+ // step. revert discards the step's WORK, never plumbbob's own machinery, so those
10
+ // paths are carried across the reset unchanged.
11
+ import { cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { findRepoRoot, resetHard, untrackedPaths } from "../lib/git.js";
16
+ import { checkpointsPath, hasSession, seamPath, sidecarDir, stepPath, writeState } from "../lib/sidecar.js";
17
+ import { matchesSeam } from "../lib/intent.js";
18
+ export function revert(cwd, args) {
19
+ const root = findRepoRoot(cwd);
20
+ if (root === null || !hasSession(root)) {
21
+ process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
22
+ return 1;
23
+ }
24
+ const to = parseTo(args);
25
+ if (to === 'invalid') {
26
+ process.stderr.write('plumbbob: revert --to needs a step number. Try: plumbbob revert --to 2.\n');
27
+ return 1;
28
+ }
29
+ const checkpoints = readCheckpoints(root);
30
+ let sha;
31
+ if (to === null) {
32
+ sha = checkpoints.steps.at(-1)?.sha ?? checkpoints.baseline;
33
+ }
34
+ else {
35
+ const entry = checkpoints.steps.find((e) => e.n === to);
36
+ if (entry === undefined) {
37
+ process.stderr.write(`plumbbob: no checkpoint recorded for step ${to}.\n`);
38
+ return 1;
39
+ }
40
+ sha = entry.sha;
41
+ }
42
+ if (sha === undefined) {
43
+ process.stderr.write('plumbbob: no baseline recorded in checkpoints — cannot revert.\n');
44
+ return 1;
45
+ }
46
+ // Compute untracked-in-seam BEFORE the reset (reset --hard leaves untracked and
47
+ // ignored files alone, so they must be removed explicitly afterward).
48
+ const seam = readSeamTokens(root);
49
+ const toRemove = untrackedPaths(root).filter((p) => matchesSeam(p, seam));
50
+ resetPreserving(root, sha, plumbbobOwnedPaths(root));
51
+ for (const rel of toRemove) {
52
+ rmSync(join(root, rel), { force: true, recursive: true });
53
+ }
54
+ rmSync(seamPath(root), { force: true });
55
+ rmSync(stepPath(root), { force: true });
56
+ writeState(root, 'DESIGN');
57
+ process.stdout.write(`plumbbob: reverted to ${sha.slice(0, 9)} — STATE=DESIGN. Park lines and intent edits were preserved.\n`);
58
+ return 0;
59
+ }
60
+ // The repo paths that belong to plumbbob, not to the work being reverted: the
61
+ // sidecar (already git-excluded, listed so revert is robust even where `.plumbbob/`
62
+ // was tracked by mistake) and each installed driver skill under .claude/skills/.
63
+ // Skill names come from plumbbob's own bundled `skills/` dir — the same source
64
+ // `setup` copies from — so only plumbbob's own skills are protected, never the
65
+ // user's. Only paths that currently exist are returned.
66
+ function plumbbobOwnedPaths(root) {
67
+ const paths = [sidecarDir(root)];
68
+ try {
69
+ for (const name of readdirSync(fileURLToPath(new URL('../../skills', import.meta.url)))) {
70
+ paths.push(join(root, '.claude', 'skills', name));
71
+ }
72
+ }
73
+ catch {
74
+ // Bundled skills dir not resolvable (unexpected) — protect just the sidecar.
75
+ }
76
+ return paths.filter((p) => existsSync(p));
77
+ }
78
+ // `git reset --hard <sha>` is repo-wide, so paths that must survive it are copied
79
+ // to a temp snapshot first, then copied back over whatever the reset produced.
80
+ // Restoring on top (no pre-delete) keeps the live sidecar safe if a copy throws.
81
+ function resetPreserving(root, sha, preserve) {
82
+ const snap = mkdtempSync(join(tmpdir(), 'plumbbob-revert-'));
83
+ try {
84
+ preserve.forEach((p, i) => cpSync(p, join(snap, String(i)), { recursive: true }));
85
+ resetHard(root, sha);
86
+ preserve.forEach((p, i) => cpSync(join(snap, String(i)), p, { recursive: true }));
87
+ }
88
+ finally {
89
+ rmSync(snap, { force: true, recursive: true });
90
+ }
91
+ }
92
+ function parseTo(args) {
93
+ const idx = args.indexOf('--to');
94
+ if (idx === -1) {
95
+ return null;
96
+ }
97
+ const raw = args[idx + 1];
98
+ if (raw === undefined || !/^\d+$/.test(raw)) {
99
+ return 'invalid';
100
+ }
101
+ return Number(raw);
102
+ }
103
+ function readCheckpoints(root) {
104
+ let content = '';
105
+ try {
106
+ content = readFileSync(checkpointsPath(root), 'utf8');
107
+ }
108
+ catch {
109
+ return { baseline: undefined, steps: [] };
110
+ }
111
+ let baseline;
112
+ const steps = [];
113
+ for (const line of content.split('\n')) {
114
+ const baselineMatch = /^baseline\s+(\S+)/.exec(line);
115
+ if (baselineMatch) {
116
+ baseline = baselineMatch[1];
117
+ continue;
118
+ }
119
+ const stepMatch = /^step\s+(\d+)\s+(\S+)/.exec(line);
120
+ if (stepMatch) {
121
+ steps.push({ n: Number(stepMatch[1]), sha: stepMatch[2] ?? '' });
122
+ }
123
+ }
124
+ return { baseline, steps };
125
+ }
126
+ function readSeamTokens(root) {
127
+ try {
128
+ return readFileSync(seamPath(root), 'utf8')
129
+ .split('\n')
130
+ .map((l) => l.trim())
131
+ .filter((l) => l.length > 0);
132
+ }
133
+ catch {
134
+ return [];
135
+ }
136
+ }
@@ -0,0 +1,24 @@
1
+ // `plumbbob review` — run the heavy check from BUILD. Green flips to REVIEW (the
2
+ // muzzle goes back on so reading the diff can't slide into editing). Red stays in
3
+ // BUILD. The state only advances on a green check.
4
+ import { findRepoRoot } from "../lib/git.js";
5
+ import { hasSession, readState, writeState } from "../lib/sidecar.js";
6
+ import { runCheck } from "../lib/check.js";
7
+ export function review(cwd) {
8
+ const root = findRepoRoot(cwd);
9
+ if (root === null || !hasSession(root)) {
10
+ process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
11
+ return 1;
12
+ }
13
+ if (readState(root) !== 'BUILD') {
14
+ process.stderr.write(`plumbbob: review runs from BUILD (current state is ${readState(root) ?? 'UNKNOWN'}). Run \`plumbbob build <n>\` first.\n`);
15
+ return 1;
16
+ }
17
+ if (runCheck(root) !== 0) {
18
+ process.stderr.write('plumbbob: check failed (red) — staying in BUILD. Fix it and re-run `review` (or `done` once green).\n');
19
+ return 1;
20
+ }
21
+ writeState(root, 'REVIEW');
22
+ process.stdout.write('plumbbob: check green — STATE=REVIEW. Read the diff against intent.md (edits muzzled). `build <n>` to re-enter and fix, or `done` to checkpoint.\n');
23
+ return 0;
24
+ }
@@ -0,0 +1,141 @@
1
+ // `plumbbob setup` — the installer. Two install shapes:
2
+ //
3
+ // self-contained (default when plumbbob is a project-local dep; --local /
4
+ // --project to force) — NOTHING is written under ~/.claude. The hooks are
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:
9
+ // --local (default) <repo>/.claude/settings.local.json — personal, untracked
10
+ // --project <repo>/.claude/settings.json — committable, enrolls the team
11
+ // This is the "run it from the project root, no global install" shape.
12
+ //
13
+ // global (--global, or the auto default when plumbbob is NOT a project-local
14
+ // dep) — the original behavior: copy the hooks to ~/.claude/plumbbob/hooks/
15
+ // and the skills to ~/.claude/skills/, register absolute command paths in
16
+ // ~/.claude/settings.json, and leave the skills calling a bare `plumbbob`.
17
+ //
18
+ // `--uninstall` strips the registration from whichever settings file the same
19
+ // resolution selects; installed/copied files are left in place. The hooks are
20
+ // session-gated, so any registration is safe in every repo (C7).
21
+ import { chmodSync, cpSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
22
+ import { homedir } from 'node:os';
23
+ import { join, sep } from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { findRepoRoot } from "../lib/git.js";
26
+ import { mergeRegistration, readSettings, stripRegistration, writeSettings } from "../lib/settings.js";
27
+ const HOOK_FILES = ['pre-edit.sh', 'bash-guard.sh', 'post-edit.sh'];
28
+ // The placeholder every skill carries in place of its `plumbbob` invocation;
29
+ // setup substitutes it with the form the chosen install shape resolves.
30
+ 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.
33
+ const SELF_HOOKS_DIR = '$CLAUDE_PROJECT_DIR/node_modules/plumbbob/hooks';
34
+ const SELF_BIN = '$CLAUDE_PROJECT_DIR/node_modules/.bin/plumbbob';
35
+ export function setup(cwd, args) {
36
+ const home = process.env.HOME ?? homedir();
37
+ const mode = resolveMode(cwd, home, args);
38
+ if (mode === null) {
39
+ process.stderr.write('plumbbob: a project-scoped install writes <repo>/.claude/, but this is not a git repository. ' +
40
+ 'Run setup from inside the repo, or use --global to install under ~/.claude.\n');
41
+ return 1;
42
+ }
43
+ if (args.includes('--uninstall')) {
44
+ writeSettings(mode.settingsFile, stripRegistration(readSettings(mode.settingsFile)));
45
+ process.stdout.write(`plumbbob: removed the hook registration from ${mode.settingsFile}. The installed/copied hooks and skills were left in place.\n`);
46
+ return 0;
47
+ }
48
+ return mode.kind === 'global' ? installGlobal(home, mode.settingsFile) : installSelfContained(mode);
49
+ }
50
+ // The original shape: hooks + skills copied under ~/.claude, absolute command
51
+ // paths, skills calling a bare `plumbbob` (which the global npm install puts on
52
+ // PATH).
53
+ function installGlobal(home, settingsFile) {
54
+ const hooksDir = join(home, '.claude', 'plumbbob', 'hooks');
55
+ const skillsDir = join(home, '.claude', 'skills');
56
+ cpSync(packageDir('hooks'), hooksDir, { recursive: true });
57
+ for (const file of HOOK_FILES) {
58
+ chmodSync(join(hooksDir, file), 0o755);
59
+ }
60
+ cpSync(packageDir('skills'), skillsDir, { recursive: true });
61
+ substituteSkillBins(skillsDir, 'plumbbob');
62
+ writeSettings(settingsFile, mergeRegistration(readSettings(settingsFile), hooksDir));
63
+ process.stdout.write(`plumbbob: installed hooks → ${hooksDir}, skills → ${skillsDir}.\n` +
64
+ `plumbbob: registered the hooks in ${settingsFile} (global scope).\n` +
65
+ 'plumbbob: restart Claude Code (or reload settings) for the hooks to take effect.\n' +
66
+ "plumbbob: the skills call a bare `plumbbob`; the npm install puts it (and `pb`) on PATH. From a dev checkout add `alias plumbbob='node <repo>/src/cli.ts'`.\n");
67
+ return 0;
68
+ }
69
+ // The self-contained shape: nothing under ~/.claude. Hooks are referenced in
70
+ // place inside node_modules; only the skills are copied into the repo (Claude
71
+ // Code discovers skills under .claude/skills/, never from node_modules), with
72
+ // their bin invocation rewritten to the project-local binary.
73
+ function installSelfContained(mode) {
74
+ const skillsDir = join(mode.repoRoot, '.claude', 'skills');
75
+ cpSync(packageDir('skills'), skillsDir, { recursive: true });
76
+ substituteSkillBins(skillsDir, SELF_BIN);
77
+ writeSettings(mode.settingsFile, mergeRegistration(readSettings(mode.settingsFile), SELF_HOOKS_DIR, true));
78
+ const note = existsSync(join(mode.repoRoot, 'node_modules', 'plumbbob', 'hooks'))
79
+ ? ''
80
+ : '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
+ 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` +
83
+ `plumbbob: referenced the hooks at ${SELF_HOOKS_DIR} and registered them in ${mode.settingsFile} (${scope} scope).\n` +
84
+ note +
85
+ 'plumbbob: restart Claude Code (or reload settings) for the hooks to take effect.\n' +
86
+ '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
+ return 0;
88
+ }
89
+ // Resolve which install shape and settings file to use. Explicit flags win;
90
+ // with no flag, auto-detect — a project-local plumbbob installs self-contained
91
+ // (personal, into settings.local.json), anything else installs global. Returns
92
+ // null when a project scope is required but cwd is not in a git repo.
93
+ function resolveMode(cwd, home, args) {
94
+ const globalFile = join(home, '.claude', 'settings.json');
95
+ if (args.includes('--global')) {
96
+ return { kind: 'global', settingsFile: globalFile };
97
+ }
98
+ const wantsProject = args.includes('--project');
99
+ const wantsLocal = args.includes('--local');
100
+ const auto = !wantsProject && !wantsLocal && isProjectLocal(cwd);
101
+ if (!wantsProject && !wantsLocal && !auto) {
102
+ return { kind: 'global', settingsFile: globalFile };
103
+ }
104
+ const repoRoot = findRepoRoot(cwd);
105
+ if (repoRoot === null) {
106
+ return null;
107
+ }
108
+ const file = wantsProject ? 'settings.json' : 'settings.local.json';
109
+ return { kind: 'self', settingsFile: join(repoRoot, '.claude', file), repoRoot };
110
+ }
111
+ // plumbbob is project-local when its package directory lives inside this repo's
112
+ // node_modules (a `pnpm add` / `npm install` dependency), as opposed to a global
113
+ // install or a dev checkout.
114
+ function isProjectLocal(cwd) {
115
+ const repoRoot = findRepoRoot(cwd);
116
+ if (repoRoot === null) {
117
+ return false;
118
+ }
119
+ return packageRoot().startsWith(join(repoRoot, 'node_modules') + sep);
120
+ }
121
+ // Rewrite the bin placeholder in every copied skill (both the `!`...`` status
122
+ // pre-injection and the allowed-tools patterns) to the resolved invocation.
123
+ function substituteSkillBins(skillsDir, bin) {
124
+ for (const name of readdirSync(skillsDir)) {
125
+ const file = join(skillsDir, name, 'SKILL.md');
126
+ if (!statSync(join(skillsDir, name)).isDirectory() || !existsSync(file)) {
127
+ continue;
128
+ }
129
+ const src = readFileSync(file, 'utf8');
130
+ if (src.includes(BIN_PLACEHOLDER)) {
131
+ writeFileSync(file, src.split(BIN_PLACEHOLDER).join(bin));
132
+ }
133
+ }
134
+ }
135
+ // The plumbbob package root (parent of hooks/ and skills/), off this module's URL.
136
+ function packageRoot() {
137
+ return fileURLToPath(new URL('../../', import.meta.url));
138
+ }
139
+ function packageDir(name) {
140
+ return join(packageRoot(), name);
141
+ }
@@ -0,0 +1,113 @@
1
+ // `plumbbob spike` (D18) — the spike lifecycle for a genuine fork the design
2
+ // phase couldn't settle. `spike "<slug>" [opt…]` creates a sibling git worktree +
3
+ // `spike/<slug>-<opt>` branch per option OUTSIDE the repo root (default opts a/b)
4
+ // and sets STATE=SPIKE; the main tree stays DESIGN-locked while you experiment in
5
+ // the worktrees, which are hook-dormant by construction — the untracked sidecar
6
+ // (D17) doesn't exist in a fresh checkout, so the hooks find no STATE there.
7
+ // `spike done` removes every spike worktree + branch and returns to DESIGN.
8
+ //
9
+ // Worktree git calls run directly here rather than via lib/git.ts (which holds the
10
+ // shared additive read/commit helpers): worktree management is spike-local, and
11
+ // this is the only place Plumbbob creates branches.
12
+ import { execFileSync } from 'node:child_process';
13
+ import { existsSync } from 'node:fs';
14
+ import { basename, dirname, join } from 'node:path';
15
+ import { findRepoRoot } from "../lib/git.js";
16
+ import { hasSession, readState, writeState } from "../lib/sidecar.js";
17
+ const DEFAULT_OPTIONS = ['a', 'b'];
18
+ export function spike(cwd, args) {
19
+ const root = findRepoRoot(cwd);
20
+ if (root === null || !hasSession(root)) {
21
+ process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
22
+ return 1;
23
+ }
24
+ const positionals = args.filter((a) => !a.startsWith('--'));
25
+ if (positionals[0] === 'done') {
26
+ return spikeDone(root);
27
+ }
28
+ return spikeStart(root, positionals);
29
+ }
30
+ function spikeStart(root, positionals) {
31
+ const state = readState(root);
32
+ if (state === 'SPIKE') {
33
+ process.stderr.write('plumbbob: already in a spike. Run `plumbbob spike done` to close it first.\n');
34
+ return 1;
35
+ }
36
+ if (state !== 'DESIGN') {
37
+ process.stderr.write(`plumbbob: spike starts from DESIGN (current state is ${state ?? 'UNKNOWN'}). ` +
38
+ 'A spike is a deliberate fork — close the current step first with `done`.\n');
39
+ return 1;
40
+ }
41
+ const slug = sanitize(positionals[0] ?? '');
42
+ if (slug.length === 0) {
43
+ process.stderr.write('plumbbob: spike needs a slug. Try: plumbbob spike "auth-store" a b.\n');
44
+ return 1;
45
+ }
46
+ const explicit = positionals.slice(1).map(sanitize).filter((o) => o.length > 0);
47
+ const options = explicit.length > 0 ? explicit : DEFAULT_OPTIONS;
48
+ const created = [];
49
+ for (const opt of options) {
50
+ const path = join(dirname(root), `${basename(root)}-spike-${slug}-${opt}`);
51
+ if (existsSync(path)) {
52
+ process.stderr.write(`plumbbob: ${path} already exists — remove it or run \`plumbbob spike done\` first.\n`);
53
+ return 1;
54
+ }
55
+ git(root, ['worktree', 'add', '-b', `spike/${slug}-${opt}`, path, 'HEAD']);
56
+ created.push(path);
57
+ }
58
+ writeState(root, 'SPIKE');
59
+ process.stdout.write(`plumbbob: STATE=SPIKE — the main tree stays DESIGN-locked. Experiment in the throwaway worktrees:\n${created
60
+ .map((p) => ` ${p}`)
61
+ .join('\n')}\nWhen you've decided, record the verdict in intent.md and run \`plumbbob spike done\`.\n`);
62
+ return 0;
63
+ }
64
+ function spikeDone(root) {
65
+ const state = readState(root);
66
+ if (state !== 'SPIKE') {
67
+ process.stderr.write(`plumbbob: no active spike (state is ${state ?? 'UNKNOWN'}).\n`);
68
+ return 1;
69
+ }
70
+ for (const path of spikeWorktrees(root)) {
71
+ git(root, ['worktree', 'remove', '--force', path]);
72
+ }
73
+ git(root, ['worktree', 'prune']);
74
+ for (const branch of spikeBranches(root)) {
75
+ git(root, ['branch', '-D', branch]);
76
+ }
77
+ writeState(root, 'DESIGN');
78
+ process.stdout.write('plumbbob: spike closed — STATE=DESIGN, worktrees and branches removed. ' +
79
+ 'Record the verdict (which option won, and why) in intent.md before you `build`.\n');
80
+ return 0;
81
+ }
82
+ // Worktree paths whose checked-out branch is under spike/ — parsed from the
83
+ // porcelain output (blank-line-separated `worktree <path>` / `branch <ref>` blocks).
84
+ function spikeWorktrees(root) {
85
+ const out = git(root, ['worktree', 'list', '--porcelain']);
86
+ const paths = [];
87
+ let current = null;
88
+ for (const line of out.split('\n')) {
89
+ if (line.startsWith('worktree ')) {
90
+ current = line.slice('worktree '.length);
91
+ }
92
+ else if (current !== null && line.startsWith('branch refs/heads/spike/')) {
93
+ paths.push(current);
94
+ }
95
+ }
96
+ return paths;
97
+ }
98
+ function spikeBranches(root) {
99
+ const out = git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/spike/']);
100
+ return out.length === 0 ? [] : out.split('\n').filter((b) => b.length > 0);
101
+ }
102
+ function sanitize(raw) {
103
+ return raw
104
+ .toLowerCase()
105
+ .replace(/[^a-z0-9]+/g, '-')
106
+ .replace(/^-+|-+$/g, '');
107
+ }
108
+ function git(root, args) {
109
+ return execFileSync('git', ['-C', root, ...args], {
110
+ encoding: 'utf8',
111
+ stdio: ['ignore', 'pipe', 'inherit'],
112
+ }).trim();
113
+ }