plumbbob 0.2.1 → 0.3.0

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 (40) hide show
  1. package/README.md +63 -143
  2. package/dist/cli.js +18 -37
  3. package/dist/lib/archive.js +6 -3
  4. package/dist/lib/git.js +0 -4
  5. package/dist/lib/intent.js +1 -1
  6. package/dist/lib/orient.js +160 -0
  7. package/dist/lib/settings.js +12 -8
  8. package/dist/lib/sidecar.js +0 -3
  9. package/dist/verbs/build.js +1 -1
  10. package/dist/verbs/check.js +17 -0
  11. package/dist/verbs/checkpoint.js +83 -0
  12. package/dist/verbs/reset.js +54 -0
  13. package/dist/verbs/revert.js +43 -3
  14. package/dist/verbs/setup.js +1 -1
  15. package/dist/verbs/status.js +23 -4
  16. package/package.json +2 -2
  17. package/skills/pb-build/SKILL.md +39 -9
  18. package/skills/pb-harvest/SKILL.md +47 -0
  19. package/skills/pb-park/SKILL.md +40 -0
  20. package/skills/pb-plan/SKILL.md +38 -0
  21. package/skills/pb-reset/SKILL.md +39 -0
  22. package/skills/pb-status/SKILL.md +22 -0
  23. package/skills/pb-step/SKILL.md +37 -0
  24. package/skills/pb-verify/SKILL.md +47 -0
  25. package/dist/verbs/done.js +0 -63
  26. package/dist/verbs/finish.js +0 -54
  27. package/dist/verbs/mode.js +0 -26
  28. package/dist/verbs/review.js +0 -24
  29. package/dist/verbs/wrap.js +0 -28
  30. package/hooks/bash-guard.sh +0 -62
  31. package/hooks/pre-edit.sh +0 -95
  32. package/skills/park/SKILL.md +0 -26
  33. package/skills/pb-done/SKILL.md +0 -18
  34. package/skills/pb-finish/SKILL.md +0 -18
  35. package/skills/pb-review/SKILL.md +0 -18
  36. package/skills/pb-start/SKILL.md +0 -19
  37. package/skills/pb-wrap/SKILL.md +0 -18
  38. package/skills/plumbbob-docs/SKILL.md +0 -25
  39. package/skills/plumbbob-report/SKILL.md +0 -35
  40. package/skills/plumbbob-triage/SKILL.md +0 -38
package/README.md CHANGED
@@ -1,179 +1,99 @@
1
1
  # Plumbbob
2
2
 
3
- A manual, attention-first build process that enforces the deciding/executing
4
- boundary so you build *with* an LLM instead of being dragged behind one. You
5
- decide on a surface outside the chat; a one-word state file and a set of Claude
6
- Code hooks refuse to let code edits cross that line until you have.
3
+ A guidance-first build process for working *with* an LLM instead of being dragged behind one. You decide on a surface outside the chat; plumbbob orients you, runs each step's labor, and then **stops and waits for you to advance** — the human is the clock, not a lock.
7
4
 
8
- The full philosophy the one law, the mode machine, the loop — lives in
9
- [`docs/plumbbob-README.md`](docs/plumbbob-README.md). This file is the
10
- as-built reference: what ships, how to install it, and the verbs you type.
5
+ > Ridgeline is the line. Plumbbob establishes *true* before you build.
6
+ > The LLM is a hand, not a head.
11
7
 
12
- This repository was built using Plumbbob, dogfooded on its own build under its
13
- own enforcement from step 5 onward.
8
+ This repository was built using Plumbbob v2, dogfooded on its own build under its own loop.
9
+
10
+ ## The shift: a clock, not a lock
11
+
12
+ Plumbbob v1 enforced the deciding/executing boundary with a hard file lock (a pre-edit muzzle). v2 replaces the lock with a **clock**: nothing blocks your edits. Instead, the system does a step's work, then pauses at a verify gate for your approval before it checkpoints. You stay the decider by *advancing the clock*, not by fighting a wall.
13
+
14
+ - The **human** owns convergence — you decide, you approve, you advance.
15
+ - The **plan** lives on durable surfaces (`intent.md`, `build-log.md`), not in the chat.
16
+ - The boundary is a **pause**, not a refusal: the system pulls up to a line and waits.
17
+
18
+ ## The eight skills
19
+
20
+ You drive the whole loop from your IDE with eight `pb-*` skills — no step numbers to remember, no raw CLI to type. `/pb-status` always names your next move.
21
+
22
+ | Skill | Does |
23
+ |-------|------|
24
+ | `/pb-plan` | frame a goal — scaffold the session + author intent's Frame, Decisions, Constraints |
25
+ | `/pb-step` | plan the next increment — a title, a done-when, a seam |
26
+ | `/pb-build` | *(optional)* implement the planned step, then verify it to the pause |
27
+ | `/pb-verify` | the tick — check → self-review → validate → **PAUSE** → checkpoint |
28
+ | `/pb-park` | capture an idea without chasing it |
29
+ | `/pb-status` | orient — where you are, what's parked, and the next move |
30
+ | `/pb-harvest` | triage parked ideas at a boundary (blocker / tangent / pivot) |
31
+ | `/pb-reset` | close out — write the report, archive, clear for a fresh goal |
32
+
33
+ Three optional power moves survive for when you need them: `/pb-revert` (recover to a checkpoint), `/pb-spike` (throwaway worktree experiment), and `/plumbbob-interrogate` (attack the frame for holes).
34
+
35
+ ## The loop
36
+
37
+ ```
38
+ /pb-plan frame the goal (once)
39
+ └ per step:
40
+ /pb-status "what's next?"
41
+ /pb-step plan the next increment
42
+ /pb-build (or DIY) implement it
43
+ /pb-verify check → review → PAUSE → checkpoint
44
+ /pb-park capture strays mid-build
45
+ /pb-harvest triage them at a boundary
46
+ /pb-reset report + archive + clear (once)
47
+ ```
48
+
49
+ **The pluggable executor.** `/pb-build` is one way to turn a planned step into code — it is *optional*. Implement by hand, in a vibe session, or with another harness, and run `/pb-verify` instead: it reads the *diff, not the author*. Plumbbob is the harness-agnostic spine; how the diff appears is a slot you fill however you like.
14
50
 
15
51
  ## What ships
16
52
 
17
- - A `plumbbob` CLI (TypeScript, run natively by Node ≥ 22.18, zero runtime
18
- dependencies)the dumb mechanical verbs that move the state machine and the
19
- git checkpoints.
20
- - Three session-gated Claude Code hooks a pre-edit muzzle + seam-guard, a Bash
21
- guard, and a non-blocking light-feedback pass. With no active session they
22
- short-circuit to allow in microseconds, so a repo without a Plumbbob session
23
- behaves like plain Claude Code.
24
- - The judgment skills — the design/finish thinking work, each human-triggered
25
- (`disable-model-invocation: true`): `/plumbbob-interrogate`, `/park`,
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.
31
- - A `.plumbbob/` sidecar of flat control files (`STATE`, `SEAM`, `checkpoints`,
32
- `intent.md`, `build-log.md`) that the hooks read with a grep.
53
+ - A `plumbbob` CLI (TypeScript, run natively by Node ≥ 22.18, zero runtime dependencies) — the dumb mechanical verbs the skills shell out to. You never type it by hand.
54
+ - One session-gated Claude Code hook `post-edit.sh`, a non-blocking light-feedback pass that injects file-scoped lint into the model's context. (v1's pre-edit muzzle and bash-guard are gone — guidance, not enforcement.)
55
+ - The eight `pb-*` skills plus the optional power moves, each `disable-model-invocation` so *you* fire every move.
56
+ - A `.plumbbob/` sidecar of flat files: `STATE` (orientation only), `intent.md`, `build-log.md`, `checkpoints`, and `archive/`.
33
57
 
34
58
  ## Install
35
59
 
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 `~`
60
+ The npm package is `plumbbob`; it installs the CLI (`plumbbob` plus a `pb` shorthand) and ships the hook and skills inside the package.
41
61
 
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:
62
+ ### Project-level (recommended)
44
63
 
45
64
  ```sh
46
65
  pnpm add -D plumbbob
47
- pnpm exec plumbbob setup --local # or just `setup` it auto-detects a project-local install
66
+ pnpm exec plumbbob setup --local # or --project to commit it for a team
48
67
  ```
49
68
 
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/`.
54
-
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) |
59
-
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.
69
+ This copies the skills into `<repo>/.claude/skills/` (their bin pointed at the project-local binary) and registers the post-edit hook in place under `node_modules`. Nothing is written under `~`.
63
70
 
64
- ### Global — one install, every repo
71
+ ### Global
65
72
 
66
73
  ```sh
67
74
  npm install -g plumbbob
68
75
  plumbbob setup --global
69
76
  ```
70
77
 
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:
78
+ This copies the hook and skills under `~/.claude/` and registers them in `~/.claude/settings.json`. Restart Claude Code (or reload settings) after install.
75
79
 
76
- ```sh
77
- alias plumbbob='node /path/to/plumbbob/src/cli.ts'
78
- ```
80
+ ## STATE is orientation, not a gate
81
+
82
+ The one-word `.plumbbob/STATE` (`DESIGN` / `BUILD` / `SPIKE`) is read by `/pb-status` to tell you where you are and what to do next. It no longer gates edits — a wrong state is a mislabeled position on a map, not a locked door. The post-edit hook is session-gated: a repo with no `.plumbbob/STATE` behaves exactly like plain Claude Code.
83
+
84
+ ## Git footprint — additive only
79
85
 
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
-
84
- ## The verbs and skills
85
-
86
- Mechanical verbs are the `plumbbob` CLI, run from your terminal. The judgment
87
- work is skills, invoked from the chat pane. The split is judgment-vs-mechanism.
88
-
89
- | Verb / skill | Does | Kind |
90
- |----------------------------|------------------------------------------------------------------|---------------|
91
- | `plumbbob start "<t>"` | scaffold `.plumbbob/`; `STATE=DESIGN`; record the baseline | CLI |
92
- | `/plumbbob-interrogate` | `DESIGN`; attack the frame for holes; append to Open questions | skill (Opus) |
93
- | `plumbbob build <n>` | write `SEAM` from step n; `STATE=BUILD` | CLI |
94
- | `plumbbob review` | run the heavy check; if green → `STATE=REVIEW` | CLI |
95
- | `plumbbob done` | ensure check green; checkpoint commit + record SHA; `STATE=DESIGN` | CLI |
96
- | `plumbbob park "<text>"` | append a raw line to the park list; the model never sees it | CLI (dumb) |
97
- | `/park` | compose one tidy tagged line, you approve, then it shells `park` | skill (Haiku) |
98
- | `/plumbbob-triage` | `DESIGN`; classify the park list blocker/tangent/pivot | skill (Opus) |
99
- | `plumbbob revert [--to n]`| `git reset --hard` to a checkpoint SHA; `STATE=DESIGN` | CLI |
100
- | `plumbbob wrap` | `STATE=FINISH` so the report and docs skills can run | CLI |
101
- | `/plumbbob-report` | `FINISH`; write `.plumbbob/report.md` from intent + log | skill (Opus) |
102
- | `/plumbbob-docs` | `FINISH`; conservatively update `docs/` from canonical intent | skill (Opus) |
103
- | `plumbbob finish` | refuse unless a report exists; archive; clear; muzzle off | CLI |
104
- | `plumbbob spike "<slug>"` | throwaway worktree experiment per option; `spike done` tears down | CLI |
105
- | `plumbbob mode <x>` | escape hatch: set `STATE` directly (not part of the normal flow) | CLI (hidden) |
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.
126
-
127
- ## The core / adapter boundary
128
-
129
- The deciding/executing core — the `plumbbob` CLI, the `.plumbbob/` sidecar, and
130
- the additive git footprint — is agent- and model-agnostic. Only the enforcement
131
- layer (the hooks, the skills, the `CLAUDECODE` verb guard) is specific to Claude
132
- Code, because enforcement inherently means intercepting some agent's edit tool.
133
- Claude Code is v1's enforcement adapter; other-agent adapters over the same core
134
- are a future Plumbbob, not a v1 obligation.
135
-
136
- ## Ratified residual gaps
137
-
138
- These are deliberate v1 boundaries, decided on the record, not oversights:
139
-
140
- - **The archive is local-only.** `finish` lists the checkpoint SHAs in the report
141
- and archives plain markdown under `.plumbbob/archive/`; it never touches git
142
- (D20). The sidecar is kept untracked so a revert can never destroy captured
143
- attention.
144
- - **The muzzle is a fence, not a wall.** The Bash guard blocks the obvious
145
- shell-write escape routes around the edit hooks, but full shell-write detection
146
- is unsolvable; a determined model can still get around it (D21). The fence
147
- raises the cost of crossing the boundary by accident, which is the job.
148
- - **`tsc` is deferred to the heavy tier.** The light post-edit feedback runs
149
- file-scoped oxlint + ast-grep only; `tsc` has no true single-file mode, so it
150
- runs in the `pnpm check` gate inside `review`/`done` rather than per keystroke
151
- (D25).
86
+ Plumbbob commits cheap checkpoint markers (`plumbbob: step n done`) on your feature branch and reverts to its own recorded SHAs. It never rewrites pushed history; your normal squash-merge collapses the checkpoints at PR time. `reset` archives plain markdown under `.plumbbob/archive/` and never touches git.
152
87
 
153
88
  ## Development
154
89
 
155
90
  ```sh
156
91
  pnpm install
157
92
  pnpm check # tsc, oxlint, ast-grep, vitest, knip, markdownlint
158
- pnpm build # emit dist/ (what the published bin runs); prepack runs this for you
93
+ pnpm build # emit dist/ (what the published bin runs)
159
94
  ```
160
95
 
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
-
167
- **The pnpm pin.** `package.json` pins `devEngines.packageManager` to an *exact*
168
- pnpm version (`11.1.2`) rather than a range, because the `^` range broke every
169
- pnpm command at the time of writing. That exact pin will need bumping by hand on
170
- the next pnpm upgrade; moving back to a ranged constraint is a future revisit.
171
-
172
- `scripts/dev-install.sh` is the development installer: it registers the hooks
173
- pointing at this working tree's `hooks/` (so hook edits take effect with no
174
- re-copy), where `plumbbob setup --global` copies them into `~/.claude/`. Use
175
- `dev-install.sh` while hacking on the hooks themselves; use `plumbbob setup` to
176
- install Plumbbob for real.
96
+ The underlying philosophy attention as the scarce resource lives in [`docs/attention-first-development.md`](docs/attention-first-development.md).
177
97
 
178
98
  ## License
179
99
 
package/dist/cli.js CHANGED
@@ -4,41 +4,31 @@
4
4
  // export (C1). Verbs are wired up build step by build step.
5
5
  import { start } from "./verbs/start.js";
6
6
  import { status } from "./verbs/status.js";
7
- import { mode } from "./verbs/mode.js";
8
7
  import { park } from "./verbs/park.js";
9
8
  import { build } from "./verbs/build.js";
10
- import { review } from "./verbs/review.js";
11
- import { done } from "./verbs/done.js";
9
+ import { check } from "./verbs/check.js";
10
+ import { checkpoint } from "./verbs/checkpoint.js";
12
11
  import { revert } from "./verbs/revert.js";
13
12
  import { spike } from "./verbs/spike.js";
14
- import { wrap } from "./verbs/wrap.js";
15
- import { finish } from "./verbs/finish.js";
13
+ import { reset } from "./verbs/reset.js";
16
14
  import { setup } from "./verbs/setup.js";
17
15
  const VERBS = [
18
16
  { name: 'start', summary: 'scaffold .plumbbob/; STATE=DESIGN; record the baseline commit' },
19
17
  { 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' },
18
+ { name: 'build', summary: 'build <n>: write the seam from step n; STATE=BUILD (orientation, not a lock)' },
19
+ { name: 'check', summary: 'run the heavy check and report; no state change' },
20
+ { name: 'checkpoint', summary: 'checkpoint [<n>]: gate on green, commit/record SHA, mark step done, STATE=DESIGN (executor-agnostic)' },
23
21
  { name: 'revert', summary: 'revert [--to n]: git reset --hard to a checkpoint SHA; STATE=DESIGN' },
24
22
  { name: 'park', summary: 'park "<text>": append a raw line to the park list' },
25
23
  { 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)' },
24
+ { name: 'reset', summary: 'v2 close-out: archive intent+log+report (no gate), clear the sidecar, STATE off' },
29
25
  { name: 'setup', summary: 'install hooks + skills; register them (self-contained per-project by default; --global for ~/.claude)' },
30
26
  ];
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']);
27
+ // Plumbbob v2 (D1/D10/D13): the deciding/executing boundary is no longer a lock,
28
+ // so there is nothing to defend — every verb runs the same whether a human or the
29
+ // model triggers it. The old human-only `mode` escape hatch and its CLAUDECODE
30
+ // in-session refusal are gone; what keeps the human the decider is the pause at
31
+ // the step boundary (the skills), not a refusal here.
42
32
  function formatHelp() {
43
33
  const width = Math.max(...VERBS.map((v) => v.name.length));
44
34
  const rows = VERBS.map((v) => ` ${v.name.padEnd(width)} ${v.summary}`);
@@ -50,24 +40,20 @@ function dispatch(verb, cwd, rest) {
50
40
  return start(cwd, rest);
51
41
  case 'status':
52
42
  return status(cwd);
53
- case 'mode':
54
- return mode(cwd, rest);
55
43
  case 'park':
56
44
  return park(cwd, rest);
57
45
  case 'build':
58
46
  return build(cwd, rest);
59
- case 'review':
60
- return review(cwd);
61
- case 'done':
62
- return done(cwd);
47
+ case 'check':
48
+ return check(cwd);
49
+ case 'checkpoint':
50
+ return checkpoint(cwd, rest);
63
51
  case 'revert':
64
52
  return revert(cwd, rest);
65
53
  case 'spike':
66
54
  return spike(cwd, rest);
67
- case 'wrap':
68
- return wrap(cwd);
69
- case 'finish':
70
- return finish(cwd);
55
+ case 'reset':
56
+ return reset(cwd);
71
57
  case 'setup':
72
58
  return setup(cwd, rest);
73
59
  default:
@@ -82,11 +68,6 @@ function run(argv) {
82
68
  process.stdout.write(`${formatHelp()}\n`);
83
69
  return 0;
84
70
  }
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
71
  try {
91
72
  return dispatch(verb, process.cwd(), rest);
92
73
  }
@@ -57,13 +57,16 @@ function uniqueArchiveDir(root, base) {
57
57
  return candidate;
58
58
  }
59
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.
60
+ // directory created. Intent and build-log always exist in an active session; the
61
+ // report is copied only when present v2 `reset` does not gate on it (D9), so a
62
+ // close-out without a report still archives the rest.
62
63
  export function archiveSession(root) {
63
64
  const dir = uniqueArchiveDir(root, `${today()}-${slugify(sessionTitle(root))}`);
64
65
  mkdirSync(dir, { recursive: true });
65
66
  copyFileSync(intentPath(root), join(dir, 'intent.md'));
66
67
  copyFileSync(buildLogPath(root), join(dir, 'build-log.md'));
67
- copyFileSync(reportPath(root), join(dir, 'report.md'));
68
+ if (existsSync(reportPath(root))) {
69
+ copyFileSync(reportPath(root), join(dir, 'report.md'));
70
+ }
68
71
  return dir;
69
72
  }
package/dist/lib/git.js CHANGED
@@ -43,10 +43,6 @@ export function isDirty(root) {
43
43
  export function stageAll(root) {
44
44
  runGit(root, ['add', '-A']);
45
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
46
  export function untrackedPaths(root) {
51
47
  const out = runGit(root, ['ls-files', '--others', '--exclude-standard']);
52
48
  return out.length === 0 ? [] : out.split('\n');
@@ -79,7 +79,7 @@ export function parseStepSeam(content, step) {
79
79
  }
80
80
  // Seam membership (D23): a repo-relative path is in-seam if it equals an exact
81
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.
82
+ // and `revert` (untracked cleanup).
83
83
  export function matchesSeam(relPath, tokens) {
84
84
  return tokens.some((token) => (token.endsWith('/') ? relPath.startsWith(token) : relPath === token));
85
85
  }
@@ -0,0 +1,160 @@
1
+ // Orientation model for `status` (D8/D15): parse a live session into the
2
+ // dashboard the human reads to know where they are and what to do next. Pure and
3
+ // best-effort — it takes raw file contents (no fs), and a malformed doc degrades
4
+ // to fewer fields rather than throwing. Functional/procedural, no classes, no
5
+ // default export (C1).
6
+ // The lines of a named `## Section`, from its heading to the next `## ` (or EOF).
7
+ function sectionLines(content, heading) {
8
+ const lines = content.split('\n');
9
+ const start = lines.findIndex((l) => l.trim() === heading);
10
+ if (start === -1) {
11
+ return [];
12
+ }
13
+ let end = lines.findIndex((l, i) => i > start && l.startsWith('## '));
14
+ if (end === -1) {
15
+ end = lines.length;
16
+ }
17
+ return lines.slice(start + 1, end);
18
+ }
19
+ export function parseTitle(intent) {
20
+ for (const line of intent.split('\n')) {
21
+ const m = /^#\s+(.+?)\s*$/.exec(line);
22
+ if (m) {
23
+ return m[1] ?? null;
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+ // Steps under `## Steps`: `N. [ |x] Title — **done when:** ...`. The title is the
29
+ // text up to the first em dash; `planned` is true when the step's block carries a
30
+ // `done when` criterion. Roadmap prose lives in its own section, never here (D6).
31
+ export function parseSteps(intent) {
32
+ const lines = sectionLines(intent, '## Steps');
33
+ const starts = [];
34
+ lines.forEach((line, idx) => {
35
+ const m = /^(\d+)\.\s+\[([ xX])\]\s+(.*)$/.exec(line);
36
+ if (m) {
37
+ const rest = m[3] ?? '';
38
+ starts.push({
39
+ n: Number(m[1]),
40
+ done: (m[2] ?? ' ').toLowerCase() === 'x',
41
+ title: (rest.split('—')[0] ?? rest).trim(),
42
+ idx,
43
+ });
44
+ }
45
+ });
46
+ return starts.map((s, i) => {
47
+ const blockEnd = starts[i + 1]?.idx ?? lines.length;
48
+ const block = lines.slice(s.idx, blockEnd).join('\n').toLowerCase();
49
+ return { n: s.n, done: s.done, title: s.title, planned: block.includes('done when') };
50
+ });
51
+ }
52
+ // Flip step N's `[ ]` checkbox to `[x]` within the `## Steps` section — mechanical
53
+ // bookkeeping for `checkpoint` so `status` reflects a checkpointed step. A no-op if
54
+ // the step is absent or already done.
55
+ export function markStepDone(intent, n) {
56
+ let inSteps = false;
57
+ return intent
58
+ .split('\n')
59
+ .map((line) => {
60
+ if (line.trim() === '## Steps') {
61
+ inSteps = true;
62
+ return line;
63
+ }
64
+ if (inSteps && line.startsWith('## ')) {
65
+ inSteps = false;
66
+ }
67
+ return inSteps && new RegExp(`^${n}\\.\\s+\\[ \\]`).test(line) ? line.replace('[ ]', '[x]') : line;
68
+ })
69
+ .join('\n');
70
+ }
71
+ // Open questions still open: `- Q\d+:` lines that do not say "resolved".
72
+ export function parseOpenQuestions(intent) {
73
+ return sectionLines(intent, '## Open questions').filter((l) => /^- Q\d+:/.test(l.trim()) && !/resolved/i.test(l))
74
+ .length;
75
+ }
76
+ // Open parked items: `- [ ]` lines under `## Park list` (the `park` verb's format).
77
+ // A harvested item is flipped to `- [x]` by `/pb-harvest` and no longer counts; the
78
+ // `(none yet)` placeholder and the blockquote instructions never match.
79
+ export function parseParked(buildLog) {
80
+ return sectionLines(buildLog, '## Park list').filter((l) => /^-\s+\[ \]\s+\S/.test(l.trim())).length;
81
+ }
82
+ export function parseLastCheckpoint(checkpoints) {
83
+ let last = null;
84
+ for (const line of checkpoints.split('\n')) {
85
+ const m = /^step\s+(\d+)\s+(\S+)/.exec(line.trim());
86
+ if (m) {
87
+ last = { n: Number(m[1]), sha: m[2] ?? '' };
88
+ }
89
+ }
90
+ return last;
91
+ }
92
+ // The single primary next move (D15). It suggests; the dashboard prints the full
93
+ // list + counts so the human can always override.
94
+ function nextMove(state, steps, inFlight, parked) {
95
+ switch (state) {
96
+ case 'SPIKE':
97
+ return 'close the spike — `plumbbob spike done`';
98
+ case 'FINISH':
99
+ return 'wrap up — `/pb-reset`';
100
+ case 'REVIEW':
101
+ return 'read the diff cold against intent, then `/pb-verify`';
102
+ case 'BUILD': {
103
+ const n = inFlight ?? steps.find((s) => !s.done)?.n;
104
+ return n === undefined
105
+ ? 'finish the step in flight — `/pb-verify`'
106
+ : `finish step ${n} — \`/pb-verify\` (or keep editing, then \`/pb-verify\`)`;
107
+ }
108
+ default: {
109
+ // DESIGN (and any unknown state): you are at the boundary.
110
+ const nextUndone = steps.find((s) => !s.done);
111
+ if (nextUndone === undefined) {
112
+ if (steps.length === 0) {
113
+ return 'plan the first step — `/pb-step`';
114
+ }
115
+ // Just-in-time (D6): finishing the *planned* steps usually means "plan the
116
+ // next," not "done" — only the human knows which, so offer both.
117
+ const harvest = parked > 0 ? `harvest ${parked} parked idea${parked === 1 ? '' : 's'} — \`/pb-harvest\`; then ` : '';
118
+ return `${harvest}plan the next step — \`/pb-step\` (or \`/pb-reset\` to wrap up if you're done)`;
119
+ }
120
+ return nextUndone.planned
121
+ ? `build step ${nextUndone.n} — \`/pb-build\``
122
+ : `plan step ${nextUndone.n} — \`/pb-step\``;
123
+ }
124
+ }
125
+ }
126
+ export function orient(input) {
127
+ const steps = parseSteps(input.intent);
128
+ const parked = parseParked(input.buildLog);
129
+ return {
130
+ title: parseTitle(input.intent),
131
+ state: input.state,
132
+ steps,
133
+ lastCheckpoint: parseLastCheckpoint(input.checkpoints),
134
+ parked,
135
+ openQuestions: parseOpenQuestions(input.intent),
136
+ next: nextMove(input.state, steps, input.inFlight, parked),
137
+ };
138
+ }
139
+ export function formatOrientation(o) {
140
+ const doneCount = o.steps.filter((s) => s.done).length;
141
+ const nextUndone = o.steps.find((s) => !s.done);
142
+ const stepLines = o.steps.map((s) => {
143
+ const marker = s.done ? '✓' : s === nextUndone ? '▸' : ' ';
144
+ const tail = s === nextUndone ? ' ← next' : '';
145
+ return ` ${marker} ${s.n} ${s.title}${tail}`;
146
+ });
147
+ const stepsBlock = o.steps.length === 0 ? ' (no steps planned yet)' : ` steps ${doneCount}/${o.steps.length} done\n${stepLines.join('\n')}`;
148
+ const cp = o.lastCheckpoint;
149
+ const cpLine = cp === null ? 'last checkpoint none yet' : `last checkpoint step ${cp.n} · ${cp.sha.slice(0, 7)}`;
150
+ return [
151
+ `Plumbbob — ${o.title ?? '(untitled)'} [${o.state}]`,
152
+ '',
153
+ stepsBlock,
154
+ '',
155
+ cpLine,
156
+ `parked ${o.parked} · open questions ${o.openQuestions}`,
157
+ '',
158
+ `next → ${o.next}`,
159
+ ].join('\n');
160
+ }
@@ -11,15 +11,18 @@ import { dirname } from 'node:path';
11
11
  // `$CLAUDE_PROJECT_DIR/node_modules/plumbbob/hooks/...` reference both contain
12
12
  // `plumbbob/hooks/`, so uninstall finds either.
13
13
  const OURS_MARKER = 'plumbbob/hooks/';
14
- // The PreToolUse edit matcher covers all four editing tools (D5).
14
+ // The edit matcher covers all four editing tools (D5); v2 uses it for the
15
+ // post-edit feedback hook (the only edit-time hook left).
15
16
  const EDIT_MATCHER = 'Edit|Write|MultiEdit|NotebookEdit';
16
- // The three hook registration entries, pointing at `hooksDir`. Two command forms:
17
+ // The single hook Plumbbob v2 registers: the PostToolUse light feedback (D11/D25).
18
+ // v1's PreToolUse muzzle + bash-guard are gone (D1/D13); the merge still STRIPS any
19
+ // leftover PreToolUse entry from an older install, it just no longer adds one. Two
20
+ // command forms:
17
21
  // global — a direct absolute path to the +x copy under ~/.claude.
18
22
  // self (viaSh) — `sh "<dir>/<file>"` against the package's hooks/ under
19
23
  // node_modules, addressed through `$CLAUDE_PROJECT_DIR` so the
20
24
  // committed/portable form carries no machine-absolute path and
21
25
  // needs no execute bit (pnpm's store may not preserve one).
22
- // Mirrors the PreToolUse/PostToolUse shape dev-install wrote.
23
26
  function registrationEntries(hooksDir, viaSh) {
24
27
  const dir = hooksDir.endsWith('/') ? hooksDir.slice(0, -1) : hooksDir;
25
28
  const command = (file) => (viaSh ? `sh "${dir}/${file}"` : `${dir}/${file}`);
@@ -27,10 +30,7 @@ function registrationEntries(hooksDir, viaSh) {
27
30
  matcher,
28
31
  hooks: [{ type: 'command', command: command(file) }],
29
32
  });
30
- return {
31
- PreToolUse: [entry(EDIT_MATCHER, 'pre-edit.sh'), entry('Bash', 'bash-guard.sh')],
32
- PostToolUse: [entry(EDIT_MATCHER, 'post-edit.sh')],
33
- };
33
+ return { PostToolUse: [entry(EDIT_MATCHER, 'post-edit.sh')] };
34
34
  }
35
35
  function isOurs(entry) {
36
36
  return (entry.hooks ?? []).some((h) => h.command.includes(OURS_MARKER));
@@ -45,7 +45,11 @@ export function mergeRegistration(settings, hooksDir, viaSh = false) {
45
45
  const entries = registrationEntries(hooksDir, viaSh);
46
46
  const existing = settings.hooks ?? {};
47
47
  const hooks = { ...existing };
48
- hooks.PreToolUse = [...stripOurs(existing.PreToolUse), ...entries.PreToolUse];
48
+ // Strip any leftover v1 PreToolUse muzzle/bash-guard from an older install, but
49
+ // add back only the PostToolUse feedback hook — v2 registers no PreToolUse.
50
+ if (existing.PreToolUse !== undefined) {
51
+ hooks.PreToolUse = stripOurs(existing.PreToolUse);
52
+ }
49
53
  hooks.PostToolUse = [...stripOurs(existing.PostToolUse), ...entries.PostToolUse];
50
54
  return { ...settings, hooks };
51
55
  }
@@ -5,9 +5,6 @@ import { existsSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs
5
5
  import { join } from 'node:path';
6
6
  import { gitDir } from "./git.js";
7
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
8
  export function sidecarDir(root) {
12
9
  return join(root, DIRNAME);
13
10
  }
@@ -25,6 +25,6 @@ export function build(cwd, args) {
25
25
  writeFileSync(seamPath(root), `${parsed.seam.join('\n')}\n`);
26
26
  writeFileSync(stepPath(root), `${step}\n`);
27
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`);
28
+ process.stdout.write(`plumbbob: building step ${step} — STATE=BUILD. Seam (for orientation; not a lock in v2):\n${parsed.seam.map((p) => ` ${p}`).join('\n')}\n`);
29
29
  return 0;
30
30
  }
@@ -0,0 +1,17 @@
1
+ // `plumbbob check` — run the heavy gate (D16/D24) and report, with NO state
2
+ // change. The read-only half of the verify tick: `/pb-verify` runs this before the
3
+ // pause so the human approves on a known-green check. Exits with the check's own
4
+ // code (0 = green).
5
+ import { findRepoRoot } from "../lib/git.js";
6
+ import { hasSession } from "../lib/sidecar.js";
7
+ import { runCheck } from "../lib/check.js";
8
+ export function check(cwd) {
9
+ const root = findRepoRoot(cwd);
10
+ if (root === null || !hasSession(root)) {
11
+ process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
12
+ return 1;
13
+ }
14
+ const code = runCheck(root);
15
+ process.stdout.write(code === 0 ? '\nplumbbob: check green.\n' : '\nplumbbob: check RED — fix it before checkpointing.\n');
16
+ return code;
17
+ }