plumbbob 0.2.3 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -141
- package/dist/cli.js +22 -37
- package/dist/lib/archive.js +6 -3
- package/dist/lib/git.js +0 -4
- package/dist/lib/intent.js +1 -1
- package/dist/lib/orient.js +160 -0
- package/dist/lib/settings.js +12 -8
- package/dist/lib/sidecar.js +0 -3
- package/dist/verbs/build.js +1 -1
- package/dist/verbs/check.js +17 -0
- package/dist/verbs/checkpoint.js +83 -0
- package/dist/verbs/doctor.js +172 -0
- package/dist/verbs/reset.js +54 -0
- package/dist/verbs/setup.js +31 -9
- package/dist/verbs/status.js +23 -4
- package/package.json +2 -2
- package/skills/pb-build/SKILL.md +40 -10
- package/skills/pb-harvest/SKILL.md +47 -0
- package/skills/pb-park/SKILL.md +40 -0
- package/skills/pb-plan/SKILL.md +38 -0
- package/skills/pb-reset/SKILL.md +39 -0
- package/skills/pb-revert/SKILL.md +1 -1
- package/skills/pb-spike/SKILL.md +1 -1
- package/skills/pb-status/SKILL.md +22 -0
- package/skills/pb-step/SKILL.md +37 -0
- package/skills/pb-verify/SKILL.md +47 -0
- package/skills/plumbbob-interrogate/SKILL.md +1 -1
- package/dist/verbs/done.js +0 -63
- package/dist/verbs/finish.js +0 -54
- package/dist/verbs/mode.js +0 -26
- package/dist/verbs/review.js +0 -24
- package/dist/verbs/wrap.js +0 -28
- package/hooks/bash-guard.sh +0 -62
- package/hooks/pre-edit.sh +0 -112
- package/skills/park/SKILL.md +0 -26
- package/skills/pb-done/SKILL.md +0 -18
- package/skills/pb-finish/SKILL.md +0 -18
- package/skills/pb-review/SKILL.md +0 -18
- package/skills/pb-start/SKILL.md +0 -19
- package/skills/pb-wrap/SKILL.md +0 -18
- package/skills/plumbbob-docs/SKILL.md +0 -25
- package/skills/plumbbob-report/SKILL.md +0 -35
- package/skills/plumbbob-triage/SKILL.md +0 -38
package/README.md
CHANGED
|
@@ -1,179 +1,107 @@
|
|
|
1
1
|
# Plumbbob
|
|
2
2
|
|
|
3
|
-
A
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
|
66
|
+
pnpm exec plumbbob setup --local # or --project to commit it for a team
|
|
48
67
|
```
|
|
49
68
|
|
|
50
|
-
|
|
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
|
|
71
|
+
### Global
|
|
65
72
|
|
|
66
73
|
```sh
|
|
67
74
|
npm install -g plumbbob
|
|
68
75
|
plumbbob setup --global
|
|
69
76
|
```
|
|
70
77
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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.
|
|
79
|
+
|
|
80
|
+
### Verify the install
|
|
75
81
|
|
|
76
82
|
```sh
|
|
77
|
-
|
|
83
|
+
pnpm exec plumbbob doctor # or `plumbbob doctor` for a global install
|
|
78
84
|
```
|
|
79
85
|
|
|
80
|
-
`
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
`doctor` checks the four things that must be true — the skills are present, their bin resolves, the CLI is installed, and the post-edit hook is registered — and prints the exact fix for anything broken. Run it first if a `/pb-*` skill ever opens with an empty dashboard.
|
|
87
|
+
|
|
88
|
+
## STATE is orientation, not a gate
|
|
89
|
+
|
|
90
|
+
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.
|
|
91
|
+
|
|
92
|
+
## Git footprint — additive only
|
|
93
|
+
|
|
94
|
+
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
95
|
|
|
153
96
|
## Development
|
|
154
97
|
|
|
155
98
|
```sh
|
|
156
99
|
pnpm install
|
|
157
100
|
pnpm check # tsc, oxlint, ast-grep, vitest, knip, markdownlint
|
|
158
|
-
pnpm build # emit dist/ (what the published bin runs)
|
|
101
|
+
pnpm build # emit dist/ (what the published bin runs)
|
|
159
102
|
```
|
|
160
103
|
|
|
161
|
-
The
|
|
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.
|
|
104
|
+
The underlying philosophy — attention as the scarce resource — lives in [`docs/attention-first-development.md`](docs/attention-first-development.md).
|
|
177
105
|
|
|
178
106
|
## License
|
|
179
107
|
|
package/dist/cli.js
CHANGED
|
@@ -4,41 +4,33 @@
|
|
|
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 {
|
|
11
|
-
import {
|
|
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 {
|
|
15
|
-
import { finish } from "./verbs/finish.js";
|
|
13
|
+
import { reset } from "./verbs/reset.js";
|
|
16
14
|
import { setup } from "./verbs/setup.js";
|
|
15
|
+
import { doctor } from "./verbs/doctor.js";
|
|
17
16
|
const VERBS = [
|
|
18
17
|
{ name: 'start', summary: 'scaffold .plumbbob/; STATE=DESIGN; record the baseline commit' },
|
|
19
18
|
{ name: 'status', summary: 'print the session state, or NO ACTIVE SESSION' },
|
|
20
|
-
{ name: 'build', summary: 'build <n>: write
|
|
21
|
-
{ name: '
|
|
22
|
-
{ name: '
|
|
19
|
+
{ name: 'build', summary: 'build <n>: write the seam from step n; STATE=BUILD (orientation, not a lock)' },
|
|
20
|
+
{ name: 'check', summary: 'run the heavy check and report; no state change' },
|
|
21
|
+
{ name: 'checkpoint', summary: 'checkpoint [<n>]: gate on green, commit/record SHA, mark step done, STATE=DESIGN (executor-agnostic)' },
|
|
23
22
|
{ name: 'revert', summary: 'revert [--to n]: git reset --hard to a checkpoint SHA; STATE=DESIGN' },
|
|
24
23
|
{ name: 'park', summary: 'park "<text>": append a raw line to the park list' },
|
|
25
24
|
{ name: 'spike', summary: 'spike "<slug>" | spike done: throwaway worktree experiment' },
|
|
26
|
-
{ name: '
|
|
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)' },
|
|
25
|
+
{ name: 'reset', summary: 'v2 close-out: archive intent+log+report (no gate), clear the sidecar, STATE off' },
|
|
29
26
|
{ name: 'setup', summary: 'install hooks + skills; register them (self-contained per-project by default; --global for ~/.claude)' },
|
|
27
|
+
{ name: 'doctor', summary: 'diagnose the install (skills, bin, hook) and print the fix for anything broken' },
|
|
30
28
|
];
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
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']);
|
|
29
|
+
// Plumbbob v2 (D1/D10/D13): the deciding/executing boundary is no longer a lock,
|
|
30
|
+
// so there is nothing to defend — every verb runs the same whether a human or the
|
|
31
|
+
// model triggers it. The old human-only `mode` escape hatch and its CLAUDECODE
|
|
32
|
+
// in-session refusal are gone; what keeps the human the decider is the pause at
|
|
33
|
+
// the step boundary (the skills), not a refusal here.
|
|
42
34
|
function formatHelp() {
|
|
43
35
|
const width = Math.max(...VERBS.map((v) => v.name.length));
|
|
44
36
|
const rows = VERBS.map((v) => ` ${v.name.padEnd(width)} ${v.summary}`);
|
|
@@ -50,26 +42,24 @@ function dispatch(verb, cwd, rest) {
|
|
|
50
42
|
return start(cwd, rest);
|
|
51
43
|
case 'status':
|
|
52
44
|
return status(cwd);
|
|
53
|
-
case 'mode':
|
|
54
|
-
return mode(cwd, rest);
|
|
55
45
|
case 'park':
|
|
56
46
|
return park(cwd, rest);
|
|
57
47
|
case 'build':
|
|
58
48
|
return build(cwd, rest);
|
|
59
|
-
case '
|
|
60
|
-
return
|
|
61
|
-
case '
|
|
62
|
-
return
|
|
49
|
+
case 'check':
|
|
50
|
+
return check(cwd);
|
|
51
|
+
case 'checkpoint':
|
|
52
|
+
return checkpoint(cwd, rest);
|
|
63
53
|
case 'revert':
|
|
64
54
|
return revert(cwd, rest);
|
|
65
55
|
case 'spike':
|
|
66
56
|
return spike(cwd, rest);
|
|
67
|
-
case '
|
|
68
|
-
return
|
|
69
|
-
case 'finish':
|
|
70
|
-
return finish(cwd);
|
|
57
|
+
case 'reset':
|
|
58
|
+
return reset(cwd);
|
|
71
59
|
case 'setup':
|
|
72
60
|
return setup(cwd, rest);
|
|
61
|
+
case 'doctor':
|
|
62
|
+
return doctor(cwd);
|
|
73
63
|
default:
|
|
74
64
|
process.stderr.write(`plumbbob: unknown verb '${verb}'. Run 'plumbbob help' for the verb table.\n`);
|
|
75
65
|
return 1;
|
|
@@ -82,11 +72,6 @@ function run(argv) {
|
|
|
82
72
|
process.stdout.write(`${formatHelp()}\n`);
|
|
83
73
|
return 0;
|
|
84
74
|
}
|
|
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
75
|
try {
|
|
91
76
|
return dispatch(verb, process.cwd(), rest);
|
|
92
77
|
}
|
package/dist/lib/archive.js
CHANGED
|
@@ -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.
|
|
61
|
-
//
|
|
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
|
-
|
|
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');
|
package/dist/lib/intent.js
CHANGED
|
@@ -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)
|
|
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
|
+
}
|
package/dist/lib/settings.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/lib/sidecar.js
CHANGED
|
@@ -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
|
}
|
package/dist/verbs/build.js
CHANGED
|
@@ -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.
|
|
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
|
}
|