claude-plan-review 0.3.1 β 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -4
- package/package.json +2 -1
- package/skill/SKILL.md +86 -0
- package/src/cli.js +198 -12
- package/src/hook.js +31 -8
- package/src/mcp.js +444 -0
- package/src/paths.js +17 -0
- package/src/plan-parse.js +238 -0
- package/src/server.js +261 -10
- package/src/stop-gate.js +73 -0
- package/src/store.js +309 -33
- package/src/ui/app.js +649 -108
- package/src/ui/index.html +51 -15
- package/src/ui/style.css +73 -18
package/README.md
CHANGED
|
@@ -23,7 +23,10 @@ Git worktrees are scoped separately automatically.
|
|
|
23
23
|
|
|
24
24
|
- π GitHub-style **rendered preview** of the plan markdown
|
|
25
25
|
- π¬ **Line comments** + general comments, **persistent** across reloads
|
|
26
|
+
- π³ **Multi-document plans** β big plans as a navigable tree (root + linked subpages),
|
|
27
|
+
with per-doc comments and diffs; plus a display-only "view as tree" toggle for flat plans
|
|
26
28
|
- π **Version history** β every `ExitPlanMode` is an immutable snapshot
|
|
29
|
+
- π **Delete** single versions, bulk-select, or a whole project (pending reviews are protected)
|
|
27
30
|
- π **Diff** current vs any previous version β **side-by-side or unified** (toggle)
|
|
28
31
|
- β
**Approve / request-changes** straight from the UI, fed back to Claude
|
|
29
32
|
- π Per-worktree scoping; per-project opt-in (only runs where you enable the hook)
|
|
@@ -79,16 +82,57 @@ run a per-project `init` in the same project, or the hook will fire twice.
|
|
|
79
82
|
new hook is picked up. Next time you finish a plan in plan mode, your browser opens
|
|
80
83
|
to the review. The server auto-starts on first use; nothing else to run.
|
|
81
84
|
|
|
85
|
+
## Team setup
|
|
86
|
+
|
|
87
|
+
Everything a colleague needs is scripted here β follow it verbatim. This section is
|
|
88
|
+
the single source of truth for how the team runs claude-plan-review.
|
|
89
|
+
|
|
90
|
+
**1. Install and wire everything up once, for all projects:**
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
npm install -g claude-plan-review # or: bun add -g claude-plan-review
|
|
94
|
+
claude-plan-review init --global --published --runtime node # pin --runtime to what your team uses
|
|
95
|
+
# swap `--runtime node` for `--runtime bun` if the team standardises on Bun
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
That single `init --global --published` does five things:
|
|
99
|
+
|
|
100
|
+
- writes the `ExitPlanMode` **hook** to `~/.claude/settings.json` (fires in **every** project);
|
|
101
|
+
- writes the **Stop-hook gate** to the same settings β it keeps Claude polling a tools-first
|
|
102
|
+
review instead of ending its turn while your decision is still pending (see below);
|
|
103
|
+
- installs the **`plan-review-multidoc` skill** to `~/.claude/skills/` (auto-triggers on large / sectioned plans);
|
|
104
|
+
- **registers the MCP server** (`plan_review_submit` / `plan_review_check` tools) via `claude mcp add --scope user`
|
|
105
|
+
β if the `claude` CLI isn't found, it prints the exact command to run;
|
|
106
|
+
- prints the **CLAUDE.md guidance block**. Add `--write-claude-md` to append it to
|
|
107
|
+
`~/.claude/CLAUDE.md` automatically (idempotent β fenced by `<!-- plan-review:start/end -->`).
|
|
108
|
+
|
|
109
|
+
`--published` makes the hook and MCP server run via `npx`/`bunx`, so there's nothing to
|
|
110
|
+
keep in any repo. **Pin `--runtime`** so everyone's hook command matches.
|
|
111
|
+
|
|
112
|
+
**2. Restart Claude Code** (or reopen `/hooks`) so the hook, skill, and MCP tools load.
|
|
113
|
+
|
|
114
|
+
**3. The review server is per-machine.** Each teammate reviews plans locally in their own
|
|
115
|
+
browser β it auto-starts on first use (`claude-plan-review serve` to start it by hand).
|
|
116
|
+
There is no shared server.
|
|
117
|
+
|
|
118
|
+
**4. Share finished plans via a storage channel.** To hand a reviewed plan to a colleague,
|
|
119
|
+
use **Save to gist** in the UI (one secret gist per project, see below). Run
|
|
120
|
+
`claude-plan-review channels` to verify `gh` is installed, logged in, and `gist`-scoped
|
|
121
|
+
before relying on it.
|
|
122
|
+
|
|
82
123
|
## Usage
|
|
83
124
|
|
|
84
125
|
Use `bun` or `node` interchangeably (or `bunx`/`npx claude-plan-review β¦` when published):
|
|
85
126
|
|
|
86
127
|
| Command | What it does |
|
|
87
128
|
| --- | --- |
|
|
88
|
-
| `β¦ cli.js init [dir] [--local] [--published] [--runtime bun\|node]` | Wire the `ExitPlanMode` hook
|
|
129
|
+
| `β¦ cli.js init [dir] [--local] [--published] [--runtime bun\|node] [--no-skill] [--write-claude-md]` | Wire the `ExitPlanMode` hook + the Stop-hook gate, install the skill, register the MCP server |
|
|
89
130
|
| `β¦ cli.js serve [port]` | Start the review server manually (default `4607`) |
|
|
90
131
|
| `β¦ cli.js stop` | Stop the running server |
|
|
91
132
|
| `β¦ cli.js channels` | Show storage-channel readiness (is `gh` installed / authed / `gist`-scoped?) |
|
|
133
|
+
| `β¦ cli.js skill` | (Re)install the `plan-review-multidoc` skill into `~/.claude/skills` |
|
|
134
|
+
| `β¦ cli.js mcp` | (internal) run the stdio MCP server exposing the `plan_review_*` tools |
|
|
135
|
+
| `β¦ cli.js stop-gate` | (internal) the `Stop` hook that blocks the turn from ending while a tools-first review is pending |
|
|
92
136
|
|
|
93
137
|
The server auto-starts on the first plan and stays up, so you can browse history
|
|
94
138
|
anytime at `http://localhost:4607`.
|
|
@@ -140,17 +184,95 @@ Claude finishes plan ββ> PreToolUse hook (ExitPlanMode)
|
|
|
140
184
|
request ββ> hook emits {permissionDecision:"deny", reason:...} ββ> Claude revises (new version)
|
|
141
185
|
```
|
|
142
186
|
|
|
187
|
+
## Multi-document plans
|
|
188
|
+
|
|
189
|
+
A big plan reads better as a **tree of documents** β a root overview plus linked
|
|
190
|
+
subpages (and sub-subpages) β than as one long scroll. The review UI renders that
|
|
191
|
+
tree with a sidebar you can navigate; comments and diffs are scoped per document.
|
|
192
|
+
|
|
193
|
+
Claude authors a tree by putting **document-separator markers** in the plan β one
|
|
194
|
+
HTML comment per document, alone on its own line (invisible when rendered as plain
|
|
195
|
+
markdown):
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
<!--doc slug=root title="Overview"-->
|
|
199
|
+
Top-level summary, linking to [[api]] and [[data-model]].
|
|
200
|
+
|
|
201
|
+
<!--doc slug=api title="API Design" parent=root-->
|
|
202
|
+
API section⦠details in [[api-auth]].
|
|
203
|
+
|
|
204
|
+
<!--doc slug=api-auth title="Auth" parent=api-->
|
|
205
|
+
Auth details.
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
- `slug` β required, kebab-case (`^[a-z0-9][a-z0-9-]*$`), unique.
|
|
209
|
+
- `title` β required (double-quote if it contains spaces).
|
|
210
|
+
- `parent` β optional; omit (or `root`/empty) for a top-level doc. **Exactly one root.**
|
|
211
|
+
- Content before the first marker becomes the root doc automatically.
|
|
212
|
+
- **Zero markers β a single-doc plan**, stored flat exactly as before (no migration).
|
|
213
|
+
- Cross-doc links: `[[slug]]`, `[[slug|text]]`, or `[text](doc:slug)` β every target
|
|
214
|
+
must be a defined slug. Marker lines inside fenced code blocks are ignored.
|
|
215
|
+
|
|
216
|
+
A **"view as tree"** toggle in the UI also splits a plain single-doc plan into a
|
|
217
|
+
navigable tree by its headings β display-only, nothing changes on disk.
|
|
218
|
+
|
|
219
|
+
### Two ways a plan gets created
|
|
220
|
+
|
|
221
|
+
1. **Plan mode** (the usual path). Claude finishes a plan in plan mode; the
|
|
222
|
+
`ExitPlanMode` hook validates the tree and opens the review. Malformed structure
|
|
223
|
+
(missing root, duplicate slug, dangling linkβ¦) is bounced back to Claude with the
|
|
224
|
+
exact problems to fix.
|
|
225
|
+
2. **Tools-first** (outside plan mode). Claude calls the **`plan_review_submit`** MCP
|
|
226
|
+
tool to open a review, then **`plan_review_check`** to poll for your decision. Both
|
|
227
|
+
are registered by `init` (see [Team setup](#team-setup)). Under the hood this uses
|
|
228
|
+
the same store as the hook, so reviews look identical either way. (A `POST /api/plans`
|
|
229
|
+
HTTP endpoint is the fallback when the MCP server isn't registered.)
|
|
230
|
+
|
|
231
|
+
`plan_review_check` accepts a **`wait`** parameter (seconds, default `0`, capped at `120`):
|
|
232
|
+
the call long-polls β it blocks server-side until you decide or `wait` elapses, then
|
|
233
|
+
returns β so Claude gets your decision promptly instead of sleeping between checks.
|
|
234
|
+
The recommended value is `20`, which keeps each call short while a loop re-issues it.
|
|
235
|
+
|
|
236
|
+
Because the plan-mode hook blocks synchronously but the tools-first path does not, a
|
|
237
|
+
**Stop-hook gate** (also installed by `init`) provides the enforcement: if Claude tries
|
|
238
|
+
to end its turn while a tools-first review it submitted is still pending (created within
|
|
239
|
+
the last 5 hours), the gate blocks and tells Claude to resume calling `plan_review_check`
|
|
240
|
+
until the review resolves. It fails open on any error and never gates plan-mode reviews.
|
|
241
|
+
|
|
242
|
+
The `plan-review-multidoc` **skill** (installed by `init`) teaches Claude to reach for
|
|
243
|
+
the doc-tree format automatically whenever a plan is large or splits into sections.
|
|
244
|
+
|
|
245
|
+
## Deleting plans
|
|
246
|
+
|
|
247
|
+
Plans no longer accumulate forever. From the UI you can:
|
|
248
|
+
|
|
249
|
+
- **Delete a single version** β a per-version button with an in-app confirm.
|
|
250
|
+
- **Bulk-delete / delete a whole project** β the manage modal (trash icon in the
|
|
251
|
+
header): tick versions and delete them, or remove the project entirely.
|
|
252
|
+
|
|
253
|
+
Deleting the **last** version removes the project directory outright. A version with a
|
|
254
|
+
**pending review** is protected: the delete returns `409` and the UI offers **Force**,
|
|
255
|
+
which auto-rejects that review (telling Claude the plan was deleted) before removing it.
|
|
256
|
+
|
|
143
257
|
## Storage layout
|
|
144
258
|
|
|
145
259
|
```
|
|
146
260
|
~/.claude/plan-review/
|
|
147
261
|
projects/<sanitized-cwd>/
|
|
148
262
|
meta.json
|
|
149
|
-
versions/
|
|
150
|
-
|
|
151
|
-
|
|
263
|
+
versions/
|
|
264
|
+
0001.json # version meta (both kinds; carries `kind`, `docCount`)
|
|
265
|
+
0001/ # tree version β a directory
|
|
266
|
+
manifest.json # {root, docs:[{slug,title,parent,file,order}]}
|
|
267
|
+
root.md api.md api-auth.md # one file per document
|
|
268
|
+
0002.md 0002.json # flat single-doc plan (legacy or markerless)
|
|
269
|
+
comments/0001.json 0002.json # comments per version (each comment carries its `doc`)
|
|
270
|
+
reviews/<id>.json # pending/resolved review requests
|
|
152
271
|
```
|
|
153
272
|
|
|
273
|
+
Single-doc plans keep the exact flat `NNNN.md` layout from earlier versions β their
|
|
274
|
+
content hash is unchanged, so dedup and history keep working with no migration.
|
|
275
|
+
|
|
154
276
|
## License
|
|
155
277
|
|
|
156
278
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-plan-review",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Review, comment on, and approve/reject Claude Code plans in a local GitHub-style web UI β with persistent comments, version history, and diffs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"src",
|
|
11
|
+
"skill",
|
|
11
12
|
"README.md",
|
|
12
13
|
"LICENSE"
|
|
13
14
|
],
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plan-review-multidoc
|
|
3
|
+
description: Author an implementation plan as a navigable tree of documents (root overview + linked subpages) instead of one long markdown blob, and get it reviewed in the claude-plan-review browser UI. Use whenever a plan is large, spans several sections/areas, is multi-part, would benefit from being split into sections, or when the user asks for a nested/structured/multi-document plan.
|
|
4
|
+
when_to_use: A plan is large or complex, splits naturally into multiple sections or areas, is multi-part, or the user asks for a nested / structured / multi-document plan (or to "split it into sections"). Covers both plan mode and getting a plan reviewed outside plan mode.
|
|
5
|
+
allowed-tools: Bash, Read
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Multi-document plans for claude-plan-review
|
|
9
|
+
|
|
10
|
+
When a plan is large or naturally sectioned, author it as a **tree of documents** β
|
|
11
|
+
a root overview plus linked subpages (and sub-subpages) β so the reviewer can
|
|
12
|
+
navigate it in the browser UI instead of scrolling one long blob. A small, simple
|
|
13
|
+
plan needs none of this: write it as plain markdown and it stays a single document.
|
|
14
|
+
|
|
15
|
+
## Marker syntax
|
|
16
|
+
|
|
17
|
+
One HTML comment per document, **alone on its own line** (harmless if rendered as
|
|
18
|
+
plain markdown). Attributes are order-independent.
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
<!--doc slug=root title="Overview"-->
|
|
22
|
+
Top-level summary. Links to the subpages: [[api]], [[data-model]].
|
|
23
|
+
|
|
24
|
+
<!--doc slug=api title="API Design" parent=root-->
|
|
25
|
+
API section⦠see [[api-auth]] for details.
|
|
26
|
+
|
|
27
|
+
<!--doc slug=api-auth title="Auth" parent=api-->
|
|
28
|
+
Auth details.
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Rules:
|
|
32
|
+
- `slug` β required, kebab-case `^[a-z0-9][a-z0-9-]*$`, unique.
|
|
33
|
+
- `title` β required (wrap in double quotes if it contains spaces).
|
|
34
|
+
- `parent` β optional; **omit it for the single top-level doc** (conventionally slugged
|
|
35
|
+
`root`). Child docs set `parent` to their parent's slug (e.g. `parent=root`).
|
|
36
|
+
- **Exactly one root** (the doc with no parent). Every `parent` must be a defined slug.
|
|
37
|
+
- Content before the first marker becomes the root doc automatically.
|
|
38
|
+
- **Zero markers β a single-doc plan** β stored flat, exactly as a normal plan.
|
|
39
|
+
- Cross-doc links: `[[slug]]`, `[[slug|link text]]`, or `[text](doc:slug)`. Every
|
|
40
|
+
target must be a defined slug.
|
|
41
|
+
- Marker lines inside fenced code blocks (```` ``` ````/`~~~`) are ignored β you can
|
|
42
|
+
show marker examples in code fences safely.
|
|
43
|
+
|
|
44
|
+
## Path A β in plan mode (preferred when you're already planning)
|
|
45
|
+
|
|
46
|
+
Put the **whole tree in the single ExitPlanMode plan string**, using the markers
|
|
47
|
+
above. The plan-review hook splits it into documents, validates the structure, and
|
|
48
|
+
opens the review. If the structure is malformed (missing root, duplicate slug,
|
|
49
|
+
dangling link, β¦) the hook denies with the exact list of problems β fix them and
|
|
50
|
+
re-present with ExitPlanMode.
|
|
51
|
+
|
|
52
|
+
## Path B β outside plan mode (tools-first)
|
|
53
|
+
|
|
54
|
+
Use the **`plan_review_submit`** MCP tool to open a review, then **`plan_review_check`**
|
|
55
|
+
to poll for the decision. This is the primary path when you are not in plan mode.
|
|
56
|
+
|
|
57
|
+
1. Call `plan_review_submit` with `cwd` (the project's absolute path) and **exactly one** of:
|
|
58
|
+
- `docs` β an array of `{slug, title, parent?, body}` (the structured tree), or
|
|
59
|
+
- `plan` β a raw string with the `<!--doc ...-->` markers above, or
|
|
60
|
+
- `markdown` β a single markdown document (no tree).
|
|
61
|
+
It returns `{ reviewId, reviewUrl, projectKey, version }` and opens the browser.
|
|
62
|
+
If the plan is structurally invalid it returns an error listing every problem β fix and resubmit.
|
|
63
|
+
2. Call `plan_review_check` with `{reviewId, wait: 20}` and keep calling it in a **loop** while the
|
|
64
|
+
status is `pending`. The tool long-polls (blocks up to `wait` seconds server-side), so each call
|
|
65
|
+
returns as soon as the user decides. **Do not end your turn while the review is pending** β call
|
|
66
|
+
again immediately. Give up only after ~5 hours total, then ask the user.
|
|
67
|
+
- `approved` β implement; incorporate `notes` if present ("approve with comments").
|
|
68
|
+
- `rejected` β revise per `reason` and resubmit with `plan_review_submit`.
|
|
69
|
+
- `pending` β call `plan_review_check` again now with `wait: 20`.
|
|
70
|
+
|
|
71
|
+
### Fallback β no MCP server registered
|
|
72
|
+
|
|
73
|
+
If the `plan_review_submit` / `plan_review_check` tools are not available (the
|
|
74
|
+
`plan-review` MCP server isn't registered), fall back to the HTTP API:
|
|
75
|
+
|
|
76
|
+
1. Resolve the port from `~/.claude/plan-review/server.json` (`.port`), else `4607`.
|
|
77
|
+
2. `POST /api/plans` with `{cwd, docs:[β¦]}` OR `{cwd, markdown}` OR `{cwd, plan}`:
|
|
78
|
+
```bash
|
|
79
|
+
curl -s -X POST http://localhost:4607/api/plans \
|
|
80
|
+
-H 'content-type: application/json' \
|
|
81
|
+
-d '{"cwd":"/abs/project","docs":[{"slug":"root","title":"Overview","body":"β¦"}]}'
|
|
82
|
+
```
|
|
83
|
+
`201` β `{key, version, reviewId, reviewUrl}`. `400` β `{error, issues}` (fix and re-POST).
|
|
84
|
+
On connection failure, tell the user to run `claude-plan-review serve`.
|
|
85
|
+
3. Poll `GET /api/reviews/:reviewId` every ~3s; read `status` (`pending`/`approved`/`rejected`),
|
|
86
|
+
`notes`, `reason`. Give up after ~5 hours and ask the user.
|
package/src/cli.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
copyFileSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
} from "node:fs";
|
|
4
11
|
import { homedir } from "node:os";
|
|
5
12
|
import { dirname, join, resolve } from "node:path";
|
|
6
13
|
import { fileURLToPath } from "node:url";
|
|
@@ -8,6 +15,9 @@ import { DEFAULT_PORT, SERVER_FILE } from "./paths.js";
|
|
|
8
15
|
|
|
9
16
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
10
17
|
const HOOK_SCRIPT = join(HERE, "hook.js");
|
|
18
|
+
const STOP_GATE_SCRIPT = join(HERE, "stop-gate.js");
|
|
19
|
+
const CLI_SCRIPT = fileURLToPath(import.meta.url);
|
|
20
|
+
const SKILL_SRC = join(HERE, "..", "skill", "SKILL.md");
|
|
11
21
|
|
|
12
22
|
function canRun(bin) {
|
|
13
23
|
try {
|
|
@@ -35,12 +45,13 @@ function chooseRuntime(args) {
|
|
|
35
45
|
|
|
36
46
|
function hookCommand(args) {
|
|
37
47
|
const runtime = chooseRuntime(args);
|
|
38
|
-
// global install β
|
|
39
|
-
//
|
|
40
|
-
//
|
|
48
|
+
// global install β always route through the package runner (npx/bunx), never the bare
|
|
49
|
+
// `claude-plan-review` command. Version managers like fnm/nvm expose global bins only via a
|
|
50
|
+
// per-shell PATH set up by interactive shell init. `canRun` succeeds here (init runs in an
|
|
51
|
+
// interactive shell) but Claude Code fires hooks in a NON-interactive shell where that PATH
|
|
52
|
+
// is absent, so the bare command resolves at init time yet fails β silently β at hook time.
|
|
53
|
+
// `npx`/`bunx` are inherited reliably from Claude Code's launching env, so they work either way.
|
|
41
54
|
if (args.includes("--global")) {
|
|
42
|
-
if (canRun("claude-plan-review")) return "claude-plan-review hook";
|
|
43
|
-
// not on PATH β fall back to the package runner
|
|
44
55
|
return runtime === "node" ? "npx claude-plan-review hook" : "bunx claude-plan-review hook";
|
|
45
56
|
}
|
|
46
57
|
if (args.includes("--published") || args.includes("--bunx")) {
|
|
@@ -50,6 +61,102 @@ function hookCommand(args) {
|
|
|
50
61
|
return `${resolveBin(runtime)} ${HOOK_SCRIPT}`;
|
|
51
62
|
}
|
|
52
63
|
|
|
64
|
+
// The Stop-hook gate command, mirroring hookCommand's runtime routing: global/
|
|
65
|
+
// published installs go through the package runner (npx/bunx) so they resolve in
|
|
66
|
+
// Claude Code's non-interactive env; a local install points straight at the
|
|
67
|
+
// stop-gate.js script (which runs on import, like hook.js).
|
|
68
|
+
function stopGateCommand(args) {
|
|
69
|
+
const runtime = chooseRuntime(args);
|
|
70
|
+
if (args.includes("--global") || args.includes("--published") || args.includes("--bunx")) {
|
|
71
|
+
return runtime === "node"
|
|
72
|
+
? "npx claude-plan-review stop-gate"
|
|
73
|
+
: "bunx claude-plan-review stop-gate";
|
|
74
|
+
}
|
|
75
|
+
return `${resolveBin(runtime)} ${STOP_GATE_SCRIPT}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The program + args that launch the MCP server, mirroring hookCommand's
|
|
79
|
+
// runtime routing: global/published installs go through the package runner
|
|
80
|
+
// (npx/bunx) so they resolve in Claude Code's non-interactive env; a local
|
|
81
|
+
// install points straight at this cli.js.
|
|
82
|
+
function mcpRunnerArgs(args) {
|
|
83
|
+
const runtime = chooseRuntime(args);
|
|
84
|
+
if (args.includes("--global") || args.includes("--published") || args.includes("--bunx")) {
|
|
85
|
+
return runtime === "node"
|
|
86
|
+
? ["npx", "claude-plan-review", "mcp"]
|
|
87
|
+
: ["bunx", "claude-plan-review", "mcp"];
|
|
88
|
+
}
|
|
89
|
+
return [resolveBin(runtime), CLI_SCRIPT, "mcp"];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Register the local MCP server with the `claude` CLI (user scope) so the
|
|
93
|
+
// plan_review_submit / plan_review_check tools are available in every project.
|
|
94
|
+
// If the `claude` CLI isn't on PATH, print the exact command to run by hand.
|
|
95
|
+
function registerMcp(args) {
|
|
96
|
+
const runner = mcpRunnerArgs(args);
|
|
97
|
+
const addArgs = ["mcp", "add", "--scope", "user", "plan-review", "--", ...runner];
|
|
98
|
+
const manual = `claude ${addArgs.join(" ")}`;
|
|
99
|
+
if (canRun("claude")) {
|
|
100
|
+
const r = spawnSync("claude", addArgs, { stdio: "inherit" });
|
|
101
|
+
if (r.status === 0) {
|
|
102
|
+
console.log("β Registered the plan-review MCP server (plan_review_submit / plan_review_check tools).");
|
|
103
|
+
} else {
|
|
104
|
+
console.log(
|
|
105
|
+
`β¦ couldn't auto-register the MCP server (it may already exist). To (re)register, run:\n ${manual}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
console.log(
|
|
110
|
+
`To enable the plan_review_submit / plan_review_check tools, register the MCP server:\n ${manual}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Copy the bundled skill into ~/.claude/skills so it auto-triggers in every project.
|
|
116
|
+
function installSkill() {
|
|
117
|
+
const destDir = join(homedir(), ".claude", "skills", "plan-review-multidoc");
|
|
118
|
+
const dest = join(destDir, "SKILL.md");
|
|
119
|
+
mkdirSync(destDir, { recursive: true });
|
|
120
|
+
copyFileSync(SKILL_SRC, dest);
|
|
121
|
+
console.log(`β Installed plan-review-multidoc skill to ${dest}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const CLAUDE_MD_START = "<!-- plan-review:start -->";
|
|
125
|
+
const CLAUDE_MD_END = "<!-- plan-review:end -->";
|
|
126
|
+
const CLAUDE_MD_BODY = `## Plan review
|
|
127
|
+
|
|
128
|
+
When a plan is large or splits into multiple sections/areas, author it as a
|
|
129
|
+
navigable **tree of documents** using the \`plan-review-multidoc\` markers
|
|
130
|
+
(root overview + linked subpages) rather than one long markdown blob. Small,
|
|
131
|
+
single-topic plans stay as plain markdown. See the \`plan-review-multidoc\` skill.`;
|
|
132
|
+
|
|
133
|
+
function claudeMdBlock() {
|
|
134
|
+
return `${CLAUDE_MD_START}\n${CLAUDE_MD_BODY}\n${CLAUDE_MD_END}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Append (or refresh) the CLAUDE.md guidance block idempotently, fenced by
|
|
138
|
+
// <!-- plan-review:start/end --> so re-running only ever updates that region.
|
|
139
|
+
function writeClaudeMd(targetFile) {
|
|
140
|
+
mkdirSync(dirname(targetFile), { recursive: true });
|
|
141
|
+
const block = claudeMdBlock();
|
|
142
|
+
let existing = "";
|
|
143
|
+
try {
|
|
144
|
+
existing = readFileSync(targetFile, "utf8");
|
|
145
|
+
} catch {
|
|
146
|
+
existing = "";
|
|
147
|
+
}
|
|
148
|
+
const startIdx = existing.indexOf(CLAUDE_MD_START);
|
|
149
|
+
const endIdx = existing.indexOf(CLAUDE_MD_END);
|
|
150
|
+
let next;
|
|
151
|
+
if (startIdx >= 0 && endIdx > startIdx) {
|
|
152
|
+
next = existing.slice(0, startIdx) + block + existing.slice(endIdx + CLAUDE_MD_END.length);
|
|
153
|
+
} else {
|
|
154
|
+
next = existing.trim() ? `${existing.replace(/\s*$/, "")}\n\n${block}\n` : `${block}\n`;
|
|
155
|
+
}
|
|
156
|
+
writeFileSync(targetFile, next);
|
|
157
|
+
console.log(`β Wrote plan-review guidance block to ${targetFile}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
53
160
|
function readJSON(path, fallback) {
|
|
54
161
|
try {
|
|
55
162
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -70,6 +177,7 @@ function cmdInit(args) {
|
|
|
70
177
|
const settings = readJSON(settingsFile, {});
|
|
71
178
|
settings.hooks ??= {};
|
|
72
179
|
settings.hooks.PreToolUse ??= [];
|
|
180
|
+
settings.hooks.Stop ??= [];
|
|
73
181
|
|
|
74
182
|
const command = hookCommand(args);
|
|
75
183
|
const already = settings.hooks.PreToolUse.some(
|
|
@@ -88,7 +196,58 @@ function cmdInit(args) {
|
|
|
88
196
|
writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + "\n");
|
|
89
197
|
console.log(`β Wrote ExitPlanMode plan-review hook to ${settingsFile}`);
|
|
90
198
|
}
|
|
91
|
-
|
|
199
|
+
|
|
200
|
+
// Stop-hook gate: blocks the turn from ending while a tools-first (mcp/api)
|
|
201
|
+
// review is still pending. Matcher-less (Stop has no tool matcher).
|
|
202
|
+
const stopCommand = stopGateCommand(args);
|
|
203
|
+
const stopAlready = settings.hooks.Stop.some((entry) =>
|
|
204
|
+
(entry.hooks || []).some(
|
|
205
|
+
(h) => h?.command?.includes("stop-gate") || h?.command === stopCommand,
|
|
206
|
+
),
|
|
207
|
+
);
|
|
208
|
+
if (stopAlready) {
|
|
209
|
+
console.log(`β Stop plan-review gate already present in ${settingsFile}`);
|
|
210
|
+
} else {
|
|
211
|
+
settings.hooks.Stop.push({
|
|
212
|
+
hooks: [{ type: "command", command: stopCommand, timeout: 10 }],
|
|
213
|
+
});
|
|
214
|
+
writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + "\n");
|
|
215
|
+
console.log(`β Wrote Stop plan-review gate to ${settingsFile}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
console.log(
|
|
219
|
+
`\nRuntime: ${chooseRuntime(args)}\nHook: ${command}\nStop: ${stopCommand}`,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// Skill: auto-triggers the multi-document plan authoring in every project.
|
|
223
|
+
if (args.includes("--no-skill")) {
|
|
224
|
+
console.log("\n(skipped skill install β --no-skill)");
|
|
225
|
+
} else {
|
|
226
|
+
try {
|
|
227
|
+
installSkill();
|
|
228
|
+
} catch (e) {
|
|
229
|
+
console.log(`β Could not install the skill: ${e?.message || e}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// MCP server: enables the plan_review_submit / plan_review_check tools.
|
|
234
|
+
console.log("");
|
|
235
|
+
registerMcp(args);
|
|
236
|
+
|
|
237
|
+
// CLAUDE.md guidance block: written on --write-claude-md, printed otherwise.
|
|
238
|
+
const claudeMdFile = global
|
|
239
|
+
? join(homedir(), ".claude", "CLAUDE.md")
|
|
240
|
+
: join(resolve(dirArg || process.cwd()), "CLAUDE.md");
|
|
241
|
+
if (args.includes("--write-claude-md")) {
|
|
242
|
+
console.log("");
|
|
243
|
+
writeClaudeMd(claudeMdFile);
|
|
244
|
+
} else {
|
|
245
|
+
console.log(
|
|
246
|
+
`\nAdd this to ${global ? "~/.claude/CLAUDE.md" : "your project's CLAUDE.md"} ` +
|
|
247
|
+
`(or re-run with --write-claude-md to append it automatically):\n\n${claudeMdBlock()}`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
92
251
|
console.log(
|
|
93
252
|
`\nReopen the /hooks menu once (or restart Claude Code) ${global ? "in any project" : "in this project"} so the new hook is picked up.\n` +
|
|
94
253
|
`Then finish a plan in plan mode β your browser will open the review.`,
|
|
@@ -138,21 +297,37 @@ function cmdStop() {
|
|
|
138
297
|
rmSync(SERVER_FILE, { force: true });
|
|
139
298
|
}
|
|
140
299
|
|
|
300
|
+
function cmdSkill() {
|
|
301
|
+
installSkill();
|
|
302
|
+
console.log(
|
|
303
|
+
"\nThe plan-review-multidoc skill auto-triggers when a plan is large or splits into sections.\n" +
|
|
304
|
+
"Restart Claude Code (or start a new session) to pick it up.",
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
141
308
|
function usage() {
|
|
142
309
|
console.log(`claude-plan-review β review Claude Code plans in your browser (runs on Bun or Node β₯18)
|
|
143
310
|
|
|
144
311
|
Usage:
|
|
145
312
|
claude-plan-review init [dir] [--global] [--local] [--published] [--runtime bun|node]
|
|
146
|
-
|
|
313
|
+
[--no-skill] [--write-claude-md]
|
|
314
|
+
Wire the ExitPlanMode hook + the Stop-hook gate into a project
|
|
315
|
+
(or all projects), install the plan-review-multidoc skill, and
|
|
316
|
+
register the MCP server.
|
|
147
317
|
Runtime auto-detects (Bun if installed, else Node).
|
|
148
|
-
--global
|
|
149
|
-
--local
|
|
150
|
-
--published
|
|
151
|
-
--runtime
|
|
318
|
+
--global β ~/.claude/settings.json (applies to ALL projects)
|
|
319
|
+
--local β .claude/settings.local.json
|
|
320
|
+
--published β portable bunx/npx command (after npm publish)
|
|
321
|
+
--runtime β force a runtime
|
|
322
|
+
--no-skill β don't install the multi-doc plan skill
|
|
323
|
+
--write-claude-mdβ append the CLAUDE.md guidance block (else printed)
|
|
152
324
|
claude-plan-review serve [port] Start the review server (default ${DEFAULT_PORT})
|
|
153
325
|
claude-plan-review stop Stop the running server
|
|
154
326
|
claude-plan-review channels Show storage-channel readiness (e.g. gh / gist)
|
|
327
|
+
claude-plan-review skill (Re)install the plan-review-multidoc skill into ~/.claude/skills
|
|
328
|
+
claude-plan-review mcp (internal) run the stdio MCP server (plan_review_* tools)
|
|
155
329
|
claude-plan-review hook (internal) the PreToolUse hook entry
|
|
330
|
+
claude-plan-review stop-gate (internal) the Stop hook entry (blocks on a pending tools-first review)
|
|
156
331
|
`);
|
|
157
332
|
}
|
|
158
333
|
|
|
@@ -170,9 +345,20 @@ switch (sub) {
|
|
|
170
345
|
case "channels":
|
|
171
346
|
await cmdChannels();
|
|
172
347
|
break;
|
|
348
|
+
case "skill":
|
|
349
|
+
cmdSkill();
|
|
350
|
+
break;
|
|
351
|
+
case "mcp": {
|
|
352
|
+
const { startMcpServer } = await import("./mcp.js");
|
|
353
|
+
startMcpServer();
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
173
356
|
case "hook":
|
|
174
357
|
await import("./hook.js");
|
|
175
358
|
break;
|
|
359
|
+
case "stop-gate":
|
|
360
|
+
await import("./stop-gate.js");
|
|
361
|
+
break;
|
|
176
362
|
default:
|
|
177
363
|
usage();
|
|
178
364
|
}
|
package/src/hook.js
CHANGED
|
@@ -14,6 +14,7 @@ import { readFileSync } from "node:fs";
|
|
|
14
14
|
import { dirname, join } from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { DEFAULT_PORT, SERVER_FILE } from "./paths.js";
|
|
17
|
+
import { buildDenyReason, parsePlan } from "./plan-parse.js";
|
|
17
18
|
import { getReview, recordPlan } from "./store.js";
|
|
18
19
|
|
|
19
20
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -127,18 +128,40 @@ async function main() {
|
|
|
127
128
|
const plan = toolInput.plan ?? "";
|
|
128
129
|
if (!plan.trim()) process.exit(0);
|
|
129
130
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
131
|
+
// Parse the plan into a normalized tree (or single-doc). A returned {error}
|
|
132
|
+
// is an EXPLICIT marker-validation failure β deny so Claude stays in plan
|
|
133
|
+
// mode and re-authors. Any thrown exception is unexpected β fail open (exit
|
|
134
|
+
// 0 silently) so a parser bug never blocks the user.
|
|
135
|
+
let tree;
|
|
136
|
+
try {
|
|
137
|
+
tree = parsePlan(plan);
|
|
138
|
+
} catch {
|
|
139
|
+
process.exit(0);
|
|
140
|
+
}
|
|
141
|
+
if (tree && tree.error) {
|
|
142
|
+
deny(buildDenyReason(tree.error.issues));
|
|
143
|
+
process.exit(0);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let recorded;
|
|
147
|
+
try {
|
|
148
|
+
recorded = recordPlan({
|
|
149
|
+
cwd: input.cwd || process.cwd(),
|
|
150
|
+
sessionId: input.session_id,
|
|
151
|
+
toolUseId: input.tool_use_id,
|
|
152
|
+
planFilePath: input.tool_input?.planFilePath,
|
|
153
|
+
tree,
|
|
154
|
+
origin: "hook",
|
|
155
|
+
});
|
|
156
|
+
} catch {
|
|
157
|
+
process.exit(0); // store failure β don't interfere
|
|
158
|
+
}
|
|
159
|
+
const { key, version, reviewId } = recorded;
|
|
137
160
|
|
|
138
161
|
const port = await ensureServer();
|
|
139
162
|
const reviewUrl = `http://localhost:${port}/?project=${encodeURIComponent(
|
|
140
163
|
key,
|
|
141
|
-
)}&version=${version}&review=${reviewId}`;
|
|
164
|
+
)}&version=${version}&review=${reviewId}&doc=root`;
|
|
142
165
|
openBrowser(reviewUrl);
|
|
143
166
|
|
|
144
167
|
// block until the UI resolves the review (or we time out)
|