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 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 five things:
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 with an in-app confirm.
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 returns `409` and the UI offers **Force**,
255
- which auto-rejects that review (telling Claude the plan was deleted) before removing it.
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.4.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?.includes("hook.js") || h?.command === 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 Stop-hook gate into a project
315
- (or all projects), install the plan-review-multidoc skill, and
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
- openBrowser(reviewUrl);
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
- return { key, version, reviewId };
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
- function showDeleteModal(title, desc) {
807
- $("deleteTitle").textContent = title;
808
- $("deleteDesc").textContent = desc;
809
- $("deletePending").hidden = true;
810
- $("deletePending").textContent = "";
811
- $("deleteForce").hidden = true;
812
- $("deleteConfirm").hidden = false;
813
- $("deleteConfirm").disabled = false;
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 openDeleteVersion() {
824
+ function deleteVersionNow() {
819
825
  if (!state.version) return;
820
826
  state.pendingDelete = { type: "version", key: state.key, n: state.version };
821
- showDeleteModal(
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 confirmDelete(force) {
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 = () => openDeleteVersion();
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
- $("deleteConfirm").onclick = () => confirmDelete(false);
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
- showDeleteModal(
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
- showDeleteModal(
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>