plumbbob 0.4.4 → 0.4.11

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "plumbbob",
3
- "displayName": "Plumbbob",
4
- "version": "0.4.4",
3
+ "displayName": "PlumbBob",
4
+ "version": "0.4.11",
5
5
  "description": "Attention-first build process — driver skills + a CLI that keep you the decider; guidance, not enforcement.",
6
6
  "author": { "name": "Rob McLarty", "email": "hello@robmclarty.com", "url": "https://robmclarty.com" },
7
7
  "homepage": "https://github.com/robmclarty/plumbbob#readme",
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Plumbbob
1
+ # PlumbBob
2
2
 
3
3
  <p align="center">
4
4
  <img src="hero.jpg" alt="A row of plumb bobs of varying shapes hanging from strings" width="600">
@@ -66,55 +66,55 @@ and it cannot be skipped.
66
66
 
67
67
  You drive the whole loop from your IDE with eight `/plumbbob:*` skills — no step numbers to
68
68
  remember, no raw CLI to type. Each is `disable-model-invocation`, so *you* fire
69
- every move, and `/plumbbob:status` always names your next one.
69
+ every move, and `/plumbbob:pb-status` always names your next one.
70
70
 
71
71
  | Skill | Does |
72
72
  |-------|------|
73
- | `/plumbbob:plan` | plan the whole goal — scaffold the session + author intent's Frame, Decisions, Constraints, **and all Steps** |
74
- | `/plumbbob:step` | revise/sharpen the next step (empty input auto-syncs it to reality) |
75
- | `/plumbbob:build` | *(optional)* implement the next planned step, then verify it to the pause — `--auto` self-approves and chains to done |
76
- | `/plumbbob:verify` | the tick — check → self-review → validate → **PAUSE** → checkpoint |
77
- | `/plumbbob:park` | capture an idea without chasing it |
78
- | `/plumbbob:status` | orient — where you are, the next step's done-when + seam, and the next move |
79
- | `/plumbbob:harvest` | triage parked ideas at a boundary (blocker / tangent / pivot) |
80
- | `/plumbbob:wrap` | wrap up — write the report, archive safely, clear for a fresh goal |
81
-
82
- Three optional power moves survive for when you need them: `/plumbbob:revert` (recover to
83
- a checkpoint), `/plumbbob:spike` (throwaway worktree experiment), and `/plumbbob:refine` (attack
73
+ | `/plumbbob:pb-plan` | plan the whole goal — scaffold the session + author intent's Frame, Decisions, Constraints, **and all Steps** |
74
+ | `/plumbbob:pb-step` | revise/sharpen the next step (empty input auto-syncs it to reality) |
75
+ | `/plumbbob:pb-build` | *(optional)* implement the next planned step, then verify it to the pause — `--auto` self-approves and chains to done |
76
+ | `/plumbbob:pb-verify` | the tick — check → self-review → validate → **PAUSE** → checkpoint |
77
+ | `/plumbbob:pb-park` | capture an idea without chasing it |
78
+ | `/plumbbob:pb-status` | orient — where you are, the next step's done-when + seam, and the next move |
79
+ | `/plumbbob:pb-harvest` | triage parked ideas at a boundary (blocker / tangent / pivot) |
80
+ | `/plumbbob:pb-wrap` | wrap up — write the report, archive safely, clear for a fresh goal |
81
+
82
+ Three optional power moves survive for when you need them: `/plumbbob:pb-revert` (recover to
83
+ a checkpoint), `/plumbbob:pb-spike` (throwaway worktree experiment), and `/plumbbob:pb-refine` (attack
84
84
  the frame for holes, or repair the plan when it drifts — usable at any point).
85
85
 
86
86
  ## The loop
87
87
 
88
- The happy path is **plan the whole thing up front, then drive `/plumbbob:build` until
88
+ The happy path is **plan the whole thing up front, then drive `/plumbbob:pb-build` until
89
89
  done** — approving each step at its verify pause:
90
90
 
91
91
  ```text
92
- /plumbbob:plan author the whole plan (incl. all steps) (once)
92
+ /plumbbob:pb-plan author the whole plan (incl. all steps) (once)
93
93
  └ per step:
94
- /plumbbob:status review the next step (done-when + seam)
95
- /plumbbob:step (optional) sharpen/revise it first if needed
96
- /plumbbob:build (or DIY) implement it → verify → PAUSE → checkpoint
97
- /plumbbob:park capture strays mid-build
98
- /plumbbob:harvest triage them at a boundary
99
- /plumbbob:wrap report + archive + clear (once)
94
+ /plumbbob:pb-status review the next step (done-when + seam)
95
+ /plumbbob:pb-step (optional) sharpen/revise it first if needed
96
+ /plumbbob:pb-build (or DIY) implement it → verify → PAUSE → checkpoint
97
+ /plumbbob:pb-park capture strays mid-build
98
+ /plumbbob:pb-harvest triage them at a boundary
99
+ /plumbbob:pb-wrap report + archive + clear (once)
100
100
  ```
101
101
 
102
- Each `/plumbbob:build` builds the next undone step and stops at the pause for your
103
- approval — re-firing it is itself the clock tick. (`/plumbbob:build --auto` is the opt-in
102
+ Each `/plumbbob:pb-build` builds the next undone step and stops at the pause for your
103
+ approval — re-firing it is itself the clock tick. (`/plumbbob:pb-build --auto` is the opt-in
104
104
  that lets the agent self-approve and chain to done, halting on a red check or any
105
105
  mismatch.) For a worked example that walks one goal end to end — planning, building
106
106
  each step, wrapping up, archiving, and starting the next task — see
107
107
  [`docs/happy-path.md`](docs/happy-path.md).
108
108
 
109
- **Three ways to plan.** `/plumbbob:plan` produces the same artifact — a complete, standalone
109
+ **Three ways to plan.** `/plumbbob:pb-plan` produces the same artifact — a complete, standalone
110
110
  `intent.md` — from whichever seed you give it: **no argument** runs a short interview;
111
111
  **a file path** absorbs an out-of-band spec (retaining its detail so the plan stands on
112
112
  its own); **any other text** expands your inline intent. No quotes required — it
113
113
  disambiguates the mode itself.
114
114
 
115
- **The pluggable executor.** `/plumbbob:build` is one way to turn a planned step into code
115
+ **The pluggable executor.** `/plumbbob:pb-build` is one way to turn a planned step into code
116
116
  — it is *optional*. Implement by hand, in a vibe session, or with another harness,
117
- and run `/plumbbob:verify` instead: it reads the *diff, not the author*. Plumbbob is the
117
+ and run `/plumbbob:pb-verify` instead: it reads the *diff, not the author*. Plumbbob is the
118
118
  harness-agnostic spine; how the diff appears is a slot you fill however you like.
119
119
 
120
120
  ## Calibration: size everything to the work
@@ -123,7 +123,7 @@ The fastest way to abandon this is ceremony on a one-liner. The discipline is
123
123
  *decisions before code*, not *always produce three files*.
124
124
 
125
125
  - **Tiny** (typo, one-liner): no session. Just fix it.
126
- - **Small** (a contained bug/change): `/plumbbob:plan` a frame + 2–3 decisions; one or two
126
+ - **Small** (a contained bug/change): `/plumbbob:pb-plan` a frame + 2–3 decisions; one or two
127
127
  steps; build → verify → checkpoint.
128
128
  - **Medium** (a feature touching a few modules): the full loop above.
129
129
  - **Large / architectural**: that's Ridgeline's job, not Plumbbob's.
@@ -134,15 +134,16 @@ Calibration is the skill. When in doubt, smaller.
134
134
 
135
135
  - A `plumbbob` CLI (TypeScript, run natively by Node ≥ 22.18, zero runtime
136
136
  dependencies) — the dumb mechanical verbs the skills shell out to. You never type
137
- it by hand.
137
+ it by hand (beyond `plumbbob --help` and `plumbbob --version`). The marketplace
138
+ plugin carries it on PATH via `bin/` shims; `npm i -g` installs it globally.
138
139
  - The eight `/plumbbob:*` skills plus the optional power moves, each
139
140
  `disable-model-invocation` so *you* fire every move.
140
141
  - One session-gated Claude Code hook — `post-edit.sh`, a non-blocking light-feedback
141
142
  pass that injects file-scoped lint into the model's context so it self-corrects in
142
143
  flow. (v1's pre-edit muzzle, seam-guard, and bash-guard are gone — guidance, not
143
144
  enforcement.)
144
- - A `.plumbbob/` sidecar of flat files: `STATE` (orientation only), `intent.md`,
145
- `build-log.md`, `checkpoints`, and `archive/`.
145
+ - A `.plumbbob/` sidecar of flat files: `STATE` (the session sentinel — its presence
146
+ means a session is live), `intent.md`, `build-log.md`, `checkpoints`, and `archive/`.
146
147
 
147
148
  ## Gates — two tiers, different jobs
148
149
 
@@ -150,17 +151,19 @@ Calibration is the skill. When in doubt, smaller.
150
151
  blocks an edit. It exists only because Claude can't see your editor's LSP, so the
151
152
  light tier *serves the model*.
152
153
  - **Heavy** — the full `pnpm check` (tsc, oxlint, ast-grep, vitest, knip,
153
- markdownlint). Not a hook: it runs *inside* `/plumbbob:verify`, which refuses to
154
+ markdownlint). Not a hook: it runs *inside* `/plumbbob:pb-verify`, which refuses to
154
155
  checkpoint while red. The hard gate lives on the deliberate boundary, not the
155
156
  keystroke.
156
157
 
157
- ## STATE is orientation, not a gate
158
+ ## Position is derived, not stored
158
159
 
159
- The current position lives in one word in `.plumbbob/STATE` — `DESIGN`, `BUILD`, or
160
- `SPIKE`. It no longer gates anything. It is read by `/plumbbob:status` to tell you where
161
- you are and what to do next; a wrong state is a mislabeled position on a map, not a
162
- locked door. The post-edit hook is session-gated: a repo with no `.plumbbob/STATE`
163
- behaves exactly like plain Claude Code.
160
+ There is no stored state machine. The dashboard's phase — `DESIGN`, `BUILD`, or `SPIKE` —
161
+ is *derived* from what's on disk: a `STEP` file means a step is in flight (BUILD), the
162
+ `SPIKE` marker means a fork is open (SPIKE), otherwise you're at a boundary (DESIGN). The
163
+ position gates nothing it's a label on a map, read by `/plumbbob:pb-status` to tell you
164
+ where you are and what to do next, never a locked door. `.plumbbob/STATE` is just the
165
+ session sentinel: its presence means a session is live, and the post-edit hook is gated on
166
+ it, so a repo with no `.plumbbob/STATE` behaves exactly like plain Claude Code.
164
167
 
165
168
  ## Git footprint — additive only
166
169
 
@@ -174,10 +177,12 @@ archives plain markdown under `.plumbbob/archive/` and never touches git.
174
177
 
175
178
  ```text
176
179
  .plumbbob/
177
- STATE # one word: DESIGN | BUILD | SPIKE orientation, not a gate
180
+ STATE # session sentinel its presence means a session is live
178
181
  SEAM # the in-flight step's declared paths (awareness, not a lock)
179
- STEP # the in-flight step number
180
- checkpoints # "step N <git-sha>", one per verified step
182
+ STEP # the in-flight step number (its presence is the BUILD phase)
183
+ SPIKE # marker present while a spike fork is open
184
+ config # key=value; check=<heavy-check command> (defaults to pnpm run check)
185
+ checkpoints # "baseline <sha>" then "step N <sha>", one per verified step
181
186
  intent.md # canonical intent
182
187
  build-log.md # live ledger
183
188
  archive/
@@ -189,24 +194,34 @@ archives plain markdown under `.plumbbob/archive/` and never touches git.
189
194
 
190
195
  ## Install
191
196
 
192
- Plumbbob is a personal tool — install it once, globally, like `gh` or your
193
- dotfiles. The npm package ships the CLI plus the skills and hook; `plumbbob init`
194
- links them into Claude Code as an in-place plugin.
197
+ Plumbbob installs **once, globally** like `gh` or your dotfiles. There are two co-equal,
198
+ mutually-exclusive ways to do it (both register a Claude Code plugin named `plumbbob`;
199
+ running both collides over the `/plumbbob:*` namespace).
200
+
201
+ **npm global + `init`** — the npm package ships the CLI, the skills, and the hook; `plumbbob
202
+ init` links them into Claude Code as an in-place plugin:
195
203
 
196
204
  ```sh
197
205
  npm i -g plumbbob # the CLI (also a `pb` shorthand)
198
206
  plumbbob init # link it into Claude Code; --uninstall to undo
199
207
  ```
200
208
 
201
- `init` symlinks the package into `~/.claude/skills/plumbbob`, where Claude Code
202
- loads it as a plugin. Claude Code namespaces a plugin's skills as
203
- `/plumbbob:<skill>`, so they appear as `/plumbbob:plan`, `/plumbbob:status`, and the
204
- rest — already clear of built-in and other-plugin commands, no prefix needed. The
205
- post-edit hook auto-registers from `hooks.json`.
206
- Nothing else under `~` is touched
207
- and `settings.json` is left alone — restart Claude Code (or `/reload-plugins`) to
208
- activate. Because it's a symlink, a later `npm i -g plumbbob@latest` stays live with
209
- no re-link.
209
+ `init` symlinks the package into `~/.claude/skills/plumbbob`, where Claude Code loads it as a
210
+ plugin; the post-edit hook auto-registers from `hooks.json`. Because it's a symlink, a later
211
+ `npm i -g plumbbob@latest` stays live with no re-link.
212
+
213
+ **The marketplace plugin** — self-contained: it ships the skills *and* the `plumbbob`/`pb`
214
+ CLI on PATH (via its `bin/` shims), so it needs neither `npm i -g` nor `plumbbob init`:
215
+
216
+ ```text
217
+ /plugin install plumbbob@<marketplace>
218
+ ```
219
+
220
+ Either way, Claude Code namespaces the skills as `/plumbbob:<skill>`, so they appear as
221
+ `/plumbbob:pb-plan`, `/plumbbob:pb-status`, and the rest. Nothing else under `~` is touched
222
+ and `settings.json` is left alone — restart Claude Code (or `/reload-plugins`) to activate.
223
+ If a marketplace plumbbob is already installed, `plumbbob init` refuses rather than create
224
+ the collision (`--force` overrides), and `plumbbob doctor` flags a double-install.
210
225
 
211
226
  **Sessions are per-project.** Install scope is not session scope: you install the
212
227
  tool once, but each goal lives in its own repo — `plumbbob start "<goal>"` writes a
@@ -222,10 +237,13 @@ look. Claude Code is the first, first-class target.
222
237
  plumbbob doctor
223
238
  ```
224
239
 
225
- `doctor` checks that the link resolves to the plugin manifest, the skills, and the
226
- hook, and prints the exact fix for anything broken. Run it first if a `/plumbbob:*`
227
- skill ever opens with an empty dashboard; [`docs/troubleshooting.md`](docs/troubleshooting.md)
228
- covers the rest.
240
+ `doctor` works for either install path: it confirms a marketplace plugin, or checks that the
241
+ skills-dir link resolves to the plugin manifest, the skills, and the hook flagging a
242
+ double-install collision and prints the exact fix for anything broken. The terminal command
243
+ above is for the **global / skills-dir** install; a **marketplace** plugin puts the CLI on
244
+ PATH only *inside a Claude Code session*, so run **`/plumbbob:pb-doctor`** there instead. Run
245
+ it first if a `/plumbbob:*` skill ever opens with an empty dashboard;
246
+ [`docs/troubleshooting.md`](docs/troubleshooting.md) covers the rest.
229
247
 
230
248
  ## Development
231
249
 
package/bin/pb ADDED
@@ -0,0 +1,15 @@
1
+ #!/bin/sh
2
+ # pb — short alias for `plumbbob` (see bin/plumbbob for the full note). Same dual
3
+ # role: the npm `bin` entry for `pb` (so npm ships it 0755) and the plugin PATH shim.
4
+ # Resolves any symlink chain so it works on PATH, globally linked, or as a dep.
5
+ src=$0
6
+ while [ -h "$src" ]; do
7
+ dir=$(CDPATH='' cd -- "$(dirname -- "$src")" && pwd)
8
+ link=$(readlink -- "$src")
9
+ case $link in
10
+ /*) src=$link ;;
11
+ *) src=$dir/$link ;;
12
+ esac
13
+ done
14
+ here=$(CDPATH='' cd -- "$(dirname -- "$src")" && pwd)
15
+ exec node "$here/../dist/cli.js" "$@"
package/bin/plumbbob ADDED
@@ -0,0 +1,26 @@
1
+ #!/bin/sh
2
+ # plumbbob — the package's `bin` entry AND the marketplace/plugin PATH shim.
3
+ #
4
+ # Pointing the npm `bin` at THIS file (not dist/cli.js) is deliberate: npm/pacote
5
+ # rewrites every packed file to 0644 except the ones named in `bin`, which it stamps
6
+ # 0755 — regardless of the mode in git. Claude Code adds this bin/ dir to the Bash
7
+ # tool's PATH while the plugin is enabled, so the shim must arrive executable; naming
8
+ # it in `bin` is what carries the +x bit through an npm-sourced plugin install.
9
+ # (dist/cli.js no longer needs its own bit — it is always run as `node` below.)
10
+ #
11
+ # As the npm bin, this file is also symlinked into a bin dir by `npm i -g` and by a
12
+ # local node_modules/.bin dep, so $0 can reach us through one or more symlinks.
13
+ # Resolve the chain to the real file, then run the bundled CLI relative to it — so it
14
+ # works on PATH (plugin), globally linked, and as a dep. Requires node >=22 on PATH
15
+ # (same as dist/cli.js's shebang).
16
+ src=$0
17
+ while [ -h "$src" ]; do
18
+ dir=$(CDPATH='' cd -- "$(dirname -- "$src")" && pwd)
19
+ link=$(readlink -- "$src")
20
+ case $link in
21
+ /*) src=$link ;;
22
+ *) src=$dir/$link ;;
23
+ esac
24
+ done
25
+ here=$(CDPATH='' cd -- "$(dirname -- "$src")" && pwd)
26
+ exec node "$here/../dist/cli.js" "$@"
package/dist/cli-core.js CHANGED
@@ -2,6 +2,8 @@
2
2
  // entry (cli.ts) so the routing/help logic is unit-testable in-process without
3
3
  // the top-level process.exit. Zero runtime deps, node: builtins only (D1/C2).
4
4
  // Functional/procedural: no classes, no `this`, no default export (C1).
5
+ import { readFileSync } from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
5
7
  import { start } from "./verbs/start.js";
6
8
  import { status } from "./verbs/status.js";
7
9
  import { park } from "./verbs/park.js";
@@ -14,15 +16,15 @@ import { wrap } from "./verbs/wrap.js";
14
16
  import { init } from "./verbs/init.js";
15
17
  import { doctor } from "./verbs/doctor.js";
16
18
  const VERBS = [
17
- { name: 'start', summary: 'scaffold .plumbbob/; STATE=DESIGN; record the baseline commit' },
18
- { name: 'status', summary: 'print the session state, or NO ACTIVE SESSION' },
19
- { name: 'build', summary: 'build <n>: write the seam from step n; STATE=BUILD (orientation, not a lock)' },
19
+ { name: 'start', summary: 'scaffold .plumbbob/; open the session; record the baseline commit' },
20
+ { name: 'status', summary: 'print the orientation dashboard, or NO ACTIVE SESSION' },
21
+ { name: 'build', summary: 'build <n>: write the seam from step n (in-flight, not a lock)' },
20
22
  { 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)' },
22
- { name: 'revert', summary: 'revert [--to n]: git reset --hard to a checkpoint SHA; STATE=DESIGN' },
23
+ { name: 'checkpoint', summary: 'checkpoint [<n>]: gate on green, commit/record SHA, mark step done (executor-agnostic)' },
24
+ { name: 'revert', summary: 'revert [--to n]: git reset --hard to a checkpoint SHA' },
23
25
  { name: 'park', summary: 'park "<text>": append a raw line to the park list' },
24
26
  { name: 'spike', summary: 'spike "<slug>" | spike done: throwaway worktree experiment' },
25
- { name: 'wrap', summary: 'v2 close-out: archive intent+log+report (no gate), clear the sidecar, STATE off' },
27
+ { name: 'wrap', summary: 'v2 close-out: archive intent+log+report (no gate), clear the sidecar, close the session' },
26
28
  { name: 'init', summary: 'link plumbbob into Claude Code as an in-place plugin (~/.claude/skills/plumbbob); --uninstall to undo' },
27
29
  { name: 'doctor', summary: 'diagnose the plugin link (manifest, skills, hook) and print the fix for anything broken' },
28
30
  ];
@@ -36,6 +38,19 @@ export function formatHelp() {
36
38
  const rows = VERBS.map((v) => ` ${v.name.padEnd(width)} ${v.summary}`);
37
39
  return ['plumbbob — attention-first build process', '', 'Usage: plumbbob <verb> [args]', '', 'Verbs:', ...rows, ''].join('\n');
38
40
  }
41
+ // The package version, read from the package.json that ships one level above the
42
+ // compiled CLI (dist/cli-core.js → ../package.json; in tests src/cli-core.ts →
43
+ // ../package.json, the repo root). Builtins only (C2); an absent or malformed
44
+ // manifest degrades to 'unknown' rather than throwing, so `--version` never errors.
45
+ export function readVersion() {
46
+ try {
47
+ const raw = readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8');
48
+ return JSON.parse(raw).version ?? 'unknown';
49
+ }
50
+ catch {
51
+ return 'unknown';
52
+ }
53
+ }
39
54
  function dispatch(verb, cwd, rest) {
40
55
  switch (verb) {
41
56
  case 'start':
@@ -72,6 +87,10 @@ export function run(argv) {
72
87
  process.stdout.write(`${formatHelp()}\n`);
73
88
  return 0;
74
89
  }
90
+ if (verb === 'version' || verb === '--version' || verb === '-v') {
91
+ process.stdout.write(`plumbbob ${readVersion()}\n`);
92
+ return 0;
93
+ }
75
94
  try {
76
95
  return dispatch(verb, process.cwd(), rest);
77
96
  }
package/dist/cli.js CHANGED
File without changes
@@ -0,0 +1,34 @@
1
+ // Mutations to the active build-log.md — the human-facing ledger. `park` appends to
2
+ // its Park list; `checkpoint` appends to its Log so the build's history accrues
3
+ // line-by-line *as it happens* rather than being reconstructed at wrap (D-history).
4
+ // Pure string transforms (no fs, no classes — C1/C2); callers own the IO so these
5
+ // stay trivially testable.
6
+ // Append `line` after the last non-blank line of the `## <heading>` section — i.e.
7
+ // just before the next `## ` heading or EOF. Returns null when the section is absent
8
+ // so the caller can report rather than silently corrupt the doc.
9
+ export function appendToSection(content, heading, line) {
10
+ const lines = content.split('\n');
11
+ const headingIdx = lines.findIndex((l) => l.trim() === `## ${heading}`);
12
+ if (headingIdx === -1) {
13
+ return null;
14
+ }
15
+ let nextSection = lines.findIndex((l, i) => i > headingIdx && l.startsWith('## '));
16
+ if (nextSection === -1) {
17
+ nextSection = lines.length;
18
+ }
19
+ let insertAt = headingIdx + 1;
20
+ for (let i = headingIdx + 1; i < nextSection; i++) {
21
+ if ((lines[i] ?? '').trim() !== '') {
22
+ insertAt = i + 1;
23
+ }
24
+ }
25
+ lines.splice(insertAt, 0, line);
26
+ return lines.join('\n');
27
+ }
28
+ // The Log line `checkpoint` writes for a landed step: dated, names the step (its
29
+ // title when intent.md still carries it), and carries the short SHA. One line of the
30
+ // build's history — `/plumbbob:pb-wrap` reads these instead of re-narrating the build.
31
+ export function checkpointLogLine(date, step, sha, title) {
32
+ const head = `- ${date} — step ${step} checkpointed · ${sha.slice(0, 9)}`;
33
+ return title !== null && title.length > 0 ? `${head} — ${title}` : head;
34
+ }
@@ -77,7 +77,7 @@ export function parseOpenQuestions(intent) {
77
77
  .length;
78
78
  }
79
79
  // Open parked items: `- [ ]` lines under `## Park list` (the `park` verb's format).
80
- // A harvested item is flipped to `- [x]` by `/plumbbob:harvest` and no longer counts; the
80
+ // A harvested item is flipped to `- [x]` by `/plumbbob:pb-harvest` and no longer counts; the
81
81
  // `(none yet)` placeholder and the blockquote instructions never match.
82
82
  export function parseParked(buildLog) {
83
83
  return sectionLines(buildLog, '## Park list').filter((l) => /^-\s+\[ \]\s+\S/.test(l.trim())).length;
@@ -93,52 +93,45 @@ export function parseLastCheckpoint(checkpoints) {
93
93
  return last;
94
94
  }
95
95
  // The single primary next move (D15). It suggests; the dashboard prints the full
96
- // list + counts so the human can always override.
97
- function nextMove(state, steps, inFlight, parked) {
98
- switch (state) {
99
- case 'SPIKE':
100
- return 'close the spike — `plumbbob spike done`';
101
- case 'FINISH':
102
- return 'wrap up — `/plumbbob:wrap`';
103
- case 'REVIEW':
104
- return 'read the diff cold against intent, then `/plumbbob:verify`';
105
- case 'BUILD': {
106
- const n = inFlight ?? steps.find((s) => !s.done)?.n;
107
- return n === undefined
108
- ? 'finish the step in flight — `/plumbbob:verify`'
109
- : `finish step ${n} — \`/plumbbob:verify\` (or keep editing, then \`/plumbbob:verify\`)`;
110
- }
111
- default: {
112
- // DESIGN (and any unknown state): you are at the boundary.
113
- const nextUndone = steps.find((s) => !s.done);
114
- if (nextUndone === undefined) {
115
- if (steps.length === 0) {
116
- return 'plan the first step — `/plumbbob:step`';
117
- }
118
- // Batch-default: the steps were planned up front, so finishing them usually
119
- // means "wrap up" — but `/plumbbob:step` can still add an increment if reality grew.
120
- const harvest = parked > 0 ? `harvest ${parked} parked idea${parked === 1 ? '' : 's'} — \`/plumbbob:harvest\`; then ` : '';
121
- return `${harvest}wrap up — \`/plumbbob:wrap\` (or \`/plumbbob:step\` to add another increment)`;
122
- }
123
- return nextUndone.planned
124
- ? `build step ${nextUndone.n} — \`/plumbbob:build\` (or \`/plumbbob:step\` to revise it first)`
125
- : `plan step ${nextUndone.n} — \`/plumbbob:step\``;
96
+ // list + counts so the human can always override. The phase is derived: a spike
97
+ // in progress and an in-flight step each have one obvious next move; otherwise you
98
+ // are at the boundary and the move follows from the steps.
99
+ function nextMove(spiking, steps, inFlight, parked) {
100
+ if (spiking) {
101
+ return 'close the spike — `plumbbob spike done`';
102
+ }
103
+ if (inFlight !== null) {
104
+ return `finish step ${inFlight} \`/plumbbob:pb-verify\` (or keep editing, then \`/plumbbob:pb-verify\`)`;
105
+ }
106
+ // At the boundary (DESIGN): the move follows from the steps.
107
+ const nextUndone = steps.find((s) => !s.done);
108
+ if (nextUndone === undefined) {
109
+ if (steps.length === 0) {
110
+ return 'plan the first step — `/plumbbob:pb-step`';
126
111
  }
112
+ // Batch-default: the steps were planned up front, so finishing them usually
113
+ // means "wrap up" — but `/plumbbob:pb-step` can still add an increment if reality grew.
114
+ const harvest = parked > 0 ? `harvest ${parked} parked idea${parked === 1 ? '' : 's'} — \`/plumbbob:pb-harvest\`; then ` : '';
115
+ return `${harvest}wrap up — \`/plumbbob:pb-wrap\` (or \`/plumbbob:pb-step\` to add another increment)`;
127
116
  }
117
+ return nextUndone.planned
118
+ ? `build step ${nextUndone.n} — \`/plumbbob:pb-build\` (or \`/plumbbob:pb-step\` to revise it first)`
119
+ : `plan step ${nextUndone.n} — \`/plumbbob:pb-step\``;
128
120
  }
129
121
  export function orient(input) {
130
122
  const steps = parseSteps(input.intent);
131
123
  const parked = parseParked(input.buildLog);
132
124
  const nextUndone = steps.find((s) => !s.done);
133
125
  const seamParse = nextUndone === undefined ? null : parseStepSeam(input.intent, nextUndone.n);
126
+ const phase = input.spiking ? 'SPIKE' : input.inFlight !== null ? 'BUILD' : 'DESIGN';
134
127
  return {
135
128
  title: parseTitle(input.intent),
136
- state: input.state,
129
+ phase,
137
130
  steps,
138
131
  lastCheckpoint: parseLastCheckpoint(input.checkpoints),
139
132
  parked,
140
133
  openQuestions: parseOpenQuestions(input.intent),
141
- next: nextMove(input.state, steps, input.inFlight, parked),
134
+ next: nextMove(input.spiking, steps, input.inFlight, parked),
142
135
  nextDoneWhen: nextUndone?.doneWhen ?? null,
143
136
  nextSeam: seamParse !== null && seamParse.ok ? seamParse.seam : [],
144
137
  };
@@ -153,7 +146,7 @@ export function formatOrientation(o) {
153
146
  if (s !== nextUndone) {
154
147
  return head;
155
148
  }
156
- // Surface the next step's detail so the human can review it (and `/plumbbob:step`-
149
+ // Surface the next step's detail so the human can review it (and `/plumbbob:pb-step`-
157
150
  // revise) before building. Only what's present — a rough step shows neither.
158
151
  const detail = [];
159
152
  if (o.nextDoneWhen !== null) {
@@ -168,7 +161,7 @@ export function formatOrientation(o) {
168
161
  const cp = o.lastCheckpoint;
169
162
  const cpLine = cp === null ? 'last checkpoint none yet' : `last checkpoint step ${cp.n} · ${cp.sha.slice(0, 7)}`;
170
163
  return [
171
- `Plumbbob — ${o.title ?? '(untitled)'} [${o.state}]`,
164
+ `Plumbbob — ${o.title ?? '(untitled)'} [${o.phase}]`,
172
165
  '',
173
166
  stepsBlock,
174
167
  '',
@@ -0,0 +1,24 @@
1
+ // Detect a marketplace-installed plumbbob plugin from Claude Code's plugin state.
2
+ // Used to keep the skills-dir install (`plumbbob init`) from colliding with a
3
+ // marketplace install: two plugins both named `plumbbob` fight over the
4
+ // `/plumbbob:*` namespace and the skills can drop to flat names (`/pb-status`).
5
+ // Read-only, node builtins only (C1/C2): a missing / unreadable / malformed file
6
+ // means "none installed".
7
+ import { readFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ // Marketplace plugin ids for plumbbob recorded in installed_plugins.json
10
+ // (e.g. "plumbbob@robmclarty"). Empty when none, or the file is absent/unreadable.
11
+ // `plumbbob@<marketplace>` matches; `plumbbob-spike@x` and the like do not.
12
+ export function marketplacePlumbbob(home) {
13
+ try {
14
+ const raw = readFileSync(join(home, '.claude', 'plugins', 'installed_plugins.json'), 'utf8');
15
+ const plugins = JSON.parse(raw).plugins;
16
+ if (plugins === undefined || plugins === null) {
17
+ return [];
18
+ }
19
+ return Object.keys(plugins).filter((id) => id === 'plumbbob' || id.startsWith('plumbbob@'));
20
+ }
21
+ catch {
22
+ return [];
23
+ }
24
+ }
@@ -1,7 +1,12 @@
1
1
  // The .plumbbob/ sidecar: control state lives in flat files so the hooks can
2
2
  // read it with a grep and no markdown parsing (D7). Functional/procedural,
3
3
  // node builtins only (C1/C2).
4
- import { existsSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
4
+ //
5
+ // STATE is a pure session sentinel: its EXISTENCE means "a session is active",
6
+ // and nothing reads its content. The phase the dashboard shows (DESIGN/BUILD/
7
+ // SPIKE) is derived, not stored — BUILD ⇔ a STEP is in flight, SPIKE ⇔ the SPIKE
8
+ // marker is present, otherwise DESIGN.
9
+ import { existsSync, readFileSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
5
10
  import { join } from 'node:path';
6
11
  import { gitDir } from "./git.js";
7
12
  const DIRNAME = '.plumbbob';
@@ -11,6 +16,12 @@ export function sidecarDir(root) {
11
16
  function statePath(root) {
12
17
  return join(root, DIRNAME, 'STATE');
13
18
  }
19
+ // The SPIKE marker (a single-purpose presence flag, like SEAM/STEP): written by
20
+ // `spike` on open, removed on `spike done`. Its existence is the one signal that
21
+ // the dashboard and the spike gates read to know "a spike is active".
22
+ export function spikePath(root) {
23
+ return join(root, DIRNAME, 'SPIKE');
24
+ }
14
25
  // SEAM and STEP carry the in-flight step (D4/D7): a plain path list and a bare
15
26
  // number, so the hooks read them with a grep and no markdown parsing.
16
27
  export function seamPath(root) {
@@ -31,21 +42,24 @@ export function intentPath(root) {
31
42
  export function buildLogPath(root) {
32
43
  return join(root, DIRNAME, 'build-log.md');
33
44
  }
34
- // A session exists iff STATE exists. Deleting STATE (at finish) is what switches
35
- // the muzzle off — so it is the single source of truth for "is there a session".
45
+ // A session exists iff STATE exists. Deleting STATE (at wrap) is what flips the
46
+ // repo back to "no session" — so it is the single source of truth for "is there
47
+ // a session". `start` calls beginSession; `wrap` removes the file.
36
48
  export function hasSession(root) {
37
49
  return existsSync(statePath(root));
38
50
  }
39
- export function readState(root) {
40
- try {
41
- return readFileSync(statePath(root), 'utf8').trim();
42
- }
43
- catch {
44
- return null;
45
- }
51
+ export function beginSession(root) {
52
+ writeFileSync(statePath(root), 'active\n');
53
+ }
54
+ // SPIKE marker helpers — existence is the whole signal (content is irrelevant).
55
+ export function inSpike(root) {
56
+ return existsSync(spikePath(root));
57
+ }
58
+ export function markSpike(root) {
59
+ writeFileSync(spikePath(root), 'active\n');
46
60
  }
47
- export function writeState(root, state) {
48
- writeFileSync(statePath(root), `${state}\n`);
61
+ export function clearSpike(root) {
62
+ rmSync(spikePath(root), { force: true });
49
63
  }
50
64
  // D17: keep the sidecar untracked by appending `.plumbbob/` to the repo's
51
65
  // git/info/exclude. Idempotent — a re-`start` after finish must not double-add.
@@ -1,9 +1,9 @@
1
1
  // `plumbbob build <n>` — read step n's seam from intent.md, write the normalized
2
- // SEAM + STEP, and enter BUILD. Re-entering from REVIEW just re-derives the same
3
- // seam and flips back to BUILD; it never checkpoints (only `done` commits).
2
+ // SEAM + STEP. The STEP file is the in-flight signal (the dashboard derives the
3
+ // BUILD phase from it); it never checkpoints (only `checkpoint` commits).
4
4
  import { readFileSync, writeFileSync } from 'node:fs';
5
5
  import { findRepoRoot } from "../lib/git.js";
6
- import { hasSession, intentPath, seamPath, stepPath, writeState } from "../lib/sidecar.js";
6
+ import { hasSession, intentPath, seamPath, stepPath } from "../lib/sidecar.js";
7
7
  import { parseStepSeam } from "../lib/intent.js";
8
8
  export function build(cwd, args) {
9
9
  const root = findRepoRoot(cwd);
@@ -24,7 +24,6 @@ export function build(cwd, args) {
24
24
  }
25
25
  writeFileSync(seamPath(root), `${parsed.seam.join('\n')}\n`);
26
26
  writeFileSync(stepPath(root), `${step}\n`);
27
- writeState(root, '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`);
27
+ process.stdout.write(`plumbbob: building step ${step}. Seam (for orientation; not a lock in v2):\n${parsed.seam.map((p) => ` ${p}`).join('\n')}\n`);
29
28
  return 0;
30
29
  }
@@ -1,5 +1,5 @@
1
1
  // `plumbbob check` — run the heavy gate (D16/D24) and report, with NO state
2
- // change. The read-only half of the verify tick: `/plumbbob:verify` runs this before the
2
+ // change. The read-only half of the verify tick: `/plumbbob:pb-verify` runs this before the
3
3
  // pause so the human approves on a known-green check. Exits with the check's own
4
4
  // code (0 = green).
5
5
  import { findRepoRoot } from "../lib/git.js";