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
package/README.md CHANGED
@@ -21,46 +21,66 @@ own enforcement from step 5 onward.
21
21
  guard, and a non-blocking light-feedback pass. With no active session they
22
22
  short-circuit to allow in microseconds, so a repo without a Plumbbob session
23
23
  behaves like plain Claude Code.
24
- - Five Claude Code skills — the judgment work, each one human-triggered
24
+ - The judgment skills — the design/finish thinking work, each human-triggered
25
25
  (`disable-model-invocation: true`): `/plumbbob-interrogate`, `/park`,
26
26
  `/plumbbob-triage`, `/plumbbob-report`, `/plumbbob-docs`.
27
+ - The `pb-*` driver skills — thin chat-side triggers for the transition verbs
28
+ (`/pb-start`, `/pb-build`, `/pb-review`, `/pb-done`, `/pb-revert`, `/pb-wrap`,
29
+ `/pb-finish`, `/pb-spike`), also `disable-model-invocation: true`, so you can
30
+ drive the whole loop from the agent window without leaving for a terminal.
27
31
  - A `.plumbbob/` sidecar of flat control files (`STATE`, `SEAM`, `checkpoints`,
28
32
  `intent.md`, `build-log.md`) that the hooks read with a grep.
29
33
 
30
34
  ## Install
31
35
 
32
- The npm package is `plumbbob` (the npm name `plumbbob` was taken); it installs
33
- the CLI as `plumbbob` plus a `pb` shorthand:
36
+ The npm package is `plumbbob`; it installs the CLI as `plumbbob` plus a `pb`
37
+ shorthand, and ships the hooks and skills inside the package. There are two
38
+ install shapes: project-level (self-contained, nothing global) and global.
39
+
40
+ ### Project-level (recommended) — nothing under `~`
41
+
42
+ Add Plumbbob as a dev dependency and set it up self-contained, so everything
43
+ lives in the repo and `node_modules` with no global install to manage:
34
44
 
35
45
  ```sh
36
- npm install -g plumbbob
46
+ pnpm add -D plumbbob
47
+ pnpm exec plumbbob setup --local # or just `setup` — it auto-detects a project-local install
37
48
  ```
38
49
 
39
- `plumbbob setup` copies the hooks to `~/.claude/plumbbob/hooks/` and the skills
40
- to `~/.claude/skills/`, then registers the hooks in a Claude Code settings file.
41
- The hooks and skills always install once under `~/.claude/`; the registration
42
- scope is a sharing choice, not a behavior choice.
50
+ A self-contained `setup` writes **nothing** under `~/.claude`. It copies the
51
+ skills into `<repo>/.claude/skills/` (with their bin invocation pointed at the
52
+ project-local `node_modules/.bin/plumbbob`) and registers the hooks in place at
53
+ `$CLAUDE_PROJECT_DIR/node_modules/plumbbob/hooks/`.
43
54
 
44
- | Command | Registers in | Use for |
45
- |----------------------------|--------------------------------------|--------------------------------------|
46
- | `plumbbob setup` | `~/.claude/settings.json` | yourself, across every repo (default) |
47
- | `plumbbob setup --project`| `<repo>/.claude/settings.json` | enrolling a whole team (committable) |
48
- | `plumbbob setup --local` | `<repo>/.claude/settings.local.json` | yourself, this repo only (untracked) |
55
+ | Command | Registers in | Use for |
56
+ |--------------------------------------|--------------------------------------|----------------------------------|
57
+ | `pnpm exec plumbbob setup --local` | `<repo>/.claude/settings.local.json` | yourself, this repo (untracked) |
58
+ | `pnpm exec plumbbob setup --project` | `<repo>/.claude/settings.json` | enrolling a team (committable) |
49
59
 
50
- The repo-scoped files register `~`-prefixed command paths, so committed settings
51
- carry no machine-absolute home directory — a teammate without Plumbbob installed
52
- gets a non-blocking hook error, not a wall. `plumbbob setup --uninstall` strips
53
- the registration (the installed files stay in place).
60
+ Both forms address the hooks and the skill bin through `$CLAUDE_PROJECT_DIR`, so
61
+ a committed `settings.json` carries no machine-absolute path — a teammate runs
62
+ `pnpm install` and re-runs `setup` to regenerate their skills copy.
54
63
 
55
- After installing, restart Claude Code (or reload settings) for the hooks to take
56
- effect. The npm install puts `plumbbob` on your `PATH`, which the skills' status
57
- pre-injection needs to resolve; from a dev checkout, alias it at the CLI entry
58
- point instead:
64
+ ### Global one install, every repo
65
+
66
+ ```sh
67
+ npm install -g plumbbob
68
+ plumbbob setup --global
69
+ ```
70
+
71
+ `setup --global` copies the hooks to `~/.claude/plumbbob/hooks/` and the skills
72
+ to `~/.claude/skills/`, then registers absolute command paths in
73
+ `~/.claude/settings.json`. The skills call a bare `plumbbob`, which the global
74
+ install puts on your `PATH`; from a dev checkout, alias it instead:
59
75
 
60
76
  ```sh
61
77
  alias plumbbob='node /path/to/plumbbob/src/cli.ts'
62
78
  ```
63
79
 
80
+ `plumbbob setup --uninstall` (with the same scope flag) strips the registration;
81
+ the installed/copied files stay in place. After installing, restart Claude Code
82
+ (or reload settings) for the hooks to take effect.
83
+
64
84
  ## The verbs and skills
65
85
 
66
86
  Mechanical verbs are the `plumbbob` CLI, run from your terminal. The judgment
@@ -83,10 +103,26 @@ work is skills, invoked from the chat pane. The split is judgment-vs-mechanism.
83
103
  | `plumbbob finish` | refuse unless a report exists; archive; clear; muzzle off | CLI |
84
104
  | `plumbbob spike "<slug>"` | throwaway worktree experiment per option; `spike done` tears down | CLI |
85
105
  | `plumbbob mode <x>` | escape hatch: set `STATE` directly (not part of the normal flow) | CLI (hidden) |
86
- | `plumbbob setup` | install hooks + skills; register them (D27 scopes above) | CLI |
87
-
88
- In a Claude Code session the deciding/transition verbs refuse to run (they are
89
- yours to type in a terminal); `status` and `park` are the deliberate exceptions.
106
+ | `plumbbob setup` | install hooks + skills; register them (install shapes above) | CLI |
107
+
108
+ ### Driving the transitions from the chat
109
+
110
+ Every transition verb also has a thin `pb-*` driver skill, so you can run the
111
+ whole loop from the agent window without switching to a terminal: `/pb-start`,
112
+ `/pb-build`, `/pb-review`, `/pb-done`, `/pb-revert`, `/pb-wrap`, `/pb-finish`,
113
+ `/pb-spike`. Each shells exactly its verb and reports the result verbatim; each is
114
+ `disable-model-invocation: true`, so *only you* can fire it.
115
+
116
+ The boundary the one law protects is **human-initiated vs model-initiated**, not
117
+ terminal-vs-chat. A `disable-model-invocation` skill is a human trigger that
118
+ happens to live in the chat: the model can never invoke it, the deciding still
119
+ happens in `intent.md`, and the verb is still dumb mechanism. So the transition
120
+ verbs run in-session now (a terminal still works too) — the lone hold-out is
121
+ `mode`, the escape hatch, which stays human-only (refused in-session, and blocked
122
+ from the model's shell by the Bash guard). The transition verbs are deliberately
123
+ kept out of your settings allowlist, so a stray *model-initiated* transition
124
+ surfaces a Claude Code permission prompt; each driver skill self-authorizes only
125
+ its own verb, only while you are running it.
90
126
 
91
127
  ## The core / adapter boundary
92
128
 
@@ -119,8 +155,15 @@ These are deliberate v1 boundaries, decided on the record, not oversights:
119
155
  ```sh
120
156
  pnpm install
121
157
  pnpm check # tsc, oxlint, ast-grep, vitest, knip, markdownlint
158
+ pnpm build # emit dist/ (what the published bin runs); prepack runs this for you
122
159
  ```
123
160
 
161
+ The published `bin` (`plumbbob` / `pb`) points at `dist/cli.js`, not the `.ts`
162
+ source, so a fresh install runs under plain `node` without `tsx`. The `prepack`
163
+ hook rebuilds `dist/` before every pack/publish; you only need `pnpm build` by
164
+ hand when you want to run the compiled output locally. From a dev checkout the
165
+ source still runs directly (`node src/cli.ts`) via Node's type stripping.
166
+
124
167
  **The pnpm pin.** `package.json` pins `devEngines.packageManager` to an *exact*
125
168
  pnpm version (`11.1.2`) rather than a range, because the `^` range broke every
126
169
  pnpm command at the time of writing. That exact pin will need bumping by hand on
@@ -128,7 +171,7 @@ the next pnpm upgrade; moving back to a ranged constraint is a future revisit.
128
171
 
129
172
  `scripts/dev-install.sh` is the development installer: it registers the hooks
130
173
  pointing at this working tree's `hooks/` (so hook edits take effect with no
131
- re-copy), where `plumbbob setup` copies them into `~/.claude/`. Use
174
+ re-copy), where `plumbbob setup --global` copies them into `~/.claude/`. Use
132
175
  `dev-install.sh` while hacking on the hooks themselves; use `plumbbob setup` to
133
176
  install Plumbbob for real.
134
177
 
package/dist/cli.js ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ // plumbbob CLI — hand-rolled argv dispatch, zero runtime deps, node: builtins
3
+ // only (D1/C2). Functional/procedural: no classes, no `this`, no default
4
+ // export (C1). Verbs are wired up build step by build step.
5
+ import { start } from "./verbs/start.js";
6
+ import { status } from "./verbs/status.js";
7
+ import { mode } from "./verbs/mode.js";
8
+ import { park } from "./verbs/park.js";
9
+ import { build } from "./verbs/build.js";
10
+ import { review } from "./verbs/review.js";
11
+ import { done } from "./verbs/done.js";
12
+ import { revert } from "./verbs/revert.js";
13
+ import { spike } from "./verbs/spike.js";
14
+ import { wrap } from "./verbs/wrap.js";
15
+ import { finish } from "./verbs/finish.js";
16
+ import { setup } from "./verbs/setup.js";
17
+ const VERBS = [
18
+ { name: 'start', summary: 'scaffold .plumbbob/; STATE=DESIGN; record the baseline commit' },
19
+ { name: 'status', summary: 'print the session state, or NO ACTIVE SESSION' },
20
+ { name: 'build', summary: 'build <n>: write SEAM from step n; STATE=BUILD' },
21
+ { name: 'review', summary: 'run the heavy check; if green flip to STATE=REVIEW' },
22
+ { name: 'done', summary: 'ensure check green; checkpoint commit + record SHA; STATE=DESIGN' },
23
+ { name: 'revert', summary: 'revert [--to n]: git reset --hard to a checkpoint SHA; STATE=DESIGN' },
24
+ { name: 'park', summary: 'park "<text>": append a raw line to the park list' },
25
+ { name: 'spike', summary: 'spike "<slug>" | spike done: throwaway worktree experiment' },
26
+ { name: 'wrap', summary: 'set STATE=FINISH so /plumbbob-report and /plumbbob-docs can run' },
27
+ { name: 'finish', summary: 'refuse unless a report is archived; archive; clear; muzzle off' },
28
+ { name: 'mode', summary: 'mode <x>: set STATE directly (hidden escape hatch)' },
29
+ { name: 'setup', summary: 'install hooks + skills; register them (self-contained per-project by default; --global for ~/.claude)' },
30
+ ];
31
+ // D21 (revised): the human owns every transition — but the trigger surface is no
32
+ // longer "terminal vs chat", it is "human-initiated vs model-initiated". The
33
+ // human now fires transitions from the chat through the `pb-*` driver skills
34
+ // (`disable-model-invocation: true`, so the model can never invoke them) just as
35
+ // well as from a terminal, so the blanket in-session refusal is gone. The lone
36
+ // hold-out is `mode`, the hidden escape hatch for desyncs: it stays human-only,
37
+ // refused in-session (CLAUDECODE set) and also blocked from the model's shell by
38
+ // bash-guard.sh. A stray model-initiated transition is caught by Claude Code's
39
+ // permission prompt, since the transition verbs are deliberately kept out of the
40
+ // settings allowlist (only each driver skill self-authorizes its own verb).
41
+ const HUMAN_ONLY_VERBS = new Set(['mode']);
42
+ function formatHelp() {
43
+ const width = Math.max(...VERBS.map((v) => v.name.length));
44
+ const rows = VERBS.map((v) => ` ${v.name.padEnd(width)} ${v.summary}`);
45
+ return ['plumbbob — attention-first build process', '', 'Usage: plumbbob <verb> [args]', '', 'Verbs:', ...rows, ''].join('\n');
46
+ }
47
+ function dispatch(verb, cwd, rest) {
48
+ switch (verb) {
49
+ case 'start':
50
+ return start(cwd, rest);
51
+ case 'status':
52
+ return status(cwd);
53
+ case 'mode':
54
+ return mode(cwd, rest);
55
+ case 'park':
56
+ return park(cwd, rest);
57
+ case 'build':
58
+ return build(cwd, rest);
59
+ case 'review':
60
+ return review(cwd);
61
+ case 'done':
62
+ return done(cwd);
63
+ case 'revert':
64
+ return revert(cwd, rest);
65
+ case 'spike':
66
+ return spike(cwd, rest);
67
+ case 'wrap':
68
+ return wrap(cwd);
69
+ case 'finish':
70
+ return finish(cwd);
71
+ case 'setup':
72
+ return setup(cwd, rest);
73
+ default:
74
+ process.stderr.write(`plumbbob: unknown verb '${verb}'. Run 'plumbbob help' for the verb table.\n`);
75
+ return 1;
76
+ }
77
+ }
78
+ function run(argv) {
79
+ const verb = argv[0] ?? 'help';
80
+ const rest = argv.slice(1);
81
+ if (verb === 'help' || verb === '--help' || verb === '-h') {
82
+ process.stdout.write(`${formatHelp()}\n`);
83
+ return 0;
84
+ }
85
+ if (HUMAN_ONLY_VERBS.has(verb) && process.env.CLAUDECODE) {
86
+ process.stderr.write(`plumbbob: '${verb}' is the human's escape hatch, not a model action (you appear to be in a Claude Code session). ` +
87
+ `Do not retry. Ask the human to run \`plumbbob ${verb}\` themselves.\n`);
88
+ return 1;
89
+ }
90
+ try {
91
+ return dispatch(verb, process.cwd(), rest);
92
+ }
93
+ catch (err) {
94
+ const message = err instanceof Error ? err.message : String(err);
95
+ process.stderr.write(`plumbbob: ${verb} failed: ${message}\n`);
96
+ return 1;
97
+ }
98
+ }
99
+ process.exit(run(process.argv.slice(2)));
@@ -0,0 +1,69 @@
1
+ // The finish-phase archive helper (D20: archive is local-only markdown in v1).
2
+ // `finish` copies the three active files into .plumbbob/archive/<date>-<slug>/
3
+ // before clearing the actives — "archive-then-clear, never destroy" (C4).
4
+ // Functional/procedural, node builtins only (C1/C2).
5
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { sidecarDir, intentPath, buildLogPath } from "./sidecar.js";
8
+ // report.md lives beside intent.md / build-log.md in the sidecar. It is written by
9
+ // /plumbbob-report and is the gate `finish` refuses without (D19). Derived from
10
+ // the exported sidecarDir so this module needs nothing new from sidecar.ts.
11
+ export function reportPath(root) {
12
+ return join(sidecarDir(root), 'report.md');
13
+ }
14
+ function archiveRoot(root) {
15
+ return join(sidecarDir(root), 'archive');
16
+ }
17
+ // A filesystem-safe slug from arbitrary title text: lowercased, runs of
18
+ // non-alphanumerics collapsed to a single hyphen, ends trimmed. Empty → `session`.
19
+ function slugify(title) {
20
+ const slug = title
21
+ .toLowerCase()
22
+ .replace(/[^a-z0-9]+/g, '-')
23
+ .replace(/^-+|-+$/g, '');
24
+ return slug.length === 0 ? 'session' : slug;
25
+ }
26
+ // The session title is intent.md's first `# ` heading (start stamps `# <title>`).
27
+ function sessionTitle(root) {
28
+ let content = '';
29
+ try {
30
+ content = readFileSync(intentPath(root), 'utf8');
31
+ }
32
+ catch {
33
+ return 'session';
34
+ }
35
+ for (const line of content.split('\n')) {
36
+ const m = /^#\s+(.+)$/.exec(line);
37
+ if (m) {
38
+ return (m[1] ?? '').trim();
39
+ }
40
+ }
41
+ return 'session';
42
+ }
43
+ // Today as YYYY-MM-DD (UTC); the archive directory is <date>-<slug>.
44
+ function today() {
45
+ return new Date().toISOString().slice(0, 10);
46
+ }
47
+ // Choose <date>-<slug>, disambiguating with -2, -3, … if a same-day, same-slug
48
+ // session was already archived — so a second session lands ALONGSIDE the first,
49
+ // never on top of it.
50
+ function uniqueArchiveDir(root, base) {
51
+ let candidate = join(archiveRoot(root), base);
52
+ let n = 2;
53
+ while (existsSync(candidate)) {
54
+ candidate = join(archiveRoot(root), `${base}-${n}`);
55
+ n += 1;
56
+ }
57
+ return candidate;
58
+ }
59
+ // Copy intent + build-log + report into archive/<date>-<slug>/ and return the
60
+ // directory created. The report must already exist (finish guards that); intent
61
+ // and build-log always exist in an active session.
62
+ export function archiveSession(root) {
63
+ const dir = uniqueArchiveDir(root, `${today()}-${slugify(sessionTitle(root))}`);
64
+ mkdirSync(dir, { recursive: true });
65
+ copyFileSync(intentPath(root), join(dir, 'intent.md'));
66
+ copyFileSync(buildLogPath(root), join(dir, 'build-log.md'));
67
+ copyFileSync(reportPath(root), join(dir, 'report.md'));
68
+ return dir;
69
+ }
@@ -0,0 +1,26 @@
1
+ // The heavy check (D16/D24): the full gate that `review` and `done` refuse to
2
+ // advance past while red. The command is read from `.plumbbob/config` (`check=`)
3
+ // so non-pnpm repos gate too; tests point it at a stub (`true`/`false`) since a
4
+ // real `pnpm check` would recurse into vitest (D14).
5
+ import { spawnSync } from 'node:child_process';
6
+ import { readFileSync } from 'node:fs';
7
+ import { configPath } from "./sidecar.js";
8
+ const DEFAULT_CHECK = 'pnpm run check';
9
+ function readCheckCommand(root) {
10
+ let config = '';
11
+ try {
12
+ config = readFileSync(configPath(root), 'utf8');
13
+ }
14
+ catch {
15
+ return DEFAULT_CHECK;
16
+ }
17
+ const line = config.split('\n').find((l) => l.startsWith('check='));
18
+ return line === undefined ? DEFAULT_CHECK : line.slice('check='.length).trim();
19
+ }
20
+ // Runs the configured check in `root`, streaming its output to the terminal so
21
+ // the human sees what failed. Returns the exit code (0 = green).
22
+ export function runCheck(root) {
23
+ const command = readCheckCommand(root);
24
+ const result = spawnSync(command, { cwd: root, shell: true, stdio: 'inherit' });
25
+ return result.status ?? 1;
26
+ }
@@ -0,0 +1,62 @@
1
+ // Thin git wrapper over `node:child_process` (C2: node builtins only, zero
2
+ // runtime deps). Functional/procedural, no classes (C1). Plumbbob's git
3
+ // footprint is additive (C5); these helpers only read and locate.
4
+ import { execFileSync } from 'node:child_process';
5
+ function runGit(root, args) {
6
+ return execFileSync('git', ['-C', root, ...args], {
7
+ encoding: 'utf8',
8
+ stdio: ['ignore', 'pipe', 'ignore'],
9
+ }).trim();
10
+ }
11
+ // The git toplevel for `cwd`, or null when `cwd` is not inside a git repo.
12
+ export function findRepoRoot(cwd) {
13
+ try {
14
+ return runGit(cwd, ['rev-parse', '--show-toplevel']);
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ // Absolute path to the .git directory (handles worktrees/linked git dirs).
21
+ export function gitDir(root) {
22
+ return runGit(root, ['rev-parse', '--absolute-git-dir']);
23
+ }
24
+ export function headSha(root) {
25
+ return runGit(root, ['rev-parse', 'HEAD']);
26
+ }
27
+ export function hasCommit(root) {
28
+ try {
29
+ runGit(root, ['rev-parse', '--verify', 'HEAD']);
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ // Dirty = any tracked change or non-ignored untracked file. The sidecar is
37
+ // git-excluded (D17), so an active session never reads as dirty.
38
+ export function isDirty(root) {
39
+ return runGit(root, ['status', '--porcelain']).length > 0;
40
+ }
41
+ // --- mutation helpers (build-loop: done checkpoints, revert resets). Additive
42
+ // only (C5): stage/commit forward, reset --hard to a recorded checkpoint SHA. ---
43
+ export function stageAll(root) {
44
+ runGit(root, ['add', '-A']);
45
+ }
46
+ export function stagedPaths(root) {
47
+ const out = runGit(root, ['diff', '--cached', '--name-only']);
48
+ return out.length === 0 ? [] : out.split('\n');
49
+ }
50
+ export function untrackedPaths(root) {
51
+ const out = runGit(root, ['ls-files', '--others', '--exclude-standard']);
52
+ return out.length === 0 ? [] : out.split('\n');
53
+ }
54
+ // Commit whatever is staged as a checkpoint and return its SHA. --allow-empty so
55
+ // a step that touched only ignored files still gets a checkpoint to revert to.
56
+ export function commit(root, message) {
57
+ runGit(root, ['commit', '--allow-empty', '-m', message]);
58
+ return headSha(root);
59
+ }
60
+ export function resetHard(root, sha) {
61
+ runGit(root, ['reset', '--hard', sha]);
62
+ }
@@ -0,0 +1,88 @@
1
+ // The strict intent parser. `build <n>` reads the nth step under `## Steps` and
2
+ // extracts its seam — the backtick-wrapped paths on the single `seam:` sub-line.
3
+ // Strict by design: it refuses precisely on globs, absolute paths, a missing
4
+ // step, a missing seam, or more than one seam line, rather than guessing.
5
+ const GLOB_CHARS = /[*?[\]{}]/;
6
+ export function parseStepSeam(content, step) {
7
+ const lines = content.split('\n');
8
+ const stepsIdx = lines.findIndex((l) => l.trim() === '## Steps');
9
+ if (stepsIdx === -1) {
10
+ return fail('intent.md has no "## Steps" section.');
11
+ }
12
+ let sectionEnd = lines.findIndex((l, i) => i > stepsIdx && l.startsWith('## '));
13
+ if (sectionEnd === -1) {
14
+ sectionEnd = lines.length;
15
+ }
16
+ const itemStarts = [];
17
+ for (let i = stepsIdx + 1; i < sectionEnd; i++) {
18
+ const m = /^(\d+)\.\s/.exec(lines[i] ?? '');
19
+ if (m) {
20
+ itemStarts.push({ n: Number(m[1]), idx: i });
21
+ }
22
+ }
23
+ const pos = itemStarts.findIndex((s) => s.n === step);
24
+ if (pos === -1) {
25
+ return fail(`intent.md has no step ${step} under "## Steps".`);
26
+ }
27
+ const itemStart = itemStarts[pos]?.idx ?? stepsIdx;
28
+ const nextStart = itemStarts[pos + 1]?.idx;
29
+ const itemEnd = nextStart === undefined ? sectionEnd : nextStart;
30
+ const itemLines = lines.slice(itemStart, itemEnd);
31
+ const seamLines = itemLines
32
+ .map((l, i) => ({ l, i }))
33
+ .filter(({ l }) => /^\s*-\s*seam:/.test(l))
34
+ .map(({ i }) => i);
35
+ if (seamLines.length === 0) {
36
+ return fail(`step ${step} has no \`seam:\` line.`);
37
+ }
38
+ if (seamLines.length > 1) {
39
+ return fail(`step ${step} has more than one \`seam:\` line.`);
40
+ }
41
+ // The seam declaration is the seam line plus any wrapped continuation lines.
42
+ // Truncate each at an HTML-comment opener so a trailing `<!-- ... -->` note
43
+ // (which may carry its own backticks) is never read as a seam token; a
44
+ // continuation ends at the first line whose pre-comment text has no backtick.
45
+ const seamStart = seamLines[0] ?? 0;
46
+ const decl = [];
47
+ for (let i = seamStart; i < itemLines.length; i++) {
48
+ const beforeComment = (itemLines[i] ?? '').split('<!--')[0] ?? '';
49
+ if (i > seamStart && !beforeComment.includes('`')) {
50
+ break;
51
+ }
52
+ decl.push(beforeComment);
53
+ }
54
+ const tokens = [];
55
+ const re = /`([^`]+)`/g;
56
+ for (const line of decl) {
57
+ for (let m = re.exec(line); m !== null; m = re.exec(line)) {
58
+ tokens.push(m[1] ?? '');
59
+ }
60
+ }
61
+ if (tokens.length === 0) {
62
+ return fail(`step ${step}'s seam lists no files.`);
63
+ }
64
+ const seam = [];
65
+ for (const raw of tokens) {
66
+ const token = raw.trim().replace(/^\.\//, '');
67
+ if (token === '') {
68
+ return fail(`step ${step}'s seam has an empty token.`);
69
+ }
70
+ if (GLOB_CHARS.test(token)) {
71
+ return fail(`step ${step}'s seam token \`${raw}\` is a glob; seams are exact paths or \`dir/\` grants (D23).`);
72
+ }
73
+ if (token.startsWith('/')) {
74
+ return fail(`step ${step}'s seam token \`${raw}\` is absolute; seams are repo-relative.`);
75
+ }
76
+ seam.push(token);
77
+ }
78
+ return { ok: true, seam };
79
+ }
80
+ // Seam membership (D23): a repo-relative path is in-seam if it equals an exact
81
+ // token, or is prefixed by a `dir/` grant. Shared by `done` (scope-drift warn)
82
+ // and `revert` (untracked cleanup); the pre-edit hook reimplements it in sh.
83
+ export function matchesSeam(relPath, tokens) {
84
+ return tokens.some((token) => (token.endsWith('/') ? relPath.startsWith(token) : relPath === token));
85
+ }
86
+ function fail(error) {
87
+ return { ok: false, error };
88
+ }
@@ -0,0 +1,79 @@
1
+ // Claude Code settings.json hook registration (D27). A pure-TS JSON merge (zero
2
+ // runtime deps, node builtins only — C2): strip our entries, then re-add them, so
3
+ // a re-run is byte-identical. The same logic serves all three D27 scopes — only
4
+ // the settings file path and the command-path representation differ, both chosen
5
+ // by the `setup` verb. Functional/procedural, no classes (C1).
6
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { dirname } from 'node:path';
8
+ // A registration entry is "ours" iff one of its hook commands points into our
9
+ // hooks dir. This marker matches every command form we emit: the global
10
+ // `~/.claude/plumbbob/hooks/...` copy and the self-contained
11
+ // `$CLAUDE_PROJECT_DIR/node_modules/plumbbob/hooks/...` reference both contain
12
+ // `plumbbob/hooks/`, so uninstall finds either.
13
+ const OURS_MARKER = 'plumbbob/hooks/';
14
+ // The PreToolUse edit matcher covers all four editing tools (D5).
15
+ const EDIT_MATCHER = 'Edit|Write|MultiEdit|NotebookEdit';
16
+ // The three hook registration entries, pointing at `hooksDir`. Two command forms:
17
+ // global — a direct absolute path to the +x copy under ~/.claude.
18
+ // self (viaSh) — `sh "<dir>/<file>"` against the package's hooks/ under
19
+ // node_modules, addressed through `$CLAUDE_PROJECT_DIR` so the
20
+ // committed/portable form carries no machine-absolute path and
21
+ // needs no execute bit (pnpm's store may not preserve one).
22
+ // Mirrors the PreToolUse/PostToolUse shape dev-install wrote.
23
+ function registrationEntries(hooksDir, viaSh) {
24
+ const dir = hooksDir.endsWith('/') ? hooksDir.slice(0, -1) : hooksDir;
25
+ const command = (file) => (viaSh ? `sh "${dir}/${file}"` : `${dir}/${file}`);
26
+ const entry = (matcher, file) => ({
27
+ matcher,
28
+ hooks: [{ type: 'command', command: command(file) }],
29
+ });
30
+ return {
31
+ PreToolUse: [entry(EDIT_MATCHER, 'pre-edit.sh'), entry('Bash', 'bash-guard.sh')],
32
+ PostToolUse: [entry(EDIT_MATCHER, 'post-edit.sh')],
33
+ };
34
+ }
35
+ function isOurs(entry) {
36
+ return (entry.hooks ?? []).some((h) => h.command.includes(OURS_MARKER));
37
+ }
38
+ function stripOurs(list) {
39
+ return (list ?? []).filter((e) => !isOurs(e));
40
+ }
41
+ // Merge our registration into a parsed settings object (strip-then-add). Returns
42
+ // a new object; every unrelated key and unrelated hook is preserved, and key
43
+ // order is stable across runs so a second merge is byte-identical.
44
+ export function mergeRegistration(settings, hooksDir, viaSh = false) {
45
+ const entries = registrationEntries(hooksDir, viaSh);
46
+ const existing = settings.hooks ?? {};
47
+ const hooks = { ...existing };
48
+ hooks.PreToolUse = [...stripOurs(existing.PreToolUse), ...entries.PreToolUse];
49
+ hooks.PostToolUse = [...stripOurs(existing.PostToolUse), ...entries.PostToolUse];
50
+ return { ...settings, hooks };
51
+ }
52
+ // Remove only our entries from the two events we manage, leaving everything else
53
+ // untouched (uninstall, and the idempotent strip half of a re-install).
54
+ export function stripRegistration(settings) {
55
+ const existing = settings.hooks;
56
+ if (existing === undefined) {
57
+ return settings;
58
+ }
59
+ const hooks = { ...existing };
60
+ if (existing.PreToolUse !== undefined) {
61
+ hooks.PreToolUse = stripOurs(existing.PreToolUse);
62
+ }
63
+ if (existing.PostToolUse !== undefined) {
64
+ hooks.PostToolUse = stripOurs(existing.PostToolUse);
65
+ }
66
+ return { ...settings, hooks };
67
+ }
68
+ export function readSettings(path) {
69
+ try {
70
+ return JSON.parse(readFileSync(path, 'utf8'));
71
+ }
72
+ catch {
73
+ return {};
74
+ }
75
+ }
76
+ export function writeSettings(path, settings) {
77
+ mkdirSync(dirname(path), { recursive: true });
78
+ writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`);
79
+ }
@@ -0,0 +1,69 @@
1
+ // The .plumbbob/ sidecar: control state lives in flat files so the hooks can
2
+ // read it with a grep and no markdown parsing (D7). Functional/procedural,
3
+ // node builtins only (C1/C2).
4
+ import { existsSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { gitDir } from "./git.js";
7
+ const DIRNAME = '.plumbbob';
8
+ // The five legal control states (README mode machine). `mode` validates against
9
+ // this; the muzzle allows edits iff STATE is BUILD or SPIKE.
10
+ export const VALID_STATES = ['DESIGN', 'BUILD', 'REVIEW', 'SPIKE', 'FINISH'];
11
+ export function sidecarDir(root) {
12
+ return join(root, DIRNAME);
13
+ }
14
+ function statePath(root) {
15
+ return join(root, DIRNAME, 'STATE');
16
+ }
17
+ // SEAM and STEP carry the in-flight step (D4/D7): a plain path list and a bare
18
+ // number, so the hooks read them with a grep and no markdown parsing.
19
+ export function seamPath(root) {
20
+ return join(root, DIRNAME, 'SEAM');
21
+ }
22
+ export function stepPath(root) {
23
+ return join(root, DIRNAME, 'STEP');
24
+ }
25
+ export function checkpointsPath(root) {
26
+ return join(root, DIRNAME, 'checkpoints');
27
+ }
28
+ export function configPath(root) {
29
+ return join(root, DIRNAME, 'config');
30
+ }
31
+ export function intentPath(root) {
32
+ return join(root, DIRNAME, 'intent.md');
33
+ }
34
+ export function buildLogPath(root) {
35
+ return join(root, DIRNAME, 'build-log.md');
36
+ }
37
+ // A session exists iff STATE exists. Deleting STATE (at finish) is what switches
38
+ // the muzzle off — so it is the single source of truth for "is there a session".
39
+ export function hasSession(root) {
40
+ return existsSync(statePath(root));
41
+ }
42
+ export function readState(root) {
43
+ try {
44
+ return readFileSync(statePath(root), 'utf8').trim();
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ export function writeState(root, state) {
51
+ writeFileSync(statePath(root), `${state}\n`);
52
+ }
53
+ // D17: keep the sidecar untracked by appending `.plumbbob/` to the repo's
54
+ // git/info/exclude. Idempotent — a re-`start` after finish must not double-add.
55
+ export function excludeSidecar(root) {
56
+ const exclude = join(gitDir(root), 'info', 'exclude');
57
+ let current = '';
58
+ try {
59
+ current = readFileSync(exclude, 'utf8');
60
+ }
61
+ catch {
62
+ current = '';
63
+ }
64
+ if (current.split('\n').some((line) => line.trim() === `${DIRNAME}/`)) {
65
+ return;
66
+ }
67
+ const prefix = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
68
+ appendFileSync(exclude, `${prefix}${DIRNAME}/\n`);
69
+ }