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/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,19 +128,46 @@ async function main() {
|
|
|
127
128
|
const plan = toolInput.plan ?? "";
|
|
128
129
|
if (!plan.trim()) process.exit(0);
|
|
129
130
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
131
|
+
// Parse the plan into a normalized tree (or single-doc). A returned {error}
|
|
132
|
+
// is an EXPLICIT marker-validation failure → deny so Claude stays in plan
|
|
133
|
+
// mode and re-authors. Any thrown exception is unexpected → fail open (exit
|
|
134
|
+
// 0 silently) so a parser bug never blocks the user.
|
|
135
|
+
let tree;
|
|
136
|
+
try {
|
|
137
|
+
tree = parsePlan(plan);
|
|
138
|
+
} catch {
|
|
139
|
+
process.exit(0);
|
|
140
|
+
}
|
|
141
|
+
if (tree && tree.error) {
|
|
142
|
+
deny(buildDenyReason(tree.error.issues));
|
|
143
|
+
process.exit(0);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let recorded;
|
|
147
|
+
try {
|
|
148
|
+
recorded = recordPlan({
|
|
149
|
+
cwd: input.cwd || process.cwd(),
|
|
150
|
+
sessionId: input.session_id,
|
|
151
|
+
toolUseId: input.tool_use_id,
|
|
152
|
+
planFilePath: input.tool_input?.planFilePath,
|
|
153
|
+
tree,
|
|
154
|
+
origin: "hook",
|
|
155
|
+
});
|
|
156
|
+
} catch {
|
|
157
|
+
process.exit(0); // store failure → don't interfere
|
|
158
|
+
}
|
|
159
|
+
const { key, version, reviewId, reused } = 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}`;
|
|
142
|
-
|
|
164
|
+
)}&version=${version}&review=${reviewId}&doc=root`;
|
|
165
|
+
// When a duplicate hook invocation (a second entry in another settings scope)
|
|
166
|
+
// fired for the SAME ExitPlanMode call, recordPlan reports `reused` — the first
|
|
167
|
+
// invocation already opened the browser, so we must NOT open it again. We still
|
|
168
|
+
// ensure the server is up and poll the shared review, so both processes return
|
|
169
|
+
// the same decision. (Fail-open invariant preserved.)
|
|
170
|
+
if (!reused) openBrowser(reviewUrl);
|
|
143
171
|
|
|
144
172
|
// block until the UI resolves the review (or we time out)
|
|
145
173
|
const deadline = Date.now() + TIMEOUT_MS;
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local MCP (Model Context Protocol) server for claude-plan-review.
|
|
3
|
+
*
|
|
4
|
+
* Exposes two tools Claude can call to get a plan reviewed WITHOUT being in plan
|
|
5
|
+
* mode (the tools-first creation path):
|
|
6
|
+
* • plan_review_submit — record a plan (single doc, raw markdown, or a
|
|
7
|
+
* multi-document tree), open the browser review UI, return the review id.
|
|
8
|
+
* • plan_review_check — poll a review for the reviewer's decision.
|
|
9
|
+
*
|
|
10
|
+
* Transport: stdio, newline-delimited JSON-RPC 2.0 (the MCP stdio framing —
|
|
11
|
+
* one JSON message per line, no embedded newlines). Hand-rolled, ZERO new deps.
|
|
12
|
+
* Implemented subset of MCP spec 2025-06-18: initialize handshake,
|
|
13
|
+
* notifications/initialized, tools/list, tools/call, ping.
|
|
14
|
+
*
|
|
15
|
+
* Records go through the SAME store.recordPlan as the PreToolUse hook, so the
|
|
16
|
+
* review UI, dedup, and the hook's own polling all see identical shapes.
|
|
17
|
+
*/
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { DEFAULT_PORT, SERVER_FILE } from "./paths.js";
|
|
23
|
+
import { buildDenyReason, parsePlan, validateDocs } from "./plan-parse.js";
|
|
24
|
+
import { getReview, recordPlan } from "./store.js";
|
|
25
|
+
|
|
26
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const SERVER_SCRIPT = join(HERE, "server.js");
|
|
28
|
+
|
|
29
|
+
// Latest MCP protocol revision we implement. We echo the client's requested
|
|
30
|
+
// version back when it sends one (the compatible behaviour for a version-
|
|
31
|
+
// agnostic tools server), else advertise this.
|
|
32
|
+
const PROTOCOL_VERSION = "2025-06-18";
|
|
33
|
+
const SERVER_INFO = { name: "claude-plan-review", version: "0.4.0" };
|
|
34
|
+
|
|
35
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
36
|
+
|
|
37
|
+
// Long-poll tuning for plan_review_check. RECOMMENDED_WAIT is the value we steer
|
|
38
|
+
// Claude to pass — short enough to stay well under Claude Code's MCP limits for a
|
|
39
|
+
// stdio server (no per-request timer; ~30-min idle timeout; a main-conversation
|
|
40
|
+
// call auto-backgrounds after ~2 min on v2.1.212+), long enough to usually catch
|
|
41
|
+
// a fast decision in one call. MAX_WAIT caps a single call; the loop re-issues.
|
|
42
|
+
const RECOMMENDED_WAIT_S = 20;
|
|
43
|
+
const MAX_WAIT_S = 120;
|
|
44
|
+
const CHECK_POLL_MS = 700;
|
|
45
|
+
|
|
46
|
+
// ---------- server lifecycle (reimplemented locally; mirrors hook.js) ----------
|
|
47
|
+
function serverPort() {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(readFileSync(SERVER_FILE, "utf8")).port || DEFAULT_PORT;
|
|
50
|
+
} catch {
|
|
51
|
+
return DEFAULT_PORT;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function isUp(port) {
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch(`http://localhost:${port}/api/health`, {
|
|
58
|
+
signal: AbortSignal.timeout(500),
|
|
59
|
+
});
|
|
60
|
+
return res.ok;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function ensureServer() {
|
|
67
|
+
const port = serverPort();
|
|
68
|
+
if (await isUp(port)) return port;
|
|
69
|
+
// spawn detached with the SAME runtime running this process (node or bun)
|
|
70
|
+
const child = spawn(process.execPath, [SERVER_SCRIPT, String(port)], {
|
|
71
|
+
detached: true,
|
|
72
|
+
stdio: "ignore",
|
|
73
|
+
});
|
|
74
|
+
child.unref();
|
|
75
|
+
for (let i = 0; i < 40; i++) {
|
|
76
|
+
if (await isUp(port)) return port;
|
|
77
|
+
await sleep(150);
|
|
78
|
+
}
|
|
79
|
+
return port; // best effort
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function openBrowser(targetUrl) {
|
|
83
|
+
const cmd =
|
|
84
|
+
process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
85
|
+
const args = process.platform === "win32" ? ["/c", "start", "", targetUrl] : [targetUrl];
|
|
86
|
+
try {
|
|
87
|
+
spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
|
|
88
|
+
} catch {
|
|
89
|
+
/* non-fatal */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------- tool definitions ----------
|
|
94
|
+
const TOOLS = [
|
|
95
|
+
{
|
|
96
|
+
name: "plan_review_submit",
|
|
97
|
+
title: "Submit a plan for human review",
|
|
98
|
+
description:
|
|
99
|
+
"Submit an implementation plan for the user to review, comment on, and approve or reject " +
|
|
100
|
+
"in a local browser UI — WITHOUT being in plan mode. Use this whenever the user should sign " +
|
|
101
|
+
"off on a plan before you implement it, especially for a large or multi-part plan that reads " +
|
|
102
|
+
"best as a navigable tree of documents.\n\n" +
|
|
103
|
+
"Provide EXACTLY ONE of these content fields:\n" +
|
|
104
|
+
" • docs — a pre-structured document tree. Each entry: {slug, title, parent?, body}. " +
|
|
105
|
+
"`slug` is a kebab-case id (^[a-z0-9][a-z0-9-]*$), unique; `title` is a human heading; " +
|
|
106
|
+
"`parent` is another doc's slug (omit it for the single top-level doc — conventionally " +
|
|
107
|
+
"slugged \"root\" — and set children's parent to that slug). Cross-doc links: [[slug]], " +
|
|
108
|
+
"[[slug|text]] or [text](doc:slug).\n" +
|
|
109
|
+
" • markdown — a single markdown document (no tree).\n" +
|
|
110
|
+
" • plan — a raw plan string that may embed document-separator markers " +
|
|
111
|
+
"(<!--doc slug=... title=\"...\" parent=...--> lines); the server splits it into a tree.\n\n" +
|
|
112
|
+
"On success returns { reviewId, reviewUrl, projectKey, version }. The call does NOT block on " +
|
|
113
|
+
"the decision — after submitting, call plan_review_check with the returned reviewId to see " +
|
|
114
|
+
"whether the user approved or requested changes. If the plan is structurally invalid the call " +
|
|
115
|
+
"returns an error listing every problem to fix before resubmitting.",
|
|
116
|
+
inputSchema: {
|
|
117
|
+
type: "object",
|
|
118
|
+
properties: {
|
|
119
|
+
cwd: {
|
|
120
|
+
type: "string",
|
|
121
|
+
description:
|
|
122
|
+
"Absolute path of the project/worktree this plan is for (usually your working directory). Required.",
|
|
123
|
+
},
|
|
124
|
+
docs: {
|
|
125
|
+
type: "array",
|
|
126
|
+
description:
|
|
127
|
+
"A pre-structured document tree. Provide this OR markdown OR plan (exactly one).",
|
|
128
|
+
items: {
|
|
129
|
+
type: "object",
|
|
130
|
+
properties: {
|
|
131
|
+
slug: { type: "string", description: "Kebab-case id, unique across docs." },
|
|
132
|
+
title: { type: "string", description: "Human-readable document title." },
|
|
133
|
+
parent: {
|
|
134
|
+
type: "string",
|
|
135
|
+
description:
|
|
136
|
+
"Slug of the parent doc. Omit for the single top-level doc (conventionally slugged \"root\"); children reference their parent's slug.",
|
|
137
|
+
},
|
|
138
|
+
body: { type: "string", description: "Markdown body of this document." },
|
|
139
|
+
},
|
|
140
|
+
required: ["slug", "title", "body"],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
markdown: {
|
|
144
|
+
type: "string",
|
|
145
|
+
description:
|
|
146
|
+
"A single markdown document. Provide this OR docs OR plan (exactly one).",
|
|
147
|
+
},
|
|
148
|
+
plan: {
|
|
149
|
+
type: "string",
|
|
150
|
+
description:
|
|
151
|
+
"A raw plan string, optionally with <!--doc ...--> separator markers. Provide this OR docs OR markdown (exactly one).",
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
required: ["cwd"],
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: "plan_review_check",
|
|
159
|
+
title: "Check a plan review's decision",
|
|
160
|
+
description:
|
|
161
|
+
"Check whether the user has decided on a plan submitted with plan_review_submit. " +
|
|
162
|
+
"Returns { status: \"pending\" | \"approved\" | \"rejected\", notes?, reason? }.\n\n" +
|
|
163
|
+
"This tool LONG-POLLS: pass `wait` (seconds) and the call blocks server-side until the user " +
|
|
164
|
+
`decides or \`wait\` elapses, then returns. Use \`wait: ${RECOMMENDED_WAIT_S}\` and call it in a ` +
|
|
165
|
+
"loop while the status is \"pending\" — do NOT end your turn while a review is pending. " +
|
|
166
|
+
"Give up only after ~5 hours total and ask the user how to proceed.\n" +
|
|
167
|
+
" • pending — the user has not decided yet. Call this tool again immediately with " +
|
|
168
|
+
`wait: ${RECOMMENDED_WAIT_S}.\n` +
|
|
169
|
+
" • approved — proceed with implementation. `notes` (if present) are the reviewer's comments " +
|
|
170
|
+
"to incorporate as you go (\"approve with comments\").\n" +
|
|
171
|
+
" • rejected — do NOT implement. `reason` explains the requested changes; revise the plan and " +
|
|
172
|
+
"resubmit with plan_review_submit.",
|
|
173
|
+
inputSchema: {
|
|
174
|
+
type: "object",
|
|
175
|
+
properties: {
|
|
176
|
+
reviewId: {
|
|
177
|
+
type: "string",
|
|
178
|
+
description: "The reviewId returned by plan_review_submit.",
|
|
179
|
+
},
|
|
180
|
+
wait: {
|
|
181
|
+
type: "number",
|
|
182
|
+
description:
|
|
183
|
+
`Seconds to block waiting for a decision before returning (long-poll). Default 0 ` +
|
|
184
|
+
`(return immediately); capped at ${MAX_WAIT_S}. Recommended: ${RECOMMENDED_WAIT_S}. ` +
|
|
185
|
+
`If still pending when it returns, call again with the same wait.`,
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
required: ["reviewId"],
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
// ---------- tool implementations ----------
|
|
194
|
+
/**
|
|
195
|
+
* Normalize a caller-supplied docs array into the store's tree shape. Mirrors
|
|
196
|
+
* plan-parse's normParent EXACTLY (absent/empty/self ⇒ top-level; "root" is a
|
|
197
|
+
* normal slug reference, so a child with parent:"root" nests under the doc
|
|
198
|
+
* slugged "root") so the stored tree is byte-identical to what the parser
|
|
199
|
+
* produces for the same content.
|
|
200
|
+
*/
|
|
201
|
+
function normParent(parent, slug) {
|
|
202
|
+
if (parent == null) return null;
|
|
203
|
+
const v = String(parent).trim();
|
|
204
|
+
if (v === "" || v === slug) return null;
|
|
205
|
+
return v;
|
|
206
|
+
}
|
|
207
|
+
function docsToTree(docs) {
|
|
208
|
+
const normalized = docs.map((d, i) => ({
|
|
209
|
+
slug: d.slug,
|
|
210
|
+
title: d.title,
|
|
211
|
+
parent: normParent(d.parent, d.slug),
|
|
212
|
+
body: d.body ?? "",
|
|
213
|
+
order: i,
|
|
214
|
+
}));
|
|
215
|
+
const root = normalized.find((d) => d.parent === null)?.slug ?? "root";
|
|
216
|
+
return { kind: "tree", root, docs: normalized };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function runSubmit(args) {
|
|
220
|
+
const cwd = args?.cwd;
|
|
221
|
+
if (typeof cwd !== "string" || !cwd.trim()) {
|
|
222
|
+
return toolError("plan_review_submit requires `cwd` (the absolute project path).");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Exactly one content field.
|
|
226
|
+
const provided = ["docs", "markdown", "plan"].filter(
|
|
227
|
+
(k) => args[k] !== undefined && args[k] !== null,
|
|
228
|
+
);
|
|
229
|
+
if (provided.length === 0) {
|
|
230
|
+
return toolError("Provide exactly one of `docs`, `markdown`, or `plan`.");
|
|
231
|
+
}
|
|
232
|
+
if (provided.length > 1) {
|
|
233
|
+
return toolError(
|
|
234
|
+
`Provide exactly one content field — got ${provided.join(", ")}. Use only one of docs / markdown / plan.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Build a normalized tree, collecting all validation issues.
|
|
239
|
+
let tree;
|
|
240
|
+
if (provided[0] === "docs") {
|
|
241
|
+
if (!Array.isArray(args.docs) || args.docs.length === 0) {
|
|
242
|
+
return toolError("`docs` must be a non-empty array of {slug, title, parent?, body}.");
|
|
243
|
+
}
|
|
244
|
+
const issues = validateDocs(args.docs);
|
|
245
|
+
if (issues && issues.length) return toolError(buildDenyReason(issues));
|
|
246
|
+
tree = docsToTree(args.docs);
|
|
247
|
+
} else {
|
|
248
|
+
const str = args[provided[0]];
|
|
249
|
+
if (typeof str !== "string" || !str.trim()) {
|
|
250
|
+
return toolError(`\`${provided[0]}\` must be a non-empty string.`);
|
|
251
|
+
}
|
|
252
|
+
let parsed;
|
|
253
|
+
try {
|
|
254
|
+
parsed = parsePlan(str);
|
|
255
|
+
} catch (e) {
|
|
256
|
+
return toolError(`Could not parse the plan: ${e?.message || e}`);
|
|
257
|
+
}
|
|
258
|
+
if (parsed && parsed.error) return toolError(buildDenyReason(parsed.error.issues));
|
|
259
|
+
tree = parsed;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Record through the SAME store path the hook uses.
|
|
263
|
+
let recorded;
|
|
264
|
+
try {
|
|
265
|
+
recorded = recordPlan({ cwd, tree, origin: "mcp" });
|
|
266
|
+
} catch (e) {
|
|
267
|
+
return toolError(`Failed to record the plan: ${e?.message || e}`);
|
|
268
|
+
}
|
|
269
|
+
const { key, version, reviewId } = recorded;
|
|
270
|
+
|
|
271
|
+
return { pending: { key, version, reviewId, tree } };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function finishSubmit(pending) {
|
|
275
|
+
const { key, version, reviewId, tree } = pending;
|
|
276
|
+
const rootDoc = (tree && tree.root) || "root";
|
|
277
|
+
const port = await ensureServer();
|
|
278
|
+
const reviewUrl = `http://localhost:${port}/?project=${encodeURIComponent(
|
|
279
|
+
key,
|
|
280
|
+
)}&version=${version}&review=${reviewId}&doc=${encodeURIComponent(rootDoc)}`;
|
|
281
|
+
openBrowser(reviewUrl);
|
|
282
|
+
|
|
283
|
+
const result = { reviewId, reviewUrl, projectKey: key, version };
|
|
284
|
+
return toolText(
|
|
285
|
+
`Plan submitted for review (version ${version}). The review UI is opening in the browser.\n\n` +
|
|
286
|
+
`Next: call plan_review_check with {reviewId: "${reviewId}", wait: ${RECOMMENDED_WAIT_S}} NOW ` +
|
|
287
|
+
`and keep calling it in a loop while status is "pending". Do not end your turn while the ` +
|
|
288
|
+
`review is pending. Give up and ask the user only after ~5 hours total.\n\n` +
|
|
289
|
+
JSON.stringify(result, null, 2),
|
|
290
|
+
result,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function runCheck(args) {
|
|
295
|
+
const reviewId = args?.reviewId;
|
|
296
|
+
if (typeof reviewId !== "string" || !reviewId.trim()) {
|
|
297
|
+
return toolError("plan_review_check requires `reviewId` (from plan_review_submit).");
|
|
298
|
+
}
|
|
299
|
+
let review = getReview(reviewId);
|
|
300
|
+
if (!review) {
|
|
301
|
+
return toolError(`No review found for reviewId "${reviewId}".`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Long-poll: while `wait` seconds remain and the review is still pending, poll
|
|
305
|
+
// the store. Each `await sleep` yields the event loop, so the stdin read loop
|
|
306
|
+
// keeps dispatching (and answering) OTHER incoming requests meanwhile.
|
|
307
|
+
let wait = Number(args?.wait);
|
|
308
|
+
if (!Number.isFinite(wait) || wait < 0) wait = 0;
|
|
309
|
+
wait = Math.min(wait, MAX_WAIT_S);
|
|
310
|
+
if (wait > 0 && review.status === "pending") {
|
|
311
|
+
const deadline = Date.now() + wait * 1000;
|
|
312
|
+
while (Date.now() < deadline && review.status === "pending") {
|
|
313
|
+
await sleep(CHECK_POLL_MS);
|
|
314
|
+
review = getReview(reviewId) || review;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const result = { status: review.status };
|
|
319
|
+
if (review.notes) result.notes = review.notes;
|
|
320
|
+
if (review.reason) result.reason = review.reason;
|
|
321
|
+
|
|
322
|
+
let hint;
|
|
323
|
+
if (review.status === "approved") {
|
|
324
|
+
hint = result.notes
|
|
325
|
+
? "APPROVED with comments — proceed with implementation, incorporating each note."
|
|
326
|
+
: "APPROVED — proceed with implementation.";
|
|
327
|
+
} else if (review.status === "rejected") {
|
|
328
|
+
hint = "CHANGES REQUESTED — do not implement; revise per `reason` and resubmit.";
|
|
329
|
+
} else {
|
|
330
|
+
hint =
|
|
331
|
+
`Still pending — call plan_review_check again now with wait:${RECOMMENDED_WAIT_S}. ` +
|
|
332
|
+
"Do not end your turn. Give up after ~5 hours total and ask the user.";
|
|
333
|
+
}
|
|
334
|
+
return toolText(`${hint}\n\n${JSON.stringify(result, null, 2)}`, result);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------- tool-result helpers ----------
|
|
338
|
+
function toolText(text, structured) {
|
|
339
|
+
const res = { content: [{ type: "text", text }] };
|
|
340
|
+
if (structured !== undefined) res.structuredContent = structured;
|
|
341
|
+
return res;
|
|
342
|
+
}
|
|
343
|
+
function toolError(text) {
|
|
344
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---------- JSON-RPC plumbing ----------
|
|
348
|
+
function send(msg) {
|
|
349
|
+
process.stdout.write(JSON.stringify(msg) + "\n");
|
|
350
|
+
}
|
|
351
|
+
function reply(id, result) {
|
|
352
|
+
send({ jsonrpc: "2.0", id, result });
|
|
353
|
+
}
|
|
354
|
+
function replyError(id, code, message, data) {
|
|
355
|
+
const error = { code, message };
|
|
356
|
+
if (data !== undefined) error.data = data;
|
|
357
|
+
send({ jsonrpc: "2.0", id, error });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function handleMessage(msg) {
|
|
361
|
+
if (!msg || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") {
|
|
362
|
+
// Malformed request with an id → invalid-request error; otherwise ignore.
|
|
363
|
+
if (msg && msg.id !== undefined && msg.id !== null) {
|
|
364
|
+
replyError(msg.id, -32600, "Invalid Request");
|
|
365
|
+
}
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const { id, method, params } = msg;
|
|
369
|
+
const isNotification = id === undefined || id === null;
|
|
370
|
+
|
|
371
|
+
switch (method) {
|
|
372
|
+
case "initialize": {
|
|
373
|
+
const requested = params?.protocolVersion;
|
|
374
|
+
const protocolVersion = typeof requested === "string" ? requested : PROTOCOL_VERSION;
|
|
375
|
+
reply(id, {
|
|
376
|
+
protocolVersion,
|
|
377
|
+
capabilities: { tools: {} },
|
|
378
|
+
serverInfo: SERVER_INFO,
|
|
379
|
+
instructions:
|
|
380
|
+
"Tools to get a plan reviewed by the user in a local browser UI. Call plan_review_submit " +
|
|
381
|
+
"to open a review, then plan_review_check to poll for the decision.",
|
|
382
|
+
});
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
case "notifications/initialized":
|
|
386
|
+
case "notifications/cancelled":
|
|
387
|
+
return; // notifications: no response
|
|
388
|
+
case "ping":
|
|
389
|
+
if (!isNotification) reply(id, {});
|
|
390
|
+
return;
|
|
391
|
+
case "tools/list":
|
|
392
|
+
reply(id, { tools: TOOLS });
|
|
393
|
+
return;
|
|
394
|
+
case "tools/call": {
|
|
395
|
+
const name = params?.name;
|
|
396
|
+
const args = params?.arguments ?? {};
|
|
397
|
+
try {
|
|
398
|
+
if (name === "plan_review_submit") {
|
|
399
|
+
const r = runSubmit(args);
|
|
400
|
+
// runSubmit returns either a tool-result (error) or {pending}
|
|
401
|
+
reply(id, r.pending ? await finishSubmit(r.pending) : r);
|
|
402
|
+
} else if (name === "plan_review_check") {
|
|
403
|
+
reply(id, await runCheck(args));
|
|
404
|
+
} else {
|
|
405
|
+
replyError(id, -32602, `Unknown tool: ${name}`);
|
|
406
|
+
}
|
|
407
|
+
} catch (e) {
|
|
408
|
+
// Never crash the server on a tool bug — report as a tool error.
|
|
409
|
+
reply(id, toolError(`Tool "${name}" failed: ${e?.message || e}`));
|
|
410
|
+
}
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
default:
|
|
414
|
+
if (!isNotification) replyError(id, -32601, `Method not found: ${method}`);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---------- stdin loop (newline-delimited JSON) ----------
|
|
420
|
+
export function startMcpServer() {
|
|
421
|
+
let buffer = "";
|
|
422
|
+
process.stdin.setEncoding("utf8");
|
|
423
|
+
process.stdin.on("data", (chunk) => {
|
|
424
|
+
buffer += chunk;
|
|
425
|
+
let nl;
|
|
426
|
+
while ((nl = buffer.indexOf("\n")) >= 0) {
|
|
427
|
+
const line = buffer.slice(0, nl).trim();
|
|
428
|
+
buffer = buffer.slice(nl + 1);
|
|
429
|
+
if (!line) continue;
|
|
430
|
+
let msg;
|
|
431
|
+
try {
|
|
432
|
+
msg = JSON.parse(line);
|
|
433
|
+
} catch {
|
|
434
|
+
// Unparseable line → JSON-RPC parse error (no id available).
|
|
435
|
+
send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
// Process sequentially; handleMessage is async but ordering of replies is
|
|
439
|
+
// best-effort and each carries its own id, so we don't await here.
|
|
440
|
+
Promise.resolve(handleMessage(msg)).catch(() => {});
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
process.stdin.on("end", () => process.exit(0));
|
|
444
|
+
}
|
package/src/paths.js
CHANGED
|
@@ -23,3 +23,20 @@ export function projectKey(cwd) {
|
|
|
23
23
|
export function projectDir(key) {
|
|
24
24
|
return join(PROJECTS_DIR, key);
|
|
25
25
|
}
|
|
26
|
+
|
|
27
|
+
const pad = (n) => String(n).padStart(4, "0");
|
|
28
|
+
|
|
29
|
+
/** Directory holding a tree version's manifest + per-doc markdown (tree kind only). */
|
|
30
|
+
export function versionDir(key, n) {
|
|
31
|
+
return join(projectDir(key), "versions", pad(n));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** manifest.json path inside a tree version's directory. */
|
|
35
|
+
export function manifestPath(key, n) {
|
|
36
|
+
return join(versionDir(key, n), "manifest.json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Path to one document file (e.g. "api.md") inside a tree version's directory. */
|
|
40
|
+
export function docPath(key, n, file) {
|
|
41
|
+
return join(versionDir(key, n), file);
|
|
42
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code PreToolUse hook for the EnterPlanMode tool. Runs on Bun or Node ≥18.
|
|
3
|
+
*
|
|
4
|
+
* Injects the multi-document authoring guidance as `additionalContext` so Claude
|
|
5
|
+
* reaches for the doc-tree format when a plan is large — WITHOUT making a
|
|
6
|
+
* permission decision.
|
|
7
|
+
*
|
|
8
|
+
* CRITICAL: EnterPlanMode is permission-gated and the user's consent to enter
|
|
9
|
+
* plan mode MUST survive this hook. Per the Claude Code hooks doc
|
|
10
|
+
* (https://code.claude.com/docs/en/hooks), a PreToolUse hook may return
|
|
11
|
+
* `additionalContext` inside `hookSpecificOutput` on its own; the valid
|
|
12
|
+
* `permissionDecision` values are "allow" / "deny" / "ask", and OMITTING the
|
|
13
|
+
* field leaves the normal permission flow untouched. We therefore emit NO
|
|
14
|
+
* permissionDecision — only additionalContext — so nothing auto-approves or
|
|
15
|
+
* denies entering plan mode.
|
|
16
|
+
*
|
|
17
|
+
* Fail open (exit 0 silently) on any error, and on any non-EnterPlanMode tool.
|
|
18
|
+
*
|
|
19
|
+
* stdin = { tool_name:"EnterPlanMode", tool_input:{…}, cwd, session_id, ... }
|
|
20
|
+
* stdout = { hookSpecificOutput:{ hookEventName:"PreToolUse", additionalContext } }
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
23
|
+
|
|
24
|
+
const CONTEXT = [
|
|
25
|
+
"Plan-review tip: when the plan you're about to write is large, multi-part, or",
|
|
26
|
+
"splits into several sections/areas, author it as a navigable TREE OF DOCUMENTS",
|
|
27
|
+
"instead of one long markdown blob. Put one marker per document, each alone on",
|
|
28
|
+
'its own line: <!--doc slug=<kebab-case> title="…" parent=<parent-slug>-->.',
|
|
29
|
+
"Exactly one root doc (omit `parent`); every child sets `parent` to its parent's",
|
|
30
|
+
"slug; cross-link docs with [[slug]]. A small, single-topic plan needs none of",
|
|
31
|
+
"this — write it as plain markdown with no markers. See the plan-review-multidoc",
|
|
32
|
+
"skill for the full syntax and rules.",
|
|
33
|
+
].join(" ");
|
|
34
|
+
|
|
35
|
+
function main() {
|
|
36
|
+
let input = {};
|
|
37
|
+
try {
|
|
38
|
+
input = JSON.parse(readFileSync(0, "utf8")); // fd 0 = stdin (node + bun)
|
|
39
|
+
} catch {
|
|
40
|
+
process.exit(0); // unparseable stdin → don't interfere
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Only act on EnterPlanMode; anything else passes through untouched.
|
|
44
|
+
if (input.tool_name !== "EnterPlanMode") process.exit(0);
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
process.stdout.write(
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
hookSpecificOutput: {
|
|
50
|
+
hookEventName: "PreToolUse",
|
|
51
|
+
// NO permissionDecision → the user's consent prompt for plan mode
|
|
52
|
+
// stays intact; we only add context.
|
|
53
|
+
additionalContext: CONTEXT,
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
} catch {
|
|
58
|
+
/* non-fatal — fail open */
|
|
59
|
+
}
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
main();
|