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,68 @@
1
+ // `plumbbob start "<title>"` — scaffold the sidecar, record the baseline, enter
2
+ // DESIGN. Refuses on a dirty tree (D22), an existing session, or a non-git dir.
3
+ import { fileURLToPath } from 'node:url';
4
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { findRepoRoot, hasCommit, headSha, isDirty } from "../lib/git.js";
7
+ import { sidecarDir, checkpointsPath, configPath, intentPath, buildLogPath, writeState, hasSession, excludeSidecar, } from "../lib/sidecar.js";
8
+ const DEFAULT_CHECK = 'pnpm run check';
9
+ export function start(cwd, args) {
10
+ const positionals = args.filter((a) => !a.startsWith('--'));
11
+ const allowDirty = args.includes('--allow-dirty');
12
+ const title = (positionals[0] ?? '').trim();
13
+ if (title.length === 0) {
14
+ process.stderr.write('plumbbob: start needs a title. Try: plumbbob start "what you are building".\n');
15
+ return 1;
16
+ }
17
+ const root = findRepoRoot(cwd);
18
+ if (root === null) {
19
+ process.stderr.write('plumbbob: not a git repository. Plumbbob records a baseline commit — run `git init` and make an initial commit first.\n');
20
+ return 1;
21
+ }
22
+ if (!hasCommit(root)) {
23
+ process.stderr.write('plumbbob: this repository has no commits yet. Make an initial commit so `start` can record a baseline.\n');
24
+ return 1;
25
+ }
26
+ if (hasSession(root)) {
27
+ process.stderr.write('plumbbob: a session is already active here. Run `plumbbob finish` to close it before starting another.\n');
28
+ return 1;
29
+ }
30
+ if (isDirty(root)) {
31
+ if (!allowDirty) {
32
+ process.stderr.write('plumbbob: the working tree is dirty. Commit or stash first, or `plumbbob start --allow-dirty "<title>"` to record the current HEAD as the baseline.\n');
33
+ return 1;
34
+ }
35
+ process.stderr.write('plumbbob: WARNING --allow-dirty: recording HEAD as baseline with a dirty tree. A later revert-to-baseline will DISCARD the uncommitted work.\n');
36
+ }
37
+ const sha = headSha(root);
38
+ const check = detectCheck(root);
39
+ mkdirSync(sidecarDir(root), { recursive: true });
40
+ writeState(root, 'DESIGN');
41
+ writeFileSync(checkpointsPath(root), `baseline ${sha}\n`);
42
+ writeFileSync(configPath(root), `check=${check.command}\n`);
43
+ writeFileSync(intentPath(root), stamp(readTemplate('intent.md'), title, check.command));
44
+ writeFileSync(buildLogPath(root), stamp(readTemplate('build-log.md'), title, check.command));
45
+ excludeSidecar(root);
46
+ if (check.warn) {
47
+ process.stderr.write(`plumbbob: WARNING the heavy check '${check.command}' is not defined in this repo's package.json. Edit .plumbbob/config (check=...) to set the real gate before \`review\`/\`done\`.\n`);
48
+ }
49
+ process.stdout.write(`plumbbob: started "${title}" — STATE=DESIGN, baseline ${sha.slice(0, 9)}. Frame and decide in .plumbbob/intent.md; flip to BUILD only once the decisions are made.\n`);
50
+ return 0;
51
+ }
52
+ // D24: default the heavy check to `pnpm run check`; warn (but still record it)
53
+ // when the target repo has no such script, so a non-pnpm repo gets a clear nudge.
54
+ function detectCheck(root) {
55
+ try {
56
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
57
+ return { command: DEFAULT_CHECK, warn: pkg.scripts?.check === undefined };
58
+ }
59
+ catch {
60
+ return { command: DEFAULT_CHECK, warn: true };
61
+ }
62
+ }
63
+ function readTemplate(name) {
64
+ return readFileSync(fileURLToPath(new URL(`../../templates/${name}`, import.meta.url)), 'utf8');
65
+ }
66
+ function stamp(template, title, check) {
67
+ return template.split('{{TITLE}}').join(title).split('{{CHECK}}').join(check);
68
+ }
@@ -0,0 +1,13 @@
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.
3
+ import { findRepoRoot } from "../lib/git.js";
4
+ import { hasSession, readState } from "../lib/sidecar.js";
5
+ export function status(cwd) {
6
+ const root = findRepoRoot(cwd);
7
+ if (root === null || !hasSession(root)) {
8
+ process.stdout.write('NO ACTIVE SESSION\n');
9
+ return 0;
10
+ }
11
+ process.stdout.write(`STATE: ${readState(root) ?? 'UNKNOWN'}\n`);
12
+ return 0;
13
+ }
@@ -0,0 +1,28 @@
1
+ // `plumbbob wrap` (D19/D28) — the FINISH-entry verb. Sets STATE=FINISH so
2
+ // /plumbbob-report can write report.md and /plumbbob-docs can touch docs/.
3
+ // `finish` stays the closing gate; wrap just opens the one state where
4
+ // documentation may be projected. A transition the human fires — from a terminal
5
+ // or, in-session, via the /pb-wrap driver skill (D21 revised).
6
+ import { findRepoRoot } from "../lib/git.js";
7
+ import { hasSession, readState, writeState } from "../lib/sidecar.js";
8
+ export function wrap(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 state = readState(root);
15
+ if (state === 'FINISH') {
16
+ process.stdout.write('plumbbob: already in FINISH. Run `/plumbbob-report`, then `plumbbob finish` to close.\n');
17
+ return 0;
18
+ }
19
+ if (state !== 'DESIGN') {
20
+ process.stderr.write(`plumbbob: wrap enters FINISH from DESIGN (current state is ${state ?? 'UNKNOWN'}). ` +
21
+ 'Close the current step first — `done` from BUILD/REVIEW, or `spike done` from SPIKE.\n');
22
+ return 1;
23
+ }
24
+ writeState(root, 'FINISH');
25
+ process.stdout.write('plumbbob: STATE=FINISH. Now `/plumbbob-report` writes the report (and `/plumbbob-docs` may touch docs/); ' +
26
+ 'then `plumbbob finish` archives and closes.\n');
27
+ return 0;
28
+ }
@@ -47,7 +47,11 @@ esac
47
47
  case "$state" in
48
48
  BUILD | SPIKE) ;;
49
49
  *)
50
- case "$command" in
50
+ # Strip redirects that can't write a real file (stderr merges, /dev/null
51
+ # sinks) so read-only commands aren't over-blocked. Any surviving `>` is a
52
+ # real write. The residual gap (e.g. `>/dev/nullEVIL`) is accepted (D21).
53
+ scrubbed=$(printf '%s' "$command" | sed 's/[0-9]*>&[0-9-]//g; s/&\{0,1\}[0-9]*>>* *\/dev\/null//g')
54
+ case "$scrubbed" in
51
55
  *">"* | *"tee "* | *"sed -i"* | *"git apply"*)
52
56
  deny "blocked: file-writing shell commands are not allowed in ${state:-?} (code edits happen in BUILD). Do not retry — park it or ask the human to \`plumbbob build <n>\`."
53
57
  ;;
package/hooks/pre-edit.sh CHANGED
@@ -84,6 +84,23 @@ case "$rel" in
84
84
  ;;
85
85
  esac
86
86
 
87
+ # The seam muzzle below governs repo code only (D23). A path *outside* the repo
88
+ # (Claude's own plan-mode scratch in ~/.claude/plans, an editor tmpfile) and a
89
+ # git-ignored path *inside* it (fallow data, dist/, coverage/) are none of its
90
+ # business — never block them. `.plumbbob/` is itself git-ignored but is
91
+ # plumbbob's own control surface, so its non-doc files (STATE/SEAM/...) must stay
92
+ # governed by the muzzle, never skipped here.
93
+ case "$rel" in
94
+ .plumbbob/*) ;; # control state: stays governed by the muzzle, never skipped
95
+ *)
96
+ # git resolves the path via the repo toplevel (robust to symlinked cwds).
97
+ # check-ignore exit: 0 = ignored, 128 = outside the repo — neither is the
98
+ # muzzle's business; 1 = in-repo, tracked territory -> fall through to it.
99
+ git -C "$root" check-ignore -q -- "$path" 2>/dev/null
100
+ [ "$?" -ne 1 ] && exit 0
101
+ ;;
102
+ esac
103
+
87
104
  # 4. Everything else is code: allowed only in BUILD, confined to the SEAM (D23).
88
105
  # SPIKE locks the main tree like DESIGN (D18) — spike edits live in dormant
89
106
  # worktrees, so the muzzle never needs to allow SPIKE here.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plumbbob",
3
- "version": "0.1.3",
3
+ "version": "0.2.3",
4
4
  "description": "Attention-first build process: a CLI + hooks that enforce the deciding/executing boundary.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -20,11 +20,11 @@
20
20
  "license": "Apache-2.0",
21
21
  "author": "Rob McLarty <hello@robmclarty.com>",
22
22
  "bin": {
23
- "plumbbob": "src/cli.ts",
24
- "pb": "src/cli.ts"
23
+ "plumbbob": "dist/cli.js",
24
+ "pb": "dist/cli.js"
25
25
  },
26
26
  "files": [
27
- "src",
27
+ "dist",
28
28
  "hooks",
29
29
  "skills",
30
30
  "templates"
@@ -49,6 +49,8 @@
49
49
  "vitest": "^4.1.8"
50
50
  },
51
51
  "scripts": {
52
+ "clean": "rm -rf dist",
53
+ "build": "tsc -p tsconfig.build.json",
52
54
  "check": "pnpm run check:tsc && pnpm run check:oxlint && pnpm run check:astgrep && pnpm run check:test && pnpm run check:knip && pnpm run check:md",
53
55
  "check:tsc": "tsc --noEmit",
54
56
  "check:oxlint": "oxlint",
@@ -3,12 +3,12 @@ name: park
3
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.
4
4
  disable-model-invocation: true
5
5
  model: haiku
6
- allowed-tools: Bash(plumbbob status:*), Bash(plumbbob park:*)
6
+ allowed-tools: Bash(__PLUMBBOB_BIN__ status:*), Bash(__PLUMBBOB_BIN__ park:*)
7
7
  ---
8
8
 
9
9
  # Park
10
10
 
11
- Current session state (injected when this skill runs): !`plumbbob status`
11
+ Current session state (injected when this skill runs): !`__PLUMBBOB_BIN__ status`
12
12
 
13
13
  ## Wrong-state refusal
14
14
 
@@ -19,8 +19,8 @@ Parking needs an **active session** to capture into. Read the state injected abo
19
19
  Take the idea, problem, or "ooh what if" the human just had and **compose it into one tidy, tagged line** — short, legible, self-contained, so it still reads cleanly weeks later. Then:
20
20
 
21
21
  1. **Show the composed line to the human** and wait for **in-turn approval** — they confirm it as-is or edit the wording.
22
- 2. **Only after** that approval, capture it by running `plumbbob park "<the approved line>"` via Bash.
22
+ 2. **Only after** that approval, capture it by running `__PLUMBBOB_BIN__ park "<the approved line>"` via Bash.
23
23
 
24
24
  ## The one hard contract
25
25
 
26
- The capture itself is the **dumb CLI**, never an edit. This skill carries **no Edit and no Write tool** on purpose (D12): you may not append to `build-log.md` yourself. Compose, get approval, then shell `plumbbob park` — that is the only write path. If approval never comes, capture nothing.
26
+ The capture itself is the **dumb CLI**, never an edit. This skill carries **no Edit and no Write tool** on purpose (D12): you may not append to `build-log.md` yourself. Compose, get approval, then shell `__PLUMBBOB_BIN__ park` — that is the only write path. If approval never comes, capture nothing.
@@ -0,0 +1,19 @@
1
+ ---
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.
4
+ disable-model-invocation: true
5
+ model: haiku
6
+ allowed-tools: Bash(__PLUMBBOB_BIN__ status:*), Bash(__PLUMBBOB_BIN__ build:*)
7
+ ---
8
+
9
+ # Plumbbob — build a step (driver)
10
+
11
+ Current session state (injected when this skill runs): !`__PLUMBBOB_BIN__ status`
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.
14
+
15
+ ## What it does
16
+
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.
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: pb-done
3
+ description: Human-triggered driver for `plumbbob done` — ensure the check is green, take the checkpoint commit, record its SHA, and return to DESIGN.
4
+ disable-model-invocation: true
5
+ model: haiku
6
+ allowed-tools: Bash(__PLUMBBOB_BIN__ status:*), Bash(__PLUMBBOB_BIN__ done:*)
7
+ ---
8
+
9
+ # Plumbbob — finish a step (driver)
10
+
11
+ Current session state (injected when this skill runs): !`__PLUMBBOB_BIN__ status`
12
+
13
+ This is a **driver skill** — a chat-side trigger for the mechanical `plumbbob done` 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.
14
+
15
+ ## What it does
16
+
17
+ 1. Run `__PLUMBBOB_BIN__ done` via Bash.
18
+ 2. Report the verb's output verbatim — the checkpoint SHA and return to DESIGN, or any refusal (e.g. the check is red).
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: pb-finish
3
+ description: Human-triggered driver for `plumbbob finish` — refuse unless a report is archived, then archive, clear the session, and switch the muzzle off.
4
+ disable-model-invocation: true
5
+ model: haiku
6
+ allowed-tools: Bash(__PLUMBBOB_BIN__ status:*), Bash(__PLUMBBOB_BIN__ finish:*)
7
+ ---
8
+
9
+ # Plumbbob — finish the session (driver)
10
+
11
+ Current session state (injected when this skill runs): !`__PLUMBBOB_BIN__ status`
12
+
13
+ This is a **driver skill** — a chat-side trigger for the mechanical `plumbbob finish` 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.
14
+
15
+ ## What it does
16
+
17
+ 1. Run `__PLUMBBOB_BIN__ finish` via Bash.
18
+ 2. Report the verb's output verbatim. It is the closing gate — it **refuses unless `.plumbbob/report.md` exists**. If it refuses, relay that and tell the human to run `/plumbbob-report` first; do not write the report yourself from here.
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: pb-revert
3
+ description: Human-triggered driver for `plumbbob revert` — git reset --hard to a checkpoint SHA (discarding the half-done step) and return to DESIGN.
4
+ disable-model-invocation: true
5
+ model: haiku
6
+ allowed-tools: Bash(__PLUMBBOB_BIN__ status:*), Bash(__PLUMBBOB_BIN__ revert:*)
7
+ ---
8
+
9
+ # Plumbbob — revert to a checkpoint (driver)
10
+
11
+ Current session state (injected when this skill runs): !`__PLUMBBOB_BIN__ status`
12
+
13
+ This is a **driver skill** — a chat-side trigger for the mechanical `plumbbob revert` 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.
14
+
15
+ ## What it does
16
+
17
+ 1. Read an optional target step from the way you were invoked (e.g. `/pb-revert --to 2` → step `2`). With no target, revert goes to the last done-checkpoint.
18
+ 2. Run `__PLUMBBOB_BIN__ revert` (or `__PLUMBBOB_BIN__ revert --to <n>`) via Bash. This is a `git reset --hard` — it discards the current in-progress step. Run it exactly as the human asked; do not add or drop the `--to` on your own.
19
+ 3. Report the verb's output verbatim — which checkpoint it reset to, or any refusal.
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: pb-review
3
+ description: Human-triggered driver for `plumbbob review` — run the heavy check and, if green, flip to REVIEW (muzzle back on) to read the diff cold.
4
+ disable-model-invocation: true
5
+ model: haiku
6
+ allowed-tools: Bash(__PLUMBBOB_BIN__ status:*), Bash(__PLUMBBOB_BIN__ review:*)
7
+ ---
8
+
9
+ # Plumbbob — review a step (driver)
10
+
11
+ Current session state (injected when this skill runs): !`__PLUMBBOB_BIN__ status`
12
+
13
+ This is a **driver skill** — a chat-side trigger for the mechanical `plumbbob review` 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.
14
+
15
+ ## What it does
16
+
17
+ 1. Run `__PLUMBBOB_BIN__ review` via Bash.
18
+ 2. Report the verb's output verbatim. If the heavy check is red the verb stays in BUILD and prints the failures — relay them and stop; fixing them is the human's call, not an automatic retry.
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: pb-spike
3
+ description: Human-triggered driver for `plumbbob spike` — open a throwaway worktree experiment for a genuine fork, or tear it down with `spike done`.
4
+ disable-model-invocation: true
5
+ model: haiku
6
+ allowed-tools: Bash(__PLUMBBOB_BIN__ status:*), Bash(__PLUMBBOB_BIN__ spike:*)
7
+ ---
8
+
9
+ # Plumbbob — spike an experiment (driver)
10
+
11
+ Current session state (injected when this skill runs): !`__PLUMBBOB_BIN__ status`
12
+
13
+ This is a **driver skill** — a chat-side trigger for the mechanical `plumbbob spike` 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.
14
+
15
+ ## What it does
16
+
17
+ 1. Read the spike target from the way you were invoked: a slug to open one (e.g. `/pb-spike redis-cache`), or the literal `done` to tear the current spike down (`/pb-spike done`). If neither is present, ask which and run nothing.
18
+ 2. Run `__PLUMBBOB_BIN__ spike "<slug>"` or `__PLUMBBOB_BIN__ spike done` via Bash.
19
+ 3. Report the verb's output verbatim — the worktree it created or removed, or any refusal.
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: pb-start
3
+ description: Human-triggered driver for `plumbbob start` — scaffold a new session (.plumbbob/, STATE=DESIGN, baseline commit) without leaving the chat.
4
+ disable-model-invocation: true
5
+ model: haiku
6
+ allowed-tools: Bash(__PLUMBBOB_BIN__ status:*), Bash(__PLUMBBOB_BIN__ start:*)
7
+ ---
8
+
9
+ # Plumbbob — start a session (driver)
10
+
11
+ Current session state (injected when this skill runs): !`__PLUMBBOB_BIN__ status`
12
+
13
+ This is a **driver skill** — a chat-side trigger for the mechanical `plumbbob start` 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.
14
+
15
+ ## What it does
16
+
17
+ 1. Read the session title from the way you were invoked (e.g. `/pb-start "fix the widget"` → title `fix the widget`). If no title is present, ask for one and run nothing.
18
+ 2. Run `__PLUMBBOB_BIN__ start "<title>"` via Bash.
19
+ 3. Report the verb's output verbatim. If it refuses (e.g. a session already exists), relay that and stop.
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: pb-wrap
3
+ description: Human-triggered driver for `plumbbob wrap` — set STATE=FINISH so the report and docs skills can run.
4
+ disable-model-invocation: true
5
+ model: haiku
6
+ allowed-tools: Bash(__PLUMBBOB_BIN__ status:*), Bash(__PLUMBBOB_BIN__ wrap:*)
7
+ ---
8
+
9
+ # Plumbbob — wrap into FINISH (driver)
10
+
11
+ Current session state (injected when this skill runs): !`__PLUMBBOB_BIN__ status`
12
+
13
+ This is a **driver skill** — a chat-side trigger for the mechanical `plumbbob wrap` 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.
14
+
15
+ ## What it does
16
+
17
+ 1. Run `__PLUMBBOB_BIN__ wrap` via Bash.
18
+ 2. Report the verb's output verbatim. FINISH is what unlocks `/plumbbob-report` and `/plumbbob-docs` — next, run `/plumbbob-report`.
@@ -3,12 +3,12 @@ name: plumbbob-docs
3
3
  description: FINISH-phase, optional docs update — conservatively project canonical intent into real docs/, only when warranted.
4
4
  disable-model-invocation: true
5
5
  model: opus
6
- allowed-tools: Read, Edit, Write, Bash(plumbbob status:*)
6
+ allowed-tools: Read, Edit, Write, Bash(__PLUMBBOB_BIN__ status:*)
7
7
  ---
8
8
 
9
9
  # Plumbbob — update the docs
10
10
 
11
- Current session state (injected when this skill runs): !`plumbbob status`
11
+ Current session state (injected when this skill runs): !`__PLUMBBOB_BIN__ status`
12
12
 
13
13
  ## Wrong-state refusal
14
14
 
@@ -3,12 +3,12 @@ name: plumbbob-interrogate
3
3
  description: DESIGN-phase frame interrogation — attack the plan for holes and append them as Open questions, without deciding anything.
4
4
  disable-model-invocation: true
5
5
  model: opus
6
- allowed-tools: Read, Edit, Bash(plumbbob status:*)
6
+ allowed-tools: Read, Edit, Bash(__PLUMBBOB_BIN__ status:*)
7
7
  ---
8
8
 
9
9
  # Plumbbob — interrogate the frame
10
10
 
11
- Current session state (injected when this skill runs): !`plumbbob status`
11
+ Current session state (injected when this skill runs): !`__PLUMBBOB_BIN__ status`
12
12
 
13
13
  ## Wrong-state refusal
14
14
 
@@ -3,12 +3,12 @@ name: plumbbob-report
3
3
  description: FINISH-phase report — synthesize intent + build-log into exactly .plumbbob/report.md with the five required sections.
4
4
  disable-model-invocation: true
5
5
  model: opus
6
- allowed-tools: Read, Write, Bash(plumbbob status:*)
6
+ allowed-tools: Read, Write, Bash(__PLUMBBOB_BIN__ status:*)
7
7
  ---
8
8
 
9
9
  # Plumbbob — write the report
10
10
 
11
- Current session state (injected when this skill runs): !`plumbbob status`
11
+ Current session state (injected when this skill runs): !`__PLUMBBOB_BIN__ status`
12
12
 
13
13
  ## Wrong-state refusal
14
14
 
@@ -3,12 +3,12 @@ name: plumbbob-triage
3
3
  description: DESIGN-phase, step-boundary triage — propose one class per parked item, write only after the human confirms each.
4
4
  disable-model-invocation: true
5
5
  model: opus
6
- allowed-tools: Read, Edit, Bash(plumbbob status:*)
6
+ allowed-tools: Read, Edit, Bash(__PLUMBBOB_BIN__ status:*)
7
7
  ---
8
8
 
9
9
  # Plumbbob — triage the park list
10
10
 
11
- Current session state (injected when this skill runs): !`plumbbob status`
11
+ Current session state (injected when this skill runs): !`__PLUMBBOB_BIN__ status`
12
12
 
13
13
  ## Wrong-state refusal
14
14
 
package/src/cli.ts DELETED
@@ -1,121 +0,0 @@
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
-
6
- import { start } from './verbs/start.ts'
7
- import { status } from './verbs/status.ts'
8
- import { mode } from './verbs/mode.ts'
9
- import { park } from './verbs/park.ts'
10
- import { build } from './verbs/build.ts'
11
- import { review } from './verbs/review.ts'
12
- import { done } from './verbs/done.ts'
13
- import { revert } from './verbs/revert.ts'
14
- import { spike } from './verbs/spike.ts'
15
- import { wrap } from './verbs/wrap.ts'
16
- import { finish } from './verbs/finish.ts'
17
- import { setup } from './verbs/setup.ts'
18
-
19
- type Verb = {
20
- readonly name: string
21
- readonly summary: string
22
- }
23
-
24
- const VERBS: ReadonlyArray<Verb> = [
25
- { name: 'start', summary: 'scaffold .plumbbob/; STATE=DESIGN; record the baseline commit' },
26
- { name: 'status', summary: 'print the session state, or NO ACTIVE SESSION' },
27
- { name: 'build', summary: 'build <n>: write SEAM from step n; STATE=BUILD' },
28
- { name: 'review', summary: 'run the heavy check; if green flip to STATE=REVIEW' },
29
- { name: 'done', summary: 'ensure check green; checkpoint commit + record SHA; STATE=DESIGN' },
30
- { name: 'revert', summary: 'revert [--to n]: git reset --hard to a checkpoint SHA; STATE=DESIGN' },
31
- { name: 'park', summary: 'park "<text>": append a raw line to the park list' },
32
- { name: 'spike', summary: 'spike "<slug>" | spike done: throwaway worktree experiment' },
33
- { name: 'wrap', summary: 'set STATE=FINISH so /plumbbob-report and /plumbbob-docs can run' },
34
- { name: 'finish', summary: 'refuse unless a report is archived; archive; clear; muzzle off' },
35
- { name: 'mode', summary: 'mode <x>: set STATE directly (hidden escape hatch)' },
36
- { name: 'setup', summary: 'install hooks + skills; register them (default global; --project / --local per D27)' },
37
- ]
38
-
39
- // D21: deciding/transition verbs are human-only. In a Claude Code session
40
- // (CLAUDECODE set) the dispatch refuses them so the model cannot drive a state
41
- // transition. `park` and `status` are the deliberate exceptions — dumb capture
42
- // and read-only inspection are model-safe.
43
- const TRANSITION_VERBS: ReadonlySet<string> = new Set([
44
- 'start',
45
- 'build',
46
- 'review',
47
- 'done',
48
- 'revert',
49
- 'spike',
50
- 'wrap',
51
- 'finish',
52
- 'mode',
53
- ])
54
-
55
- function formatHelp(): string {
56
- const width = Math.max(...VERBS.map((v) => v.name.length))
57
- const rows = VERBS.map((v) => ` ${v.name.padEnd(width)} ${v.summary}`)
58
- return ['plumbbob — attention-first build process', '', 'Usage: plumbbob <verb> [args]', '', 'Verbs:', ...rows, ''].join(
59
- '\n',
60
- )
61
- }
62
-
63
- function dispatch(verb: string, cwd: string, rest: ReadonlyArray<string>): number {
64
- switch (verb) {
65
- case 'start':
66
- return start(cwd, rest)
67
- case 'status':
68
- return status(cwd)
69
- case 'mode':
70
- return mode(cwd, rest)
71
- case 'park':
72
- return park(cwd, rest)
73
- case 'build':
74
- return build(cwd, rest)
75
- case 'review':
76
- return review(cwd)
77
- case 'done':
78
- return done(cwd)
79
- case 'revert':
80
- return revert(cwd, rest)
81
- case 'spike':
82
- return spike(cwd, rest)
83
- case 'wrap':
84
- return wrap(cwd)
85
- case 'finish':
86
- return finish(cwd)
87
- case 'setup':
88
- return setup(cwd, rest)
89
- default:
90
- process.stderr.write(`plumbbob: unknown verb '${verb}'. Run 'plumbbob help' for the verb table.\n`)
91
- return 1
92
- }
93
- }
94
-
95
- function run(argv: ReadonlyArray<string>): number {
96
- const verb = argv[0] ?? 'help'
97
- const rest = argv.slice(1)
98
-
99
- if (verb === 'help' || verb === '--help' || verb === '-h') {
100
- process.stdout.write(`${formatHelp()}\n`)
101
- return 0
102
- }
103
-
104
- if (TRANSITION_VERBS.has(verb) && process.env.CLAUDECODE) {
105
- process.stderr.write(
106
- `plumbbob: '${verb}' is a deciding verb — only the human runs it (you appear to be in a Claude Code session). ` +
107
- `Do not retry. Ask the human to run \`plumbbob ${verb}\` in their terminal.\n`,
108
- )
109
- return 1
110
- }
111
-
112
- try {
113
- return dispatch(verb, process.cwd(), rest)
114
- } catch (err) {
115
- const message = err instanceof Error ? err.message : String(err)
116
- process.stderr.write(`plumbbob: ${verb} failed: ${message}\n`)
117
- return 1
118
- }
119
- }
120
-
121
- process.exit(run(process.argv.slice(2)))
@@ -1,76 +0,0 @@
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
-
6
- import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
7
- import { join } from 'node:path'
8
- import { sidecarDir, intentPath, buildLogPath } from './sidecar.ts'
9
-
10
- // report.md lives beside intent.md / build-log.md in the sidecar. It is written by
11
- // /plumbbob-report and is the gate `finish` refuses without (D19). Derived from
12
- // the exported sidecarDir so this module needs nothing new from sidecar.ts.
13
- export function reportPath(root: string): string {
14
- return join(sidecarDir(root), 'report.md')
15
- }
16
-
17
- function archiveRoot(root: string): string {
18
- return join(sidecarDir(root), 'archive')
19
- }
20
-
21
- // A filesystem-safe slug from arbitrary title text: lowercased, runs of
22
- // non-alphanumerics collapsed to a single hyphen, ends trimmed. Empty → `session`.
23
- function slugify(title: string): string {
24
- const slug = title
25
- .toLowerCase()
26
- .replace(/[^a-z0-9]+/g, '-')
27
- .replace(/^-+|-+$/g, '')
28
- return slug.length === 0 ? 'session' : slug
29
- }
30
-
31
- // The session title is intent.md's first `# ` heading (start stamps `# <title>`).
32
- function sessionTitle(root: string): string {
33
- let content = ''
34
- try {
35
- content = readFileSync(intentPath(root), 'utf8')
36
- } catch {
37
- return 'session'
38
- }
39
- for (const line of content.split('\n')) {
40
- const m = /^#\s+(.+)$/.exec(line)
41
- if (m) {
42
- return (m[1] ?? '').trim()
43
- }
44
- }
45
- return 'session'
46
- }
47
-
48
- // Today as YYYY-MM-DD (UTC); the archive directory is <date>-<slug>.
49
- function today(): string {
50
- return new Date().toISOString().slice(0, 10)
51
- }
52
-
53
- // Choose <date>-<slug>, disambiguating with -2, -3, … if a same-day, same-slug
54
- // session was already archived — so a second session lands ALONGSIDE the first,
55
- // never on top of it.
56
- function uniqueArchiveDir(root: string, base: string): string {
57
- let candidate = join(archiveRoot(root), base)
58
- let n = 2
59
- while (existsSync(candidate)) {
60
- candidate = join(archiveRoot(root), `${base}-${n}`)
61
- n += 1
62
- }
63
- return candidate
64
- }
65
-
66
- // Copy intent + build-log + report into archive/<date>-<slug>/ and return the
67
- // directory created. The report must already exist (finish guards that); intent
68
- // and build-log always exist in an active session.
69
- export function archiveSession(root: string): string {
70
- const dir = uniqueArchiveDir(root, `${today()}-${slugify(sessionTitle(root))}`)
71
- mkdirSync(dir, { recursive: true })
72
- copyFileSync(intentPath(root), join(dir, 'intent.md'))
73
- copyFileSync(buildLogPath(root), join(dir, 'build-log.md'))
74
- copyFileSync(reportPath(root), join(dir, 'report.md'))
75
- return dir
76
- }