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 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 into a project |
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/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
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.1",
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 { 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,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 β†’ use the bare `claude-plan-review` command (resolved via PATH at hook time).
39
- // We intentionally do NOT hardcode an absolute path: version managers like fnm/nvm expose
40
- // bins via per-shell paths that don't persist across sessions.
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
- console.log(`\nRuntime: ${chooseRuntime(args)}\nCommand: ${command}`);
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
- Wire the ExitPlanMode hook into a project (or all projects).
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 β†’ ~/.claude/settings.json (applies to ALL projects)
149
- --local β†’ .claude/settings.local.json
150
- --published β†’ portable bunx/npx command (after npm publish)
151
- --runtime β†’ force a 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
- const { key, version, reviewId } = recordPlan({
131
- cwd: input.cwd || process.cwd(),
132
- plan,
133
- planFilePath: input.tool_input?.planFilePath,
134
- sessionId: input.session_id,
135
- toolUseId: input.tool_use_id,
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)