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 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 into a project |
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/0001.md 0001.json 0002.md 0002.json # immutable snapshots
150
- comments/0001.json 0002.json # comments per version
151
- reviews/<id>.json # pending/resolved review requests
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.2",
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 { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
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?.includes("hook.js") || h?.command === 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
- console.log(`\nRuntime: ${chooseRuntime(args)}\nCommand: ${command}`);
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
- Wire the ExitPlanMode hook into a project (or all projects).
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 β†’ ~/.claude/settings.json (applies to ALL projects)
150
- --local β†’ .claude/settings.local.json
151
- --published β†’ portable bunx/npx command (after npm publish)
152
- --runtime β†’ force a 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 hook (internal) the PreToolUse hook entry
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
  }