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