claude-plan-review 0.3.2 β 0.5.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 +132 -4
- package/package.json +2 -1
- package/skill/SKILL.md +90 -0
- package/src/cli.js +279 -9
- package/src/hook.js +37 -9
- package/src/mcp.js +444 -0
- package/src/paths.js +17 -0
- package/src/plan-context.js +63 -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 +372 -34
- package/src/ui/app.js +639 -108
- package/src/ui/index.html +50 -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,60 @@ 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 these things:
|
|
99
|
+
|
|
100
|
+
- writes the `ExitPlanMode` **hook** to `~/.claude/settings.json` (fires in **every** project);
|
|
101
|
+
- writes the `EnterPlanMode` **context hook** to the same settings β it nudges Claude toward the
|
|
102
|
+
multi-document tree format as it starts planning, **without** touching plan-mode consent (it
|
|
103
|
+
makes no permission decision, so your approval to enter plan mode is untouched);
|
|
104
|
+
- writes the **Stop-hook gate** to the same settings β it keeps Claude polling a tools-first
|
|
105
|
+
review instead of ending its turn while your decision is still pending (see below);
|
|
106
|
+
- installs the **`plan-review-multidoc` skill** to `~/.claude/skills/` (auto-triggers on large / sectioned plans);
|
|
107
|
+
- **registers the MCP server** (`plan_review_submit` / `plan_review_check` tools) via `claude mcp add --scope user`
|
|
108
|
+
β if the `claude` CLI isn't found, it prints the exact command to run;
|
|
109
|
+
- prints the **CLAUDE.md guidance block**. Add `--write-claude-md` to append it to
|
|
110
|
+
`~/.claude/CLAUDE.md` automatically (idempotent β fenced by `<!-- plan-review:start/end -->`).
|
|
111
|
+
|
|
112
|
+
`--published` makes the hook and MCP server run via `npx`/`bunx`, so there's nothing to
|
|
113
|
+
keep in any repo. **Pin `--runtime`** so everyone's hook command matches.
|
|
114
|
+
|
|
115
|
+
**2. Restart Claude Code** (or reopen `/hooks`) so the hook, skill, and MCP tools load.
|
|
116
|
+
|
|
117
|
+
**3. The review server is per-machine.** Each teammate reviews plans locally in their own
|
|
118
|
+
browser β it auto-starts on first use (`claude-plan-review serve` to start it by hand).
|
|
119
|
+
There is no shared server.
|
|
120
|
+
|
|
121
|
+
**4. Share finished plans via a storage channel.** To hand a reviewed plan to a colleague,
|
|
122
|
+
use **Save to gist** in the UI (one secret gist per project, see below). Run
|
|
123
|
+
`claude-plan-review channels` to verify `gh` is installed, logged in, and `gist`-scoped
|
|
124
|
+
before relying on it.
|
|
125
|
+
|
|
82
126
|
## Usage
|
|
83
127
|
|
|
84
128
|
Use `bun` or `node` interchangeably (or `bunx`/`npx claude-plan-review β¦` when published):
|
|
85
129
|
|
|
86
130
|
| Command | What it does |
|
|
87
131
|
| --- | --- |
|
|
88
|
-
| `β¦ cli.js init [dir] [--local] [--published] [--runtime bun\|node]` | Wire the `ExitPlanMode` hook
|
|
132
|
+
| `β¦ cli.js init [dir] [--local] [--published] [--runtime bun\|node] [--no-skill] [--write-claude-md]` | Wire the `ExitPlanMode` hook + the `EnterPlanMode` context hook + the Stop-hook gate, install the skill, register the MCP server |
|
|
89
133
|
| `β¦ cli.js serve [port]` | Start the review server manually (default `4607`) |
|
|
90
134
|
| `β¦ cli.js stop` | Stop the running server |
|
|
91
135
|
| `β¦ cli.js channels` | Show storage-channel readiness (is `gh` installed / authed / `gist`-scoped?) |
|
|
136
|
+
| `β¦ cli.js skill` | (Re)install the `plan-review-multidoc` skill into `~/.claude/skills` |
|
|
137
|
+
| `β¦ cli.js mcp` | (internal) run the stdio MCP server exposing the `plan_review_*` tools |
|
|
138
|
+
| `β¦ cli.js stop-gate` | (internal) the `Stop` hook that blocks the turn from ending while a tools-first review is pending |
|
|
92
139
|
|
|
93
140
|
The server auto-starts on the first plan and stays up, so you can browse history
|
|
94
141
|
anytime at `http://localhost:4607`.
|
|
@@ -140,17 +187,98 @@ Claude finishes plan ββ> PreToolUse hook (ExitPlanMode)
|
|
|
140
187
|
request ββ> hook emits {permissionDecision:"deny", reason:...} ββ> Claude revises (new version)
|
|
141
188
|
```
|
|
142
189
|
|
|
190
|
+
## Multi-document plans
|
|
191
|
+
|
|
192
|
+
A big plan reads better as a **tree of documents** β a root overview plus linked
|
|
193
|
+
subpages (and sub-subpages) β than as one long scroll. The review UI renders that
|
|
194
|
+
tree with a sidebar you can navigate; comments and diffs are scoped per document.
|
|
195
|
+
|
|
196
|
+
Claude authors a tree by putting **document-separator markers** in the plan β one
|
|
197
|
+
HTML comment per document, alone on its own line (invisible when rendered as plain
|
|
198
|
+
markdown):
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
<!--doc slug=root title="Overview"-->
|
|
202
|
+
Top-level summary, linking to [[api]] and [[data-model]].
|
|
203
|
+
|
|
204
|
+
<!--doc slug=api title="API Design" parent=root-->
|
|
205
|
+
API section⦠details in [[api-auth]].
|
|
206
|
+
|
|
207
|
+
<!--doc slug=api-auth title="Auth" parent=api-->
|
|
208
|
+
Auth details.
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
- `slug` β required, kebab-case (`^[a-z0-9][a-z0-9-]*$`), unique.
|
|
212
|
+
- `title` β required (double-quote if it contains spaces).
|
|
213
|
+
- `parent` β optional; omit (or `root`/empty) for a top-level doc. **Exactly one root.**
|
|
214
|
+
- Content before the first marker becomes the root doc automatically.
|
|
215
|
+
- **Zero markers β a single-doc plan**, stored flat exactly as before (no migration).
|
|
216
|
+
- Cross-doc links: `[[slug]]`, `[[slug|text]]`, or `[text](doc:slug)` β every target
|
|
217
|
+
must be a defined slug. Marker lines inside fenced code blocks are ignored.
|
|
218
|
+
|
|
219
|
+
A **"view as tree"** toggle in the UI also splits a plain single-doc plan into a
|
|
220
|
+
navigable tree by its headings β display-only, nothing changes on disk.
|
|
221
|
+
|
|
222
|
+
### Two ways a plan gets created
|
|
223
|
+
|
|
224
|
+
1. **Plan mode** (the usual path). Claude finishes a plan in plan mode; the
|
|
225
|
+
`ExitPlanMode` hook validates the tree and opens the review. Malformed structure
|
|
226
|
+
(missing root, duplicate slug, dangling linkβ¦) is bounced back to Claude with the
|
|
227
|
+
exact problems to fix.
|
|
228
|
+
2. **Tools-first** (outside plan mode). Claude calls the **`plan_review_submit`** MCP
|
|
229
|
+
tool to open a review, then **`plan_review_check`** to poll for your decision. Both
|
|
230
|
+
are registered by `init` (see [Team setup](#team-setup)). Under the hood this uses
|
|
231
|
+
the same store as the hook, so reviews look identical either way. (A `POST /api/plans`
|
|
232
|
+
HTTP endpoint is the fallback when the MCP server isn't registered.)
|
|
233
|
+
|
|
234
|
+
`plan_review_check` accepts a **`wait`** parameter (seconds, default `0`, capped at `120`):
|
|
235
|
+
the call long-polls β it blocks server-side until you decide or `wait` elapses, then
|
|
236
|
+
returns β so Claude gets your decision promptly instead of sleeping between checks.
|
|
237
|
+
The recommended value is `20`, which keeps each call short while a loop re-issues it.
|
|
238
|
+
|
|
239
|
+
Because the plan-mode hook blocks synchronously but the tools-first path does not, a
|
|
240
|
+
**Stop-hook gate** (also installed by `init`) provides the enforcement: if Claude tries
|
|
241
|
+
to end its turn while a tools-first review it submitted is still pending (created within
|
|
242
|
+
the last 5 hours), the gate blocks and tells Claude to resume calling `plan_review_check`
|
|
243
|
+
until the review resolves. It fails open on any error and never gates plan-mode reviews.
|
|
244
|
+
|
|
245
|
+
The `plan-review-multidoc` **skill** (installed by `init`) teaches Claude to reach for
|
|
246
|
+
the doc-tree format automatically whenever a plan is large or splits into sections.
|
|
247
|
+
|
|
248
|
+
## Deleting plans
|
|
249
|
+
|
|
250
|
+
Plans no longer accumulate forever. From the UI you can:
|
|
251
|
+
|
|
252
|
+
- **Delete a single version** β a per-version button. Deletes happen **immediately**
|
|
253
|
+
on click (no confirmation dialog).
|
|
254
|
+
- **Bulk-delete / delete a whole project** β the manage modal (trash icon in the
|
|
255
|
+
header): tick versions and delete them, or remove the project entirely β also immediate.
|
|
256
|
+
|
|
257
|
+
Deleting the **last** version removes the project directory outright. A version with a
|
|
258
|
+
**pending review** is protected: the delete is refused (`409` for a single version or
|
|
259
|
+
project; a non-empty `blocked` list for a bulk delete) and the UI then pops a dialog
|
|
260
|
+
offering **Force**, which auto-rejects the review (telling Claude the plan was deleted)
|
|
261
|
+
before removing it. That blocked/Force dialog is the only confirmation step that remains.
|
|
262
|
+
|
|
143
263
|
## Storage layout
|
|
144
264
|
|
|
145
265
|
```
|
|
146
266
|
~/.claude/plan-review/
|
|
147
267
|
projects/<sanitized-cwd>/
|
|
148
268
|
meta.json
|
|
149
|
-
versions/
|
|
150
|
-
|
|
151
|
-
|
|
269
|
+
versions/
|
|
270
|
+
0001.json # version meta (both kinds; carries `kind`, `docCount`)
|
|
271
|
+
0001/ # tree version β a directory
|
|
272
|
+
manifest.json # {root, docs:[{slug,title,parent,file,order}]}
|
|
273
|
+
root.md api.md api-auth.md # one file per document
|
|
274
|
+
0002.md 0002.json # flat single-doc plan (legacy or markerless)
|
|
275
|
+
comments/0001.json 0002.json # comments per version (each comment carries its `doc`)
|
|
276
|
+
reviews/<id>.json # pending/resolved review requests
|
|
152
277
|
```
|
|
153
278
|
|
|
279
|
+
Single-doc plans keep the exact flat `NNNN.md` layout from earlier versions β their
|
|
280
|
+
content hash is unchanged, so dedup and history keep working with no migration.
|
|
281
|
+
|
|
154
282
|
## License
|
|
155
283
|
|
|
156
284
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-plan-review",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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,90 @@
|
|
|
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
|
+
> An `EnterPlanMode` PreToolUse hook (installed by `init`) injects a short reminder of
|
|
45
|
+
> this marker format as you start planning β it only adds context and never affects
|
|
46
|
+
> your consent to enter plan mode. This skill is the full reference behind that nudge.
|
|
47
|
+
|
|
48
|
+
## Path A β in plan mode (preferred when you're already planning)
|
|
49
|
+
|
|
50
|
+
Put the **whole tree in the single ExitPlanMode plan string**, using the markers
|
|
51
|
+
above. The plan-review hook splits it into documents, validates the structure, and
|
|
52
|
+
opens the review. If the structure is malformed (missing root, duplicate slug,
|
|
53
|
+
dangling link, β¦) the hook denies with the exact list of problems β fix them and
|
|
54
|
+
re-present with ExitPlanMode.
|
|
55
|
+
|
|
56
|
+
## Path B β outside plan mode (tools-first)
|
|
57
|
+
|
|
58
|
+
Use the **`plan_review_submit`** MCP tool to open a review, then **`plan_review_check`**
|
|
59
|
+
to poll for the decision. This is the primary path when you are not in plan mode.
|
|
60
|
+
|
|
61
|
+
1. Call `plan_review_submit` with `cwd` (the project's absolute path) and **exactly one** of:
|
|
62
|
+
- `docs` β an array of `{slug, title, parent?, body}` (the structured tree), or
|
|
63
|
+
- `plan` β a raw string with the `<!--doc ...-->` markers above, or
|
|
64
|
+
- `markdown` β a single markdown document (no tree).
|
|
65
|
+
It returns `{ reviewId, reviewUrl, projectKey, version }` and opens the browser.
|
|
66
|
+
If the plan is structurally invalid it returns an error listing every problem β fix and resubmit.
|
|
67
|
+
2. Call `plan_review_check` with `{reviewId, wait: 20}` and keep calling it in a **loop** while the
|
|
68
|
+
status is `pending`. The tool long-polls (blocks up to `wait` seconds server-side), so each call
|
|
69
|
+
returns as soon as the user decides. **Do not end your turn while the review is pending** β call
|
|
70
|
+
again immediately. Give up only after ~5 hours total, then ask the user.
|
|
71
|
+
- `approved` β implement; incorporate `notes` if present ("approve with comments").
|
|
72
|
+
- `rejected` β revise per `reason` and resubmit with `plan_review_submit`.
|
|
73
|
+
- `pending` β call `plan_review_check` again now with `wait: 20`.
|
|
74
|
+
|
|
75
|
+
### Fallback β no MCP server registered
|
|
76
|
+
|
|
77
|
+
If the `plan_review_submit` / `plan_review_check` tools are not available (the
|
|
78
|
+
`plan-review` MCP server isn't registered), fall back to the HTTP API:
|
|
79
|
+
|
|
80
|
+
1. Resolve the port from `~/.claude/plan-review/server.json` (`.port`), else `4607`.
|
|
81
|
+
2. `POST /api/plans` with `{cwd, docs:[β¦]}` OR `{cwd, markdown}` OR `{cwd, plan}`:
|
|
82
|
+
```bash
|
|
83
|
+
curl -s -X POST http://localhost:4607/api/plans \
|
|
84
|
+
-H 'content-type: application/json' \
|
|
85
|
+
-d '{"cwd":"/abs/project","docs":[{"slug":"root","title":"Overview","body":"β¦"}]}'
|
|
86
|
+
```
|
|
87
|
+
`201` β `{key, version, reviewId, reviewUrl}`. `400` β `{error, issues}` (fix and re-POST).
|
|
88
|
+
On connection failure, tell the user to run `claude-plan-review serve`.
|
|
89
|
+
3. Poll `GET /api/reviews/:reviewId` every ~3s; read `status` (`pending`/`approved`/`rejected`),
|
|
90
|
+
`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,21 @@ 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 PLAN_CONTEXT_SCRIPT = join(HERE, "plan-context.js");
|
|
20
|
+
const CLI_SCRIPT = fileURLToPath(import.meta.url);
|
|
21
|
+
const SKILL_SRC = join(HERE, "..", "skill", "SKILL.md");
|
|
22
|
+
|
|
23
|
+
// A hook command "belongs to" plan-review when it invokes one of our entrypoints,
|
|
24
|
+
// regardless of prefix (env vars) or runner (node/bun/npx/bunx). Robust dedup:
|
|
25
|
+
// treat `PLAN_REVIEW_TIMEOUT=β¦ npx claude-plan-review hook`, `bunx claude-plan-review hook`,
|
|
26
|
+
// and `node /abs/path/hook.js` all as the same existing hook.
|
|
27
|
+
// kind: "hook" | "stop-gate" | "plan-context"
|
|
28
|
+
function isPlanReviewHook(command, kind) {
|
|
29
|
+
if (!command) return false;
|
|
30
|
+
const scriptFile = { hook: "hook.js", "stop-gate": "stop-gate.js", "plan-context": "plan-context.js" }[kind];
|
|
31
|
+
return command.includes(`claude-plan-review ${kind}`) || (!!scriptFile && command.includes(scriptFile));
|
|
32
|
+
}
|
|
11
33
|
|
|
12
34
|
function canRun(bin) {
|
|
13
35
|
try {
|
|
@@ -51,6 +73,116 @@ function hookCommand(args) {
|
|
|
51
73
|
return `${resolveBin(runtime)} ${HOOK_SCRIPT}`;
|
|
52
74
|
}
|
|
53
75
|
|
|
76
|
+
// The Stop-hook gate command, mirroring hookCommand's runtime routing: global/
|
|
77
|
+
// published installs go through the package runner (npx/bunx) so they resolve in
|
|
78
|
+
// Claude Code's non-interactive env; a local install points straight at the
|
|
79
|
+
// stop-gate.js script (which runs on import, like hook.js).
|
|
80
|
+
function stopGateCommand(args) {
|
|
81
|
+
const runtime = chooseRuntime(args);
|
|
82
|
+
if (args.includes("--global") || args.includes("--published") || args.includes("--bunx")) {
|
|
83
|
+
return runtime === "node"
|
|
84
|
+
? "npx claude-plan-review stop-gate"
|
|
85
|
+
: "bunx claude-plan-review stop-gate";
|
|
86
|
+
}
|
|
87
|
+
return `${resolveBin(runtime)} ${STOP_GATE_SCRIPT}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The EnterPlanMode context-injection hook command, mirroring hookCommand's
|
|
91
|
+
// runtime routing: global/published installs go through the package runner
|
|
92
|
+
// (npx/bunx) so they resolve in Claude Code's non-interactive env; a local
|
|
93
|
+
// install points straight at plan-context.js (which runs on import).
|
|
94
|
+
function planContextCommand(args) {
|
|
95
|
+
const runtime = chooseRuntime(args);
|
|
96
|
+
if (args.includes("--global") || args.includes("--published") || args.includes("--bunx")) {
|
|
97
|
+
return runtime === "node"
|
|
98
|
+
? "npx claude-plan-review plan-context"
|
|
99
|
+
: "bunx claude-plan-review plan-context";
|
|
100
|
+
}
|
|
101
|
+
return `${resolveBin(runtime)} ${PLAN_CONTEXT_SCRIPT}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The program + args that launch the MCP server, mirroring hookCommand's
|
|
105
|
+
// runtime routing: global/published installs go through the package runner
|
|
106
|
+
// (npx/bunx) so they resolve in Claude Code's non-interactive env; a local
|
|
107
|
+
// install points straight at this cli.js.
|
|
108
|
+
function mcpRunnerArgs(args) {
|
|
109
|
+
const runtime = chooseRuntime(args);
|
|
110
|
+
if (args.includes("--global") || args.includes("--published") || args.includes("--bunx")) {
|
|
111
|
+
return runtime === "node"
|
|
112
|
+
? ["npx", "claude-plan-review", "mcp"]
|
|
113
|
+
: ["bunx", "claude-plan-review", "mcp"];
|
|
114
|
+
}
|
|
115
|
+
return [resolveBin(runtime), CLI_SCRIPT, "mcp"];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Register the local MCP server with the `claude` CLI (user scope) so the
|
|
119
|
+
// plan_review_submit / plan_review_check tools are available in every project.
|
|
120
|
+
// If the `claude` CLI isn't on PATH, print the exact command to run by hand.
|
|
121
|
+
function registerMcp(args) {
|
|
122
|
+
const runner = mcpRunnerArgs(args);
|
|
123
|
+
const addArgs = ["mcp", "add", "--scope", "user", "plan-review", "--", ...runner];
|
|
124
|
+
const manual = `claude ${addArgs.join(" ")}`;
|
|
125
|
+
if (canRun("claude")) {
|
|
126
|
+
const r = spawnSync("claude", addArgs, { stdio: "inherit" });
|
|
127
|
+
if (r.status === 0) {
|
|
128
|
+
console.log("β Registered the plan-review MCP server (plan_review_submit / plan_review_check tools).");
|
|
129
|
+
} else {
|
|
130
|
+
console.log(
|
|
131
|
+
`β¦ couldn't auto-register the MCP server (it may already exist). To (re)register, run:\n ${manual}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
console.log(
|
|
136
|
+
`To enable the plan_review_submit / plan_review_check tools, register the MCP server:\n ${manual}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Copy the bundled skill into ~/.claude/skills so it auto-triggers in every project.
|
|
142
|
+
function installSkill() {
|
|
143
|
+
const destDir = join(homedir(), ".claude", "skills", "plan-review-multidoc");
|
|
144
|
+
const dest = join(destDir, "SKILL.md");
|
|
145
|
+
mkdirSync(destDir, { recursive: true });
|
|
146
|
+
copyFileSync(SKILL_SRC, dest);
|
|
147
|
+
console.log(`β Installed plan-review-multidoc skill to ${dest}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const CLAUDE_MD_START = "<!-- plan-review:start -->";
|
|
151
|
+
const CLAUDE_MD_END = "<!-- plan-review:end -->";
|
|
152
|
+
const CLAUDE_MD_BODY = `## Plan review
|
|
153
|
+
|
|
154
|
+
When a plan is large or splits into multiple sections/areas, author it as a
|
|
155
|
+
navigable **tree of documents** using the \`plan-review-multidoc\` markers
|
|
156
|
+
(root overview + linked subpages) rather than one long markdown blob. Small,
|
|
157
|
+
single-topic plans stay as plain markdown. See the \`plan-review-multidoc\` skill.`;
|
|
158
|
+
|
|
159
|
+
function claudeMdBlock() {
|
|
160
|
+
return `${CLAUDE_MD_START}\n${CLAUDE_MD_BODY}\n${CLAUDE_MD_END}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Append (or refresh) the CLAUDE.md guidance block idempotently, fenced by
|
|
164
|
+
// <!-- plan-review:start/end --> so re-running only ever updates that region.
|
|
165
|
+
function writeClaudeMd(targetFile) {
|
|
166
|
+
mkdirSync(dirname(targetFile), { recursive: true });
|
|
167
|
+
const block = claudeMdBlock();
|
|
168
|
+
let existing = "";
|
|
169
|
+
try {
|
|
170
|
+
existing = readFileSync(targetFile, "utf8");
|
|
171
|
+
} catch {
|
|
172
|
+
existing = "";
|
|
173
|
+
}
|
|
174
|
+
const startIdx = existing.indexOf(CLAUDE_MD_START);
|
|
175
|
+
const endIdx = existing.indexOf(CLAUDE_MD_END);
|
|
176
|
+
let next;
|
|
177
|
+
if (startIdx >= 0 && endIdx > startIdx) {
|
|
178
|
+
next = existing.slice(0, startIdx) + block + existing.slice(endIdx + CLAUDE_MD_END.length);
|
|
179
|
+
} else {
|
|
180
|
+
next = existing.trim() ? `${existing.replace(/\s*$/, "")}\n\n${block}\n` : `${block}\n`;
|
|
181
|
+
}
|
|
182
|
+
writeFileSync(targetFile, next);
|
|
183
|
+
console.log(`β Wrote plan-review guidance block to ${targetFile}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
54
186
|
function readJSON(path, fallback) {
|
|
55
187
|
try {
|
|
56
188
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -59,6 +191,39 @@ function readJSON(path, fallback) {
|
|
|
59
191
|
}
|
|
60
192
|
}
|
|
61
193
|
|
|
194
|
+
// Warn if a plan-review ExitPlanMode hook is ALSO configured in another settings
|
|
195
|
+
// scope we know about (global + project settings.json + project settings.local.json).
|
|
196
|
+
// Two entries in different scopes both fire for one plan-mode exit β the review
|
|
197
|
+
// opens twice. `justWrote` is the scope init targeted this run (skip it).
|
|
198
|
+
function warnOtherScopes(justWrote, global, dirArg) {
|
|
199
|
+
const projectRoot = resolve(dirArg || process.cwd());
|
|
200
|
+
const scopes = [
|
|
201
|
+
join(homedir(), ".claude", "settings.json"),
|
|
202
|
+
join(projectRoot, ".claude", "settings.json"),
|
|
203
|
+
join(projectRoot, ".claude", "settings.local.json"),
|
|
204
|
+
];
|
|
205
|
+
const seen = new Set([resolve(justWrote)]);
|
|
206
|
+
for (const file of scopes) {
|
|
207
|
+
const abs = resolve(file);
|
|
208
|
+
if (seen.has(abs)) continue;
|
|
209
|
+
seen.add(abs);
|
|
210
|
+
if (!existsSync(abs)) continue;
|
|
211
|
+
const s = readJSON(abs, null);
|
|
212
|
+
const entries = s?.hooks?.PreToolUse || [];
|
|
213
|
+
const hasHook = entries.some(
|
|
214
|
+
(entry) =>
|
|
215
|
+
entry?.matcher === "ExitPlanMode" &&
|
|
216
|
+
(entry.hooks || []).some((h) => isPlanReviewHook(h?.command, "hook")),
|
|
217
|
+
);
|
|
218
|
+
if (hasHook) {
|
|
219
|
+
console.log(
|
|
220
|
+
`\nβ WARNING: plan review hook also configured in ${abs} β multiple entries open the review multiple times.\n` +
|
|
221
|
+
` Remove the ExitPlanMode plan-review hook from one of the scopes.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
62
227
|
function cmdInit(args) {
|
|
63
228
|
const global = args.includes("--global");
|
|
64
229
|
const local = args.includes("--local");
|
|
@@ -71,12 +236,13 @@ function cmdInit(args) {
|
|
|
71
236
|
const settings = readJSON(settingsFile, {});
|
|
72
237
|
settings.hooks ??= {};
|
|
73
238
|
settings.hooks.PreToolUse ??= [];
|
|
239
|
+
settings.hooks.Stop ??= [];
|
|
74
240
|
|
|
75
241
|
const command = hookCommand(args);
|
|
76
242
|
const already = settings.hooks.PreToolUse.some(
|
|
77
243
|
(entry) =>
|
|
78
244
|
entry?.matcher === "ExitPlanMode" &&
|
|
79
|
-
(entry.hooks || []).some((h) => h?.command
|
|
245
|
+
(entry.hooks || []).some((h) => isPlanReviewHook(h?.command, "hook")),
|
|
80
246
|
);
|
|
81
247
|
|
|
82
248
|
if (already) {
|
|
@@ -89,7 +255,80 @@ function cmdInit(args) {
|
|
|
89
255
|
writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + "\n");
|
|
90
256
|
console.log(`β Wrote ExitPlanMode plan-review hook to ${settingsFile}`);
|
|
91
257
|
}
|
|
92
|
-
|
|
258
|
+
|
|
259
|
+
// EnterPlanMode context-injection hook: nudges Claude toward the multi-doc
|
|
260
|
+
// tree format as it starts planning. Never makes a permission decision.
|
|
261
|
+
const planContextCmd = planContextCommand(args);
|
|
262
|
+
const pcAlready = settings.hooks.PreToolUse.some(
|
|
263
|
+
(entry) =>
|
|
264
|
+
entry?.matcher === "EnterPlanMode" &&
|
|
265
|
+
(entry.hooks || []).some((h) => isPlanReviewHook(h?.command, "plan-context")),
|
|
266
|
+
);
|
|
267
|
+
if (pcAlready) {
|
|
268
|
+
console.log(`β EnterPlanMode plan-context hook already present in ${settingsFile}`);
|
|
269
|
+
} else {
|
|
270
|
+
settings.hooks.PreToolUse.push({
|
|
271
|
+
matcher: "EnterPlanMode",
|
|
272
|
+
hooks: [{ type: "command", command: planContextCmd, timeout: 10 }],
|
|
273
|
+
});
|
|
274
|
+
writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + "\n");
|
|
275
|
+
console.log(`β Wrote EnterPlanMode plan-context hook to ${settingsFile}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Stop-hook gate: blocks the turn from ending while a tools-first (mcp/api)
|
|
279
|
+
// review is still pending. Matcher-less (Stop has no tool matcher).
|
|
280
|
+
const stopCommand = stopGateCommand(args);
|
|
281
|
+
const stopAlready = settings.hooks.Stop.some((entry) =>
|
|
282
|
+
(entry.hooks || []).some((h) => isPlanReviewHook(h?.command, "stop-gate")),
|
|
283
|
+
);
|
|
284
|
+
if (stopAlready) {
|
|
285
|
+
console.log(`β Stop plan-review gate already present in ${settingsFile}`);
|
|
286
|
+
} else {
|
|
287
|
+
settings.hooks.Stop.push({
|
|
288
|
+
hooks: [{ type: "command", command: stopCommand, timeout: 10 }],
|
|
289
|
+
});
|
|
290
|
+
writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + "\n");
|
|
291
|
+
console.log(`β Wrote Stop plan-review gate to ${settingsFile}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Cross-scope warning: multiple ExitPlanMode plan-review hooks (across global /
|
|
295
|
+
// project / project-local settings) each fire for one plan-mode exit and open
|
|
296
|
+
// the review that many times. Scan the OTHER known scopes and warn.
|
|
297
|
+
warnOtherScopes(settingsFile, global, dirArg);
|
|
298
|
+
|
|
299
|
+
console.log(
|
|
300
|
+
`\nRuntime: ${chooseRuntime(args)}\nHook: ${command}\nPlanCtx: ${planContextCmd}\nStop: ${stopCommand}`,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
// Skill: auto-triggers the multi-document plan authoring in every project.
|
|
304
|
+
if (args.includes("--no-skill")) {
|
|
305
|
+
console.log("\n(skipped skill install β --no-skill)");
|
|
306
|
+
} else {
|
|
307
|
+
try {
|
|
308
|
+
installSkill();
|
|
309
|
+
} catch (e) {
|
|
310
|
+
console.log(`β Could not install the skill: ${e?.message || e}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// MCP server: enables the plan_review_submit / plan_review_check tools.
|
|
315
|
+
console.log("");
|
|
316
|
+
registerMcp(args);
|
|
317
|
+
|
|
318
|
+
// CLAUDE.md guidance block: written on --write-claude-md, printed otherwise.
|
|
319
|
+
const claudeMdFile = global
|
|
320
|
+
? join(homedir(), ".claude", "CLAUDE.md")
|
|
321
|
+
: join(resolve(dirArg || process.cwd()), "CLAUDE.md");
|
|
322
|
+
if (args.includes("--write-claude-md")) {
|
|
323
|
+
console.log("");
|
|
324
|
+
writeClaudeMd(claudeMdFile);
|
|
325
|
+
} else {
|
|
326
|
+
console.log(
|
|
327
|
+
`\nAdd this to ${global ? "~/.claude/CLAUDE.md" : "your project's CLAUDE.md"} ` +
|
|
328
|
+
`(or re-run with --write-claude-md to append it automatically):\n\n${claudeMdBlock()}`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
93
332
|
console.log(
|
|
94
333
|
`\nReopen the /hooks menu once (or restart Claude Code) ${global ? "in any project" : "in this project"} so the new hook is picked up.\n` +
|
|
95
334
|
`Then finish a plan in plan mode β your browser will open the review.`,
|
|
@@ -139,21 +378,38 @@ function cmdStop() {
|
|
|
139
378
|
rmSync(SERVER_FILE, { force: true });
|
|
140
379
|
}
|
|
141
380
|
|
|
381
|
+
function cmdSkill() {
|
|
382
|
+
installSkill();
|
|
383
|
+
console.log(
|
|
384
|
+
"\nThe plan-review-multidoc skill auto-triggers when a plan is large or splits into sections.\n" +
|
|
385
|
+
"Restart Claude Code (or start a new session) to pick it up.",
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
142
389
|
function usage() {
|
|
143
390
|
console.log(`claude-plan-review β review Claude Code plans in your browser (runs on Bun or Node β₯18)
|
|
144
391
|
|
|
145
392
|
Usage:
|
|
146
393
|
claude-plan-review init [dir] [--global] [--local] [--published] [--runtime bun|node]
|
|
147
|
-
|
|
394
|
+
[--no-skill] [--write-claude-md]
|
|
395
|
+
Wire the ExitPlanMode hook + the EnterPlanMode context hook +
|
|
396
|
+
the Stop-hook gate into a project (or all projects), install the
|
|
397
|
+
plan-review-multidoc skill, and register the MCP server.
|
|
148
398
|
Runtime auto-detects (Bun if installed, else Node).
|
|
149
|
-
--global
|
|
150
|
-
--local
|
|
151
|
-
--published
|
|
152
|
-
--runtime
|
|
399
|
+
--global β ~/.claude/settings.json (applies to ALL projects)
|
|
400
|
+
--local β .claude/settings.local.json
|
|
401
|
+
--published β portable bunx/npx command (after npm publish)
|
|
402
|
+
--runtime β force a runtime
|
|
403
|
+
--no-skill β don't install the multi-doc plan skill
|
|
404
|
+
--write-claude-mdβ append the CLAUDE.md guidance block (else printed)
|
|
153
405
|
claude-plan-review serve [port] Start the review server (default ${DEFAULT_PORT})
|
|
154
406
|
claude-plan-review stop Stop the running server
|
|
155
407
|
claude-plan-review channels Show storage-channel readiness (e.g. gh / gist)
|
|
156
|
-
claude-plan-review
|
|
408
|
+
claude-plan-review skill (Re)install the plan-review-multidoc skill into ~/.claude/skills
|
|
409
|
+
claude-plan-review mcp (internal) run the stdio MCP server (plan_review_* tools)
|
|
410
|
+
claude-plan-review hook (internal) the PreToolUse hook entry (ExitPlanMode β review)
|
|
411
|
+
claude-plan-review plan-context (internal) the PreToolUse hook entry (EnterPlanMode β multi-doc guidance)
|
|
412
|
+
claude-plan-review stop-gate (internal) the Stop hook entry (blocks on a pending tools-first review)
|
|
157
413
|
`);
|
|
158
414
|
}
|
|
159
415
|
|
|
@@ -171,9 +427,23 @@ switch (sub) {
|
|
|
171
427
|
case "channels":
|
|
172
428
|
await cmdChannels();
|
|
173
429
|
break;
|
|
430
|
+
case "skill":
|
|
431
|
+
cmdSkill();
|
|
432
|
+
break;
|
|
433
|
+
case "mcp": {
|
|
434
|
+
const { startMcpServer } = await import("./mcp.js");
|
|
435
|
+
startMcpServer();
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
174
438
|
case "hook":
|
|
175
439
|
await import("./hook.js");
|
|
176
440
|
break;
|
|
441
|
+
case "stop-gate":
|
|
442
|
+
await import("./stop-gate.js");
|
|
443
|
+
break;
|
|
444
|
+
case "plan-context":
|
|
445
|
+
await import("./plan-context.js");
|
|
446
|
+
break;
|
|
177
447
|
default:
|
|
178
448
|
usage();
|
|
179
449
|
}
|