claude-plan-review 0.4.0 → 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 +12 -6
- package/package.json +1 -1
- package/skill/SKILL.md +4 -0
- package/src/cli.js +94 -9
- package/src/hook.js +7 -2
- package/src/plan-context.js +63 -0
- package/src/store.js +63 -1
- package/src/ui/app.js +24 -34
- package/src/ui/index.html +0 -1
package/README.md
CHANGED
|
@@ -95,9 +95,12 @@ claude-plan-review init --global --published --runtime node # pin --runtime to
|
|
|
95
95
|
# swap `--runtime node` for `--runtime bun` if the team standardises on Bun
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
That single `init --global --published` does
|
|
98
|
+
That single `init --global --published` does these things:
|
|
99
99
|
|
|
100
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);
|
|
101
104
|
- writes the **Stop-hook gate** to the same settings — it keeps Claude polling a tools-first
|
|
102
105
|
review instead of ending its turn while your decision is still pending (see below);
|
|
103
106
|
- installs the **`plan-review-multidoc` skill** to `~/.claude/skills/` (auto-triggers on large / sectioned plans);
|
|
@@ -126,7 +129,7 @@ Use `bun` or `node` interchangeably (or `bunx`/`npx claude-plan-review …` when
|
|
|
126
129
|
|
|
127
130
|
| Command | What it does |
|
|
128
131
|
| --- | --- |
|
|
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 |
|
|
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 |
|
|
130
133
|
| `… cli.js serve [port]` | Start the review server manually (default `4607`) |
|
|
131
134
|
| `… cli.js stop` | Stop the running server |
|
|
132
135
|
| `… cli.js channels` | Show storage-channel readiness (is `gh` installed / authed / `gist`-scoped?) |
|
|
@@ -246,13 +249,16 @@ the doc-tree format automatically whenever a plan is large or splits into sectio
|
|
|
246
249
|
|
|
247
250
|
Plans no longer accumulate forever. From the UI you can:
|
|
248
251
|
|
|
249
|
-
- **Delete a single version** — a per-version button
|
|
252
|
+
- **Delete a single version** — a per-version button. Deletes happen **immediately**
|
|
253
|
+
on click (no confirmation dialog).
|
|
250
254
|
- **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.
|
|
255
|
+
header): tick versions and delete them, or remove the project entirely — also immediate.
|
|
252
256
|
|
|
253
257
|
Deleting the **last** version removes the project directory outright. A version with a
|
|
254
|
-
**pending review** is protected: the delete
|
|
255
|
-
|
|
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.
|
|
256
262
|
|
|
257
263
|
## Storage layout
|
|
258
264
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-plan-review",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Review, comment on, and approve/reject Claude Code plans in a local GitHub-style web UI — with persistent comments, version history, and diffs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/skill/SKILL.md
CHANGED
|
@@ -41,6 +41,10 @@ Rules:
|
|
|
41
41
|
- Marker lines inside fenced code blocks (```` ``` ````/`~~~`) are ignored — you can
|
|
42
42
|
show marker examples in code fences safely.
|
|
43
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
|
+
|
|
44
48
|
## Path A — in plan mode (preferred when you're already planning)
|
|
45
49
|
|
|
46
50
|
Put the **whole tree in the single ExitPlanMode plan string**, using the markers
|
package/src/cli.js
CHANGED
|
@@ -16,9 +16,21 @@ import { DEFAULT_PORT, SERVER_FILE } from "./paths.js";
|
|
|
16
16
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
17
|
const HOOK_SCRIPT = join(HERE, "hook.js");
|
|
18
18
|
const STOP_GATE_SCRIPT = join(HERE, "stop-gate.js");
|
|
19
|
+
const PLAN_CONTEXT_SCRIPT = join(HERE, "plan-context.js");
|
|
19
20
|
const CLI_SCRIPT = fileURLToPath(import.meta.url);
|
|
20
21
|
const SKILL_SRC = join(HERE, "..", "skill", "SKILL.md");
|
|
21
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
|
+
}
|
|
33
|
+
|
|
22
34
|
function canRun(bin) {
|
|
23
35
|
try {
|
|
24
36
|
return spawnSync(bin, ["--version"], { stdio: "ignore" }).status === 0;
|
|
@@ -75,6 +87,20 @@ function stopGateCommand(args) {
|
|
|
75
87
|
return `${resolveBin(runtime)} ${STOP_GATE_SCRIPT}`;
|
|
76
88
|
}
|
|
77
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
|
+
|
|
78
104
|
// The program + args that launch the MCP server, mirroring hookCommand's
|
|
79
105
|
// runtime routing: global/published installs go through the package runner
|
|
80
106
|
// (npx/bunx) so they resolve in Claude Code's non-interactive env; a local
|
|
@@ -165,6 +191,39 @@ function readJSON(path, fallback) {
|
|
|
165
191
|
}
|
|
166
192
|
}
|
|
167
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
|
+
|
|
168
227
|
function cmdInit(args) {
|
|
169
228
|
const global = args.includes("--global");
|
|
170
229
|
const local = args.includes("--local");
|
|
@@ -183,7 +242,7 @@ function cmdInit(args) {
|
|
|
183
242
|
const already = settings.hooks.PreToolUse.some(
|
|
184
243
|
(entry) =>
|
|
185
244
|
entry?.matcher === "ExitPlanMode" &&
|
|
186
|
-
(entry.hooks || []).some((h) => h?.command
|
|
245
|
+
(entry.hooks || []).some((h) => isPlanReviewHook(h?.command, "hook")),
|
|
187
246
|
);
|
|
188
247
|
|
|
189
248
|
if (already) {
|
|
@@ -197,13 +256,30 @@ function cmdInit(args) {
|
|
|
197
256
|
console.log(`✓ Wrote ExitPlanMode plan-review hook to ${settingsFile}`);
|
|
198
257
|
}
|
|
199
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
|
+
|
|
200
278
|
// Stop-hook gate: blocks the turn from ending while a tools-first (mcp/api)
|
|
201
279
|
// review is still pending. Matcher-less (Stop has no tool matcher).
|
|
202
280
|
const stopCommand = stopGateCommand(args);
|
|
203
281
|
const stopAlready = settings.hooks.Stop.some((entry) =>
|
|
204
|
-
(entry.hooks || []).some(
|
|
205
|
-
(h) => h?.command?.includes("stop-gate") || h?.command === stopCommand,
|
|
206
|
-
),
|
|
282
|
+
(entry.hooks || []).some((h) => isPlanReviewHook(h?.command, "stop-gate")),
|
|
207
283
|
);
|
|
208
284
|
if (stopAlready) {
|
|
209
285
|
console.log(`✓ Stop plan-review gate already present in ${settingsFile}`);
|
|
@@ -215,8 +291,13 @@ function cmdInit(args) {
|
|
|
215
291
|
console.log(`✓ Wrote Stop plan-review gate to ${settingsFile}`);
|
|
216
292
|
}
|
|
217
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
|
+
|
|
218
299
|
console.log(
|
|
219
|
-
`\nRuntime: ${chooseRuntime(args)}\nHook: ${command}\nStop: ${stopCommand}`,
|
|
300
|
+
`\nRuntime: ${chooseRuntime(args)}\nHook: ${command}\nPlanCtx: ${planContextCmd}\nStop: ${stopCommand}`,
|
|
220
301
|
);
|
|
221
302
|
|
|
222
303
|
// Skill: auto-triggers the multi-document plan authoring in every project.
|
|
@@ -311,9 +392,9 @@ function usage() {
|
|
|
311
392
|
Usage:
|
|
312
393
|
claude-plan-review init [dir] [--global] [--local] [--published] [--runtime bun|node]
|
|
313
394
|
[--no-skill] [--write-claude-md]
|
|
314
|
-
Wire the ExitPlanMode hook + the
|
|
315
|
-
(or all projects), install the
|
|
316
|
-
register the MCP server.
|
|
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.
|
|
317
398
|
Runtime auto-detects (Bun if installed, else Node).
|
|
318
399
|
--global → ~/.claude/settings.json (applies to ALL projects)
|
|
319
400
|
--local → .claude/settings.local.json
|
|
@@ -326,7 +407,8 @@ Usage:
|
|
|
326
407
|
claude-plan-review channels Show storage-channel readiness (e.g. gh / gist)
|
|
327
408
|
claude-plan-review skill (Re)install the plan-review-multidoc skill into ~/.claude/skills
|
|
328
409
|
claude-plan-review mcp (internal) run the stdio MCP server (plan_review_* tools)
|
|
329
|
-
claude-plan-review hook (internal) the PreToolUse hook entry
|
|
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)
|
|
330
412
|
claude-plan-review stop-gate (internal) the Stop hook entry (blocks on a pending tools-first review)
|
|
331
413
|
`);
|
|
332
414
|
}
|
|
@@ -359,6 +441,9 @@ switch (sub) {
|
|
|
359
441
|
case "stop-gate":
|
|
360
442
|
await import("./stop-gate.js");
|
|
361
443
|
break;
|
|
444
|
+
case "plan-context":
|
|
445
|
+
await import("./plan-context.js");
|
|
446
|
+
break;
|
|
362
447
|
default:
|
|
363
448
|
usage();
|
|
364
449
|
}
|
package/src/hook.js
CHANGED
|
@@ -156,13 +156,18 @@ async function main() {
|
|
|
156
156
|
} catch {
|
|
157
157
|
process.exit(0); // store failure → don't interfere
|
|
158
158
|
}
|
|
159
|
-
const { key, version, reviewId } = recorded;
|
|
159
|
+
const { key, version, reviewId, reused } = recorded;
|
|
160
160
|
|
|
161
161
|
const port = await ensureServer();
|
|
162
162
|
const reviewUrl = `http://localhost:${port}/?project=${encodeURIComponent(
|
|
163
163
|
key,
|
|
164
164
|
)}&version=${version}&review=${reviewId}&doc=root`;
|
|
165
|
-
|
|
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);
|
|
166
171
|
|
|
167
172
|
// block until the UI resolves the review (or we time out)
|
|
168
173
|
const deadline = Date.now() + TIMEOUT_MS;
|
|
@@ -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();
|
package/src/store.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import {
|
|
3
|
+
closeSync,
|
|
3
4
|
mkdirSync,
|
|
5
|
+
openSync,
|
|
4
6
|
readdirSync,
|
|
5
7
|
readFileSync,
|
|
6
8
|
writeFileSync,
|
|
9
|
+
writeSync,
|
|
7
10
|
existsSync,
|
|
8
11
|
rmSync,
|
|
9
12
|
} from "node:fs";
|
|
@@ -49,6 +52,28 @@ const versionMeta = (key, n) => join(projectDir(key), "versions", `${pad(n)}.jso
|
|
|
49
52
|
const commentsPath = (key, n) => join(projectDir(key), "comments", `${pad(n)}.json`);
|
|
50
53
|
const reviewPath = (id) => join(REVIEWS_DIR, `${id}.json`);
|
|
51
54
|
|
|
55
|
+
// ---------- double-fire dedup (one ExitPlanMode call → one review) ----------
|
|
56
|
+
// Two hook entries in different settings scopes can both fire for the SAME
|
|
57
|
+
// ExitPlanMode tool call; they arrive with an identical tool_use_id. We serialize
|
|
58
|
+
// them with an exclusive-create marker file keyed by tool_use_id: the first
|
|
59
|
+
// process to create the marker "wins" and records the review, writing its id back
|
|
60
|
+
// into the marker; concurrent losers read that id and reuse the review instead of
|
|
61
|
+
// creating a duplicate. The marker uses a NON-.json extension so it's invisible to
|
|
62
|
+
// listReviews() (which globs *.json). Fail-open: any error skips dedup.
|
|
63
|
+
const toolUseMarkerPath = (id) =>
|
|
64
|
+
join(REVIEWS_DIR, `tooluse-${String(id).replace(/[^a-zA-Z0-9_-]/g, "_")}.lock`);
|
|
65
|
+
|
|
66
|
+
/** Synchronous sleep (recordPlan is sync, called from the blocking hook). */
|
|
67
|
+
function sleepSync(ms) {
|
|
68
|
+
try {
|
|
69
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
70
|
+
} catch {
|
|
71
|
+
// SharedArrayBuffer unavailable → best-effort busy wait
|
|
72
|
+
const end = Date.now() + ms;
|
|
73
|
+
while (Date.now() < end) {}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
52
77
|
// ---------- projects / versions ----------
|
|
53
78
|
export function getProjectMeta(key) {
|
|
54
79
|
return existsSync(metaPath(key)) ? readJSON(metaPath(key), null) : null;
|
|
@@ -104,6 +129,31 @@ function writeTree(key, version, tree) {
|
|
|
104
129
|
export function recordPlan(opts) {
|
|
105
130
|
const key = projectKey(opts.cwd);
|
|
106
131
|
ensureDirs(key);
|
|
132
|
+
|
|
133
|
+
// Double-fire dedup: if another invocation with the same tool_use_id is already
|
|
134
|
+
// in flight (or done), reuse its review rather than creating a second one.
|
|
135
|
+
let markerFd = null;
|
|
136
|
+
const markerFile = opts.toolUseId ? toolUseMarkerPath(opts.toolUseId) : null;
|
|
137
|
+
if (markerFile) {
|
|
138
|
+
try {
|
|
139
|
+
markerFd = openSync(markerFile, "wx"); // atomic exclusive create — we won the race
|
|
140
|
+
} catch (e) {
|
|
141
|
+
if (e && e.code === "EEXIST") {
|
|
142
|
+
// Someone else is recording (or has recorded) this tool call. Wait for
|
|
143
|
+
// them to publish the reviewId, then reuse it.
|
|
144
|
+
for (let i = 0; i < 120; i++) {
|
|
145
|
+
const m = readJSON(markerFile, null);
|
|
146
|
+
if (m && m.reviewId && existsSync(reviewPath(m.reviewId))) {
|
|
147
|
+
return { key, version: m.version, reviewId: m.reviewId, reused: true };
|
|
148
|
+
}
|
|
149
|
+
sleepSync(50);
|
|
150
|
+
}
|
|
151
|
+
// Winner never published (crashed mid-write) → fall through and record our own.
|
|
152
|
+
}
|
|
153
|
+
// Any other error → skip dedup, record normally.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
107
157
|
const tree =
|
|
108
158
|
opts.tree ?? { kind: "single", markdown: opts.plan != null ? opts.plan : "" };
|
|
109
159
|
const hash = hashTree(tree);
|
|
@@ -166,7 +216,19 @@ export function recordPlan(opts) {
|
|
|
166
216
|
createdAt: now(),
|
|
167
217
|
}),
|
|
168
218
|
);
|
|
169
|
-
|
|
219
|
+
|
|
220
|
+
// Publish the reviewId into the marker so any concurrent duplicate invocation
|
|
221
|
+
// reuses it instead of creating a second review.
|
|
222
|
+
if (markerFd !== null) {
|
|
223
|
+
try {
|
|
224
|
+
writeSync(markerFd, JSON.stringify({ toolUseId: opts.toolUseId, key, version, reviewId }));
|
|
225
|
+
} catch {}
|
|
226
|
+
try {
|
|
227
|
+
closeSync(markerFd);
|
|
228
|
+
} catch {}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { key, version, reviewId, reused: false };
|
|
170
232
|
}
|
|
171
233
|
|
|
172
234
|
export function listProjects() {
|
package/src/ui/app.js
CHANGED
|
@@ -803,28 +803,32 @@ async function doSave() {
|
|
|
803
803
|
}
|
|
804
804
|
|
|
805
805
|
// ---------- deletion ----------
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
806
|
+
// Deletes happen IMMEDIATELY on click — no confirmation modal. The modal appears
|
|
807
|
+
// ONLY when a delete is blocked by a pending review (409 for single/project, or a
|
|
808
|
+
// non-empty `blocked` array for bulk): it shows the blocked info + a Force option.
|
|
809
|
+
function deleteTitleFor(pd) {
|
|
810
|
+
if (pd.type === "version") return `Couldn't delete v${pd.n}`;
|
|
811
|
+
if (pd.type === "project") return `Couldn't delete “${projNameByKey(pd.key)}”`;
|
|
812
|
+
return "Couldn't delete every selected version";
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Open the blocked/Force modal (the only remaining modal interaction).
|
|
816
|
+
function openBlockedModal(pd) {
|
|
817
|
+
$("deleteTitle").textContent = deleteTitleFor(pd);
|
|
818
|
+
$("deleteDesc").textContent = "";
|
|
819
|
+
$("deleteForce").hidden = false;
|
|
814
820
|
$("deleteForce").disabled = false;
|
|
815
821
|
$("deleteModal").hidden = false;
|
|
816
822
|
}
|
|
817
823
|
|
|
818
|
-
function
|
|
824
|
+
function deleteVersionNow() {
|
|
819
825
|
if (!state.version) return;
|
|
820
826
|
state.pendingDelete = { type: "version", key: state.key, n: state.version };
|
|
821
|
-
|
|
822
|
-
`Delete v${state.version}?`,
|
|
823
|
-
`This permanently removes v${state.version} of “${projectName()}” and its comments. This can't be undone.`,
|
|
824
|
-
);
|
|
827
|
+
performDelete(false);
|
|
825
828
|
}
|
|
826
829
|
|
|
827
830
|
function showDeleteBlocked(e) {
|
|
831
|
+
openBlockedModal(state.pendingDelete);
|
|
828
832
|
const revs = e.pendingReviews || [];
|
|
829
833
|
const info = $("deletePending");
|
|
830
834
|
info.hidden = false;
|
|
@@ -832,14 +836,13 @@ function showDeleteBlocked(e) {
|
|
|
832
836
|
info.textContent =
|
|
833
837
|
`Blocked: ${revs.length || "a"} pending review${many ? "s" : ""} still awaiting a decision. ` +
|
|
834
838
|
`Force will auto-reject ${many ? "them" : "it"} (Claude is told the plan was deleted and to re-present or ask how to proceed), then delete.`;
|
|
835
|
-
$("deleteForce").hidden = false;
|
|
836
|
-
$("deleteConfirm").hidden = true;
|
|
837
839
|
}
|
|
838
840
|
|
|
839
841
|
// bulk-delete is always 200 with partial-success {deleted,blocked,meta}; render
|
|
840
842
|
// the blocked versions (+ their pending reviews) and offer a Force retry for
|
|
841
843
|
// just those. Single-version / project DELETE stay 409-on-block (showDeleteBlocked).
|
|
842
844
|
function showBulkBlocked(blocked, deleted) {
|
|
845
|
+
openBlockedModal(state.pendingDelete);
|
|
843
846
|
const info = $("deletePending");
|
|
844
847
|
info.hidden = false;
|
|
845
848
|
const ns = blocked.map((b) => b.n);
|
|
@@ -850,9 +853,6 @@ function showBulkBlocked(blocked, deleted) {
|
|
|
850
853
|
`${delMsg}Blocked: v${ns.join(", v")} — ${totalPending || "a"} pending review${many ? "s" : ""} ` +
|
|
851
854
|
`still awaiting a decision. Force will auto-reject ${many ? "them" : "it"} (Claude is told the plan ` +
|
|
852
855
|
`was deleted and to re-present or ask how to proceed) and delete the remaining ${ns.length} version${ns.length === 1 ? "" : "s"}.`;
|
|
853
|
-
$("deleteForce").hidden = false;
|
|
854
|
-
$("deleteForce").disabled = false;
|
|
855
|
-
$("deleteConfirm").hidden = true;
|
|
856
856
|
}
|
|
857
857
|
|
|
858
858
|
async function finishDelete(pd, msg) {
|
|
@@ -864,12 +864,10 @@ async function finishDelete(pd, msg) {
|
|
|
864
864
|
if (wasManage && !$("manageModal").hidden) await renderManage();
|
|
865
865
|
}
|
|
866
866
|
|
|
867
|
-
async function
|
|
867
|
+
async function performDelete(force) {
|
|
868
868
|
const pd = state.pendingDelete;
|
|
869
869
|
if (!pd) return;
|
|
870
|
-
const bconfirm = $("deleteConfirm");
|
|
871
870
|
const bforce = $("deleteForce");
|
|
872
|
-
bconfirm.disabled = true;
|
|
873
871
|
bforce.disabled = true;
|
|
874
872
|
const f = force ? "true" : "";
|
|
875
873
|
try {
|
|
@@ -903,7 +901,6 @@ async function confirmDelete(force) {
|
|
|
903
901
|
if (e.status === 409) showDeleteBlocked(e); // version / project block
|
|
904
902
|
else {
|
|
905
903
|
toast("Delete failed: " + e.message);
|
|
906
|
-
bconfirm.disabled = false;
|
|
907
904
|
bforce.disabled = false;
|
|
908
905
|
}
|
|
909
906
|
}
|
|
@@ -1109,8 +1106,8 @@ function wireUI() {
|
|
|
1109
1106
|
await resolve("reject");
|
|
1110
1107
|
};
|
|
1111
1108
|
|
|
1112
|
-
// deletion + manage
|
|
1113
|
-
$("deleteVerBtn").onclick = () =>
|
|
1109
|
+
// deletion + manage — delete fires immediately; the modal is blocked-only
|
|
1110
|
+
$("deleteVerBtn").onclick = () => deleteVersionNow();
|
|
1114
1111
|
$("deleteCancel").onclick = async () => {
|
|
1115
1112
|
const pd = state.pendingDelete;
|
|
1116
1113
|
$("deleteModal").hidden = true;
|
|
@@ -1121,8 +1118,7 @@ function wireUI() {
|
|
|
1121
1118
|
if (!$("manageModal").hidden) await renderManage();
|
|
1122
1119
|
}
|
|
1123
1120
|
};
|
|
1124
|
-
$("
|
|
1125
|
-
$("deleteForce").onclick = () => confirmDelete(true);
|
|
1121
|
+
$("deleteForce").onclick = () => performDelete(true);
|
|
1126
1122
|
|
|
1127
1123
|
$("manageBtn").onclick = () => openManageModal();
|
|
1128
1124
|
$("manageClose").onclick = () => ($("manageModal").hidden = true);
|
|
@@ -1132,10 +1128,7 @@ function wireUI() {
|
|
|
1132
1128
|
const key = btn.dataset.key;
|
|
1133
1129
|
if (btn.dataset.mg === "delproj") {
|
|
1134
1130
|
state.pendingDelete = { type: "project", key, fromManage: true };
|
|
1135
|
-
|
|
1136
|
-
`Delete project “${projNameByKey(key)}”?`,
|
|
1137
|
-
`Permanently removes the project and all its versions, comments and reviews. This can't be undone.`,
|
|
1138
|
-
);
|
|
1131
|
+
performDelete(false);
|
|
1139
1132
|
} else if (btn.dataset.mg === "delsel") {
|
|
1140
1133
|
const ns = [...$("manageList").querySelectorAll('input[type="checkbox"]:checked')]
|
|
1141
1134
|
.filter((x) => x.dataset.key === key)
|
|
@@ -1145,10 +1138,7 @@ function wireUI() {
|
|
|
1145
1138
|
return;
|
|
1146
1139
|
}
|
|
1147
1140
|
state.pendingDelete = { type: "bulk", key, ns, fromManage: true };
|
|
1148
|
-
|
|
1149
|
-
`Delete ${ns.length} version${ns.length === 1 ? "" : "s"} of “${projNameByKey(key)}”?`,
|
|
1150
|
-
`Permanently removes v${ns.join(", v")} and their comments. This can't be undone.`,
|
|
1151
|
-
);
|
|
1141
|
+
performDelete(false);
|
|
1152
1142
|
}
|
|
1153
1143
|
});
|
|
1154
1144
|
}
|
package/src/ui/index.html
CHANGED
|
@@ -127,7 +127,6 @@
|
|
|
127
127
|
<div class="pendingInfo" id="deletePending" hidden></div>
|
|
128
128
|
<div class="row">
|
|
129
129
|
<button class="btn" id="deleteCancel">Cancel</button>
|
|
130
|
-
<button class="btn reject" id="deleteConfirm">Delete</button>
|
|
131
130
|
<button class="btn reject" id="deleteForce" hidden>Force delete</button>
|
|
132
131
|
</div>
|
|
133
132
|
</div>
|