claude-plan-review 0.1.1 → 0.3.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
@@ -8,7 +8,10 @@ When Claude finishes a plan in **plan mode**, a `PreToolUse` hook on the
8
8
  `ExitPlanMode` tool intercepts it, opens the plan in your browser, and **blocks**
9
9
  until you decide:
10
10
 
11
- - **Approve** → Claude exits plan mode and starts implementing.
11
+ - **Approve** → Claude exits plan mode and starts implementing **immediately** —
12
+ no need to switch back to the terminal and confirm. (If you leave any comments
13
+ before approving, they ride along as guidance for Claude to incorporate while
14
+ implementing — "approve with comments".)
12
15
  - **Request changes** → your line + general comments are sent back as the denial
13
16
  reason; Claude stays in plan mode and revises, producing a new version.
14
17
 
@@ -24,6 +27,9 @@ Git worktrees are scoped separately automatically.
24
27
  - 🔀 **Diff** current vs any previous version — **side-by-side or unified** (toggle)
25
28
  - ✅ **Approve / request-changes** straight from the UI, fed back to Claude
26
29
  - 🗂 Per-worktree scoping; per-project opt-in (only runs where you enable the hook)
30
+ - 📤 **Save a plan to a storage channel** — push the plan markdown to a secret
31
+ **GitHub Gist** (one gist per project, overwritten on each save; GitHub keeps
32
+ the gist's revision history). More channels can be added later.
27
33
  - ⚡ Tiny, zero-framework UI; Bun server; one dependency (`marked`)
28
34
 
29
35
  ## Requirements
@@ -32,6 +38,9 @@ Git worktrees are scoped separately automatically.
32
38
  tool is plain ESM JavaScript and runs unchanged on both. `init` auto-detects which
33
39
  you have (preferring Bun) and writes the matching hook command; override with `--runtime`.
34
40
  - Claude Code ≥ 2.1 (verified against 2.1.185)
41
+ - *(Optional, only for saving to a Gist)* the [GitHub CLI](https://cli.github.com)
42
+ `gh`, logged in (`gh auth login`) with the `gist` scope. The tool reuses your
43
+ existing `gh` session — it never asks for or stores a token of its own.
35
44
 
36
45
  ## Setup
37
46
 
@@ -54,7 +63,7 @@ any machine — nothing to keep in your repo but the one settings entry.
54
63
  ### Option B — from a local clone (for hacking on it)
55
64
 
56
65
  ```bash
57
- git clone https://github.com/pavlo-petrychenko/claude-plan-preview-local
66
+ git clone https://github.com/pavlo-petrychenko/claude-plan-review
58
67
  cd claude-plan-review
59
68
  bun install # or: npm install
60
69
  bun src/cli.js init /path/to/your/project # …or…
@@ -88,6 +97,7 @@ Use `bun` or `node` interchangeably (or `bunx`/`npx claude-plan-review …` when
88
97
  | `… cli.js init [dir] [--local] [--published] [--runtime bun\|node]` | Wire the `ExitPlanMode` hook into a project |
89
98
  | `… cli.js serve [port]` | Start the review server manually (default `4607`) |
90
99
  | `… cli.js stop` | Stop the running server |
100
+ | `… cli.js channels` | Show storage-channel readiness (is `gh` installed / authed / `gist`-scoped?) |
91
101
 
92
102
  The server auto-starts on the first plan and stays up, so you can browse history
93
103
  anytime at `http://localhost:4607`.
@@ -102,6 +112,28 @@ anytime at `http://localhost:4607`.
102
112
 
103
113
  > The hook entry sets `timeout: 1800` so Claude Code waits while you review.
104
114
 
115
+ ## Storage channels (save a plan out of the local store)
116
+
117
+ Each plan version lives only on your machine by default. A **storage channel**
118
+ lets you push the plan markdown somewhere shareable. Today there's one channel —
119
+ **GitHub Gist** — and the registry is built so more can be added later.
120
+
121
+ Click **Save to gist** in the header. The first save asks for a description and
122
+ file name and creates a **secret** (unlisted) gist from the version you're
123
+ viewing. There's **one gist per project**: saving any later version overwrites
124
+ that same gist (GitHub keeps the gist's own revision history), and you can rename
125
+ the description/file name on any save. The header then shows a link to the gist
126
+ and which version it currently holds.
127
+
128
+ Auth is delegated entirely to the GitHub CLI — if you've run `gh auth login`,
129
+ nothing else is needed and **no token is ever stored by this tool**. If `gh`
130
+ isn't installed, isn't logged in, or its token lacks the `gist` scope, the save
131
+ dialog tells you the exact command to fix it (e.g. `gh auth refresh -s gist`).
132
+ Run `claude-plan-review channels` to check readiness from the terminal.
133
+
134
+ > Note: the Gist API requires a **classic** `gh` token scope (`gist`);
135
+ > fine-grained tokens don't support gists. `gh auth login` handles this for you.
136
+
105
137
  ## How it works
106
138
 
107
139
  ```
@@ -111,8 +143,10 @@ Claude finishes plan ──> PreToolUse hook (ExitPlanMode)
111
143
  │ ensures the server is up, opens the browser
112
144
  │ BLOCKS, polling for your decision
113
145
  browser (you) ─────────┘
114
- approve ──> hook emits {permissionDecision:"allow"} ──> Claude implements
115
- request ──> hook emits {permissionDecision:"deny", reason:...} ──> Claude revises (new version)
146
+ approve ──> hook emits {permissionDecision:"allow", updatedInput, additionalContext?} ──> Claude implements
147
+ (updatedInput is what skips the native "Exit plan mode?" prompt;
148
+ additionalContext carries any comments as "approve with comments")
149
+ request ──> hook emits {permissionDecision:"deny", reason:...} ──> Claude revises (new version)
116
150
  ```
117
151
 
118
152
  ## Storage layout
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plan-review",
3
- "version": "0.1.1",
3
+ "version": "0.3.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": {
@@ -32,13 +32,13 @@
32
32
  "hooks",
33
33
  "exitplanmode"
34
34
  ],
35
- "homepage": "https://github.com/pavlo-petrychenko/claude-plan-preview-local#readme",
35
+ "homepage": "https://github.com/pavlo-petrychenko/claude-plan-review#readme",
36
36
  "repository": {
37
37
  "type": "git",
38
- "url": "git+https://github.com/pavlo-petrychenko/claude-plan-preview-local.git"
38
+ "url": "git+https://github.com/pavlo-petrychenko/claude-plan-review.git"
39
39
  },
40
40
  "bugs": {
41
- "url": "https://github.com/pavlo-petrychenko/claude-plan-preview-local/issues"
41
+ "url": "https://github.com/pavlo-petrychenko/claude-plan-review/issues"
42
42
  },
43
43
  "author": "pavlo.p@mate.academy",
44
44
  "license": "MIT"
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Thin wrapper around the GitHub CLI (`gh`). Runs on Bun or Node ≥18.
3
+ *
4
+ * We shell out to `gh` rather than holding our own token: if the user has
5
+ * already `gh auth login`'d, we store no secrets at all. All request bodies are
6
+ * piped via STDIN (never argv), so plan content / descriptions can't break
7
+ * argument parsing.
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+
11
+ function run(args, { input } = {}) {
12
+ try {
13
+ const r = spawnSync("gh", args, {
14
+ input: input ?? undefined,
15
+ encoding: "utf8",
16
+ // gh writes `auth status` to stderr; capture both streams.
17
+ });
18
+ return {
19
+ ok: r.status === 0,
20
+ status: r.status,
21
+ stdout: r.stdout || "",
22
+ stderr: r.stderr || "",
23
+ spawnError: r.error || null,
24
+ };
25
+ } catch (e) {
26
+ return { ok: false, status: null, stdout: "", stderr: "", spawnError: e };
27
+ }
28
+ }
29
+
30
+ /** Is `gh` on PATH at all? */
31
+ export function installed() {
32
+ return run(["--version"]).ok;
33
+ }
34
+
35
+ /**
36
+ * Readiness probe: { installed, authed, scopes:[...] }.
37
+ * `gh auth status` exits non-zero when not logged in; its human output (on
38
+ * stderr) carries a "Token scopes: 'gist', 'repo', …" line we parse.
39
+ */
40
+ export function status() {
41
+ if (!installed()) return { installed: false, authed: false, scopes: [] };
42
+ const r = run(["auth", "status"]);
43
+ const text = `${r.stdout}\n${r.stderr}`;
44
+ const authed = r.ok || /Logged in to/i.test(text);
45
+ let scopes = [];
46
+ const m = text.match(/Token scopes:\s*(.+)/i);
47
+ if (m) {
48
+ scopes = m[1]
49
+ .split(",")
50
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
51
+ .filter(Boolean);
52
+ }
53
+ return { installed: true, authed, scopes };
54
+ }
55
+
56
+ /**
57
+ * Call the GitHub REST API through gh, sending `body` as JSON on stdin.
58
+ * Returns { ok, data, error, needsGistScope }.
59
+ */
60
+ export function api(method, path, body) {
61
+ const args = ["api", "-X", method.toUpperCase(), path];
62
+ let input;
63
+ if (body !== undefined) {
64
+ args.push("--input", "-");
65
+ input = JSON.stringify(body);
66
+ }
67
+ const r = run(args, { input });
68
+ if (r.ok) {
69
+ try {
70
+ return { ok: true, data: JSON.parse(r.stdout || "null") };
71
+ } catch {
72
+ return { ok: true, data: null };
73
+ }
74
+ }
75
+ const errText = `${r.stderr}${r.stdout}`.trim();
76
+ // gh surfaces a "(HTTP 404)"/"(HTTP 403)" + scope hint when the token can't touch gists
77
+ const needsGistScope =
78
+ /gist/i.test(errText) && /(scope|HTTP 40[34]|Not Found|requires)/i.test(errText);
79
+ return {
80
+ ok: false,
81
+ error: errText || (r.spawnError ? String(r.spawnError.message || r.spawnError) : "gh failed"),
82
+ needsGistScope,
83
+ };
84
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * GitHub Gist storage channel. Stores the plan markdown (only) in a secret
3
+ * gist owned by the user's `gh`-authenticated account. One gist per project:
4
+ * the first save creates it, later saves PATCH the same gist (GitHub keeps the
5
+ * gist's own revision history automatically).
6
+ */
7
+ import * as gh from "./gh.js";
8
+
9
+ export const id = "gist";
10
+ export const label = "GitHub Gist";
11
+
12
+ const REFRESH_CMD = "gh auth refresh -s gist";
13
+ const LOGIN_CMD = "gh auth login";
14
+
15
+ /** Readiness for the UI: can we create a gist right now, and if not, how to fix it. */
16
+ export function available() {
17
+ const s = gh.status();
18
+ if (!s.installed)
19
+ return {
20
+ ready: false,
21
+ installed: false,
22
+ authed: false,
23
+ hasGistScope: false,
24
+ reason: "GitHub CLI (gh) is not installed.",
25
+ fixCommand: null,
26
+ fixUrl: "https://cli.github.com",
27
+ };
28
+ if (!s.authed)
29
+ return {
30
+ ready: false,
31
+ installed: true,
32
+ authed: false,
33
+ hasGistScope: false,
34
+ reason: "Not logged in to GitHub.",
35
+ fixCommand: LOGIN_CMD,
36
+ };
37
+ const hasGistScope = s.scopes.includes("gist");
38
+ return {
39
+ ready: hasGistScope,
40
+ installed: true,
41
+ authed: true,
42
+ hasGistScope,
43
+ reason: hasGistScope ? null : "Your gh token is missing the 'gist' scope.",
44
+ fixCommand: hasGistScope ? null : REFRESH_CMD,
45
+ scopes: s.scopes,
46
+ };
47
+ }
48
+
49
+ function ghError(res) {
50
+ const e = new Error(
51
+ res.needsGistScope
52
+ ? `GitHub rejected the gist write — your gh token likely lacks the 'gist' scope. Run: ${REFRESH_CMD}`
53
+ : res.error || "gh request failed",
54
+ );
55
+ e.fixCommand = res.needsGistScope ? REFRESH_CMD : null;
56
+ return e;
57
+ }
58
+
59
+ /** Create a new secret gist. Returns the binding info we persist. */
60
+ export function create({ markdown, description, filename }) {
61
+ const file = filename || "plan.md";
62
+ const res = gh.api("POST", "/gists", {
63
+ description: description || "",
64
+ public: false,
65
+ files: { [file]: { content: markdown || "\n" } },
66
+ });
67
+ if (!res.ok) throw ghError(res);
68
+ return {
69
+ id: res.data.id,
70
+ htmlUrl: res.data.html_url,
71
+ filename: file,
72
+ description: description || "",
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Overwrite an existing gist with new content (and optionally a new
78
+ * description / filename). `oldFilename` is the name currently in the gist so
79
+ * we can rename rather than orphan it.
80
+ */
81
+ export function update({ id: gistId, markdown, description, filename, oldFilename }) {
82
+ const newName = filename || oldFilename || "plan.md";
83
+ const fileKey = oldFilename || newName;
84
+ const filePatch = { content: markdown || "\n" };
85
+ if (newName !== fileKey) filePatch.filename = newName;
86
+ const res = gh.api("PATCH", `/gists/${gistId}`, {
87
+ description: description ?? "",
88
+ files: { [fileKey]: filePatch },
89
+ });
90
+ if (!res.ok) throw ghError(res);
91
+ return {
92
+ id: res.data.id,
93
+ htmlUrl: res.data.html_url,
94
+ filename: newName,
95
+ description: res.data.description ?? description ?? "",
96
+ };
97
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Storage-channel registry. A channel lets the user push a plan out of the
3
+ * local store into some shareable location. Gist is the only channel today;
4
+ * the registry keeps adding more (S3, Drive, …) a matter of dropping in a
5
+ * module with the same shape.
6
+ *
7
+ * Channel contract:
8
+ * id, label — identity
9
+ * available() → { ready, ...flags } — can we use it now, and how to fix it
10
+ * create({markdown, description, filename}) → binding info
11
+ * update({id, markdown, description, filename, oldFilename}) → binding info
12
+ */
13
+ import * as gist from "./gist.js";
14
+
15
+ export const channels = { [gist.id]: gist };
16
+
17
+ export function getChannel(id) {
18
+ return channels[id] || null;
19
+ }
20
+
21
+ /** Readiness of every channel, for the picker / verify UI. */
22
+ export function listChannelStatus() {
23
+ return Object.values(channels).map((ch) => ({ id: ch.id, label: ch.label, ...ch.available() }));
24
+ }
package/src/cli.js CHANGED
@@ -102,6 +102,27 @@ async function cmdServe(args) {
102
102
  console.log(`Open http://localhost:${port} (Ctrl+C to stop)`);
103
103
  }
104
104
 
105
+ async function cmdChannels() {
106
+ const { listChannelStatus } = await import("./channels/index.js");
107
+ const list = listChannelStatus();
108
+ if (!list.length) {
109
+ console.log("No storage channels registered.");
110
+ return;
111
+ }
112
+ console.log("Storage channels:\n");
113
+ for (const c of list) {
114
+ const mark = c.ready ? "✓" : "✗";
115
+ console.log(` ${mark} ${c.label} (${c.id})`);
116
+ if (!c.ready) {
117
+ if (c.reason) console.log(` ${c.reason}`);
118
+ if (c.fixCommand) console.log(` fix: ${c.fixCommand}`);
119
+ else if (c.fixUrl) console.log(` see: ${c.fixUrl}`);
120
+ } else if (c.scopes) {
121
+ console.log(` gh scopes: ${c.scopes.join(", ")}`);
122
+ }
123
+ }
124
+ }
125
+
105
126
  function cmdStop() {
106
127
  const info = readJSON(SERVER_FILE, null);
107
128
  if (!info?.pid) {
@@ -130,6 +151,7 @@ Usage:
130
151
  --runtime → force a runtime
131
152
  claude-plan-review serve [port] Start the review server (default ${DEFAULT_PORT})
132
153
  claude-plan-review stop Stop the running server
154
+ claude-plan-review channels Show storage-channel readiness (e.g. gh / gist)
133
155
  claude-plan-review hook (internal) the PreToolUse hook entry
134
156
  `);
135
157
  }
@@ -145,6 +167,9 @@ switch (sub) {
145
167
  case "stop":
146
168
  cmdStop();
147
169
  break;
170
+ case "channels":
171
+ await cmdChannels();
172
+ break;
148
173
  case "hook":
149
174
  await import("./hook.js");
150
175
  break;
package/src/hook.js CHANGED
@@ -23,17 +23,48 @@ const POLL_MS = 700;
23
23
 
24
24
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
25
25
 
26
- function out(decision, reason, sys) {
27
- process.stdout.write(
28
- JSON.stringify({
29
- hookSpecificOutput: {
30
- hookEventName: "PreToolUse",
31
- permissionDecision: decision,
32
- permissionDecisionReason: reason,
33
- },
34
- systemMessage: sys,
35
- }),
36
- );
26
+ function emit(payload) {
27
+ process.stdout.write(JSON.stringify(payload));
28
+ }
29
+
30
+ /**
31
+ * Approve and auto-proceed WITHOUT the terminal "Exit plan mode?" keypress.
32
+ *
33
+ * ExitPlanMode reports requiresUserInteraction()=true, so a bare allow falls
34
+ * through to the native prompt. The permission combiner only bypasses the
35
+ * prompt when the hook ALSO returns `updatedInput` (it treats that as "the hook
36
+ * already satisfied the user interaction"). So we echo the original tool input
37
+ * back. `additionalContext`, when present, carries the reviewer's notes into
38
+ * Claude's context — i.e. "approve with comments".
39
+ * (Verified against the Claude Code 2.1.185 binary.)
40
+ */
41
+ function allow(toolInput, notes) {
42
+ const hookSpecificOutput = {
43
+ hookEventName: "PreToolUse",
44
+ permissionDecision: "allow",
45
+ permissionDecisionReason: notes
46
+ ? "Approved with comments in the plan-review UI."
47
+ : "Approved in the plan-review UI.",
48
+ updatedInput: toolInput, // REQUIRED to skip the native approval prompt
49
+ };
50
+ if (notes) hookSpecificOutput.additionalContext = notes;
51
+ emit({
52
+ hookSpecificOutput,
53
+ systemMessage: notes
54
+ ? "✅ Plan approved with comments via plan-review"
55
+ : "✅ Plan approved via plan-review",
56
+ });
57
+ }
58
+
59
+ function deny(reason) {
60
+ emit({
61
+ hookSpecificOutput: {
62
+ hookEventName: "PreToolUse",
63
+ permissionDecision: "deny",
64
+ permissionDecisionReason: reason,
65
+ },
66
+ systemMessage: "📝 Changes requested via plan-review",
67
+ });
37
68
  }
38
69
 
39
70
  function serverPort() {
@@ -92,7 +123,8 @@ async function main() {
92
123
 
93
124
  if (input.tool_name !== "ExitPlanMode") process.exit(0);
94
125
 
95
- const plan = input.tool_input?.plan ?? "";
126
+ const toolInput = input.tool_input ?? {};
127
+ const plan = toolInput.plan ?? "";
96
128
  if (!plan.trim()) process.exit(0);
97
129
 
98
130
  const { key, version, reviewId } = recordPlan({
@@ -114,15 +146,11 @@ async function main() {
114
146
  while (Date.now() < deadline) {
115
147
  const review = getReview(reviewId);
116
148
  if (review && review.status === "approved") {
117
- out("allow", "Approved in the plan-review UI.", "✅ Plan approved via plan-review");
149
+ allow(toolInput, review.notes);
118
150
  process.exit(0);
119
151
  }
120
152
  if (review && review.status === "rejected") {
121
- out(
122
- "deny",
123
- review.reason || "Changes requested in the plan-review UI.",
124
- "📝 Changes requested via plan-review",
125
- );
153
+ deny(review.reason || "Changes requested in the plan-review UI.");
126
154
  process.exit(0);
127
155
  }
128
156
  await sleep(POLL_MS);
package/src/server.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  deleteComment,
10
10
  getProjectMeta,
11
11
  getReview,
12
+ getStorage,
12
13
  getVersion,
13
14
  listComments,
14
15
  listProjects,
@@ -16,7 +17,9 @@ import {
16
17
  listVersions,
17
18
  pruneResolvedReviews,
18
19
  resolveReview,
20
+ setStorage,
19
21
  } from "./store.js";
22
+ import { getChannel, listChannelStatus } from "./channels/index.js";
20
23
 
21
24
  const HERE = dirname(fileURLToPath(import.meta.url));
22
25
  const UI_DIR = join(HERE, "ui");
@@ -115,6 +118,7 @@ async function handleApi(req, res, seg, url) {
115
118
 
116
119
  if (a === "health") return sendJSON(res, { ok: true });
117
120
  if (a === "version") return sendJSON(res, { version: pkg.version });
121
+ if (a === "channels" && !b) return sendJSON(res, listChannelStatus());
118
122
 
119
123
  if (a === "projects" && !b) return sendJSON(res, listProjects());
120
124
 
@@ -134,6 +138,11 @@ async function handleApi(req, res, seg, url) {
134
138
  });
135
139
  }
136
140
 
141
+ // /api/projects/:key/storage → current channel bindings
142
+ if (a === "projects" && b && c === "storage" && !d) {
143
+ return sendJSON(res, getStorage(b));
144
+ }
145
+
137
146
  // /api/projects/:key/versions/:n ...
138
147
  if (a === "projects" && b && c === "versions" && d) {
139
148
  const key = b;
@@ -162,6 +171,10 @@ async function handleApi(req, res, seg, url) {
162
171
  if (e === "comments" && seg[5] && method === "DELETE") {
163
172
  return sendJSON(res, { ok: deleteComment(key, n, seg[5]) });
164
173
  }
174
+ // /api/projects/:key/versions/:n/save (POST) → push this version to a storage channel
175
+ if (e === "save" && method === "POST") {
176
+ return await handleSave(req, res, key, n);
177
+ }
165
178
  }
166
179
 
167
180
  // /api/reviews ...
@@ -181,6 +194,47 @@ async function handleApi(req, res, seg, url) {
181
194
  return sendJSON(res, { error: "unknown endpoint" }, 404);
182
195
  }
183
196
 
197
+ /** POST /api/projects/:key/versions/:n/save — create or overwrite the channel's store. */
198
+ async function handleSave(req, res, key, n) {
199
+ const body = JSON.parse((await readBody(req)) || "{}");
200
+ const channelId = body.channel || "gist";
201
+ const channel = getChannel(channelId);
202
+ if (!channel) return sendJSON(res, { error: `unknown channel: ${channelId}` }, 400);
203
+
204
+ const v = getVersion(key, n);
205
+ if (!v) return sendJSON(res, { error: "no such version" }, 404);
206
+
207
+ const ready = channel.available();
208
+ if (!ready.ready) {
209
+ return sendJSON(
210
+ res,
211
+ { error: ready.reason || "channel not ready", fixCommand: ready.fixCommand ?? null },
212
+ 409,
213
+ );
214
+ }
215
+
216
+ const existing = getStorage(key)[channelId];
217
+ try {
218
+ const info = existing?.id
219
+ ? channel.update({
220
+ id: existing.id,
221
+ markdown: v.markdown,
222
+ description: body.description ?? existing.description ?? "",
223
+ filename: body.filename ?? existing.filename,
224
+ oldFilename: existing.filename,
225
+ })
226
+ : channel.create({
227
+ markdown: v.markdown,
228
+ description: body.description ?? "",
229
+ filename: body.filename,
230
+ });
231
+ const saved = setStorage(key, channelId, { ...info, savedVersion: n });
232
+ return sendJSON(res, { channel: channelId, savedVersion: n, ...saved });
233
+ } catch (e) {
234
+ return sendJSON(res, { error: String(e?.message || e), fixCommand: e?.fixCommand ?? null }, 502);
235
+ }
236
+ }
237
+
184
238
  // run directly: `node src/server.js [port]` (or bun)
185
239
  const invokedDirectly =
186
240
  process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
package/src/store.js CHANGED
@@ -42,6 +42,23 @@ export function getProjectMeta(key) {
42
42
  return existsSync(metaPath(key)) ? readJSON(metaPath(key), null) : null;
43
43
  }
44
44
 
45
+ // ---------- storage-channel bindings ----------
46
+ /** All channel bindings for a project, e.g. { gist: { id, htmlUrl, … } }. */
47
+ export function getStorage(key) {
48
+ return getProjectMeta(key)?.storage ?? {};
49
+ }
50
+
51
+ /** Persist (or update) the binding for one channel on a project. */
52
+ export function setStorage(key, channelId, info) {
53
+ const meta = getProjectMeta(key);
54
+ if (!meta) return null;
55
+ meta.storage ??= {};
56
+ meta.storage[channelId] = { ...meta.storage[channelId], ...info, savedAt: now() };
57
+ meta.updatedAt = now();
58
+ writeFileSync(metaPath(key), enc(meta));
59
+ return meta.storage[channelId];
60
+ }
61
+
45
62
  /**
46
63
  * Record a freshly-presented plan. De-dupes by content hash: an identical
47
64
  * re-presentation reuses the existing version (and keeps its comments),
@@ -82,6 +99,8 @@ export function recordPlan(opts) {
82
99
  updatedAt: now(),
83
100
  currentVersion: version,
84
101
  latestHash: hash,
102
+ // carry forward storage-channel bindings — they outlive individual versions
103
+ storage: meta?.storage ?? {},
85
104
  };
86
105
  }
87
106
  writeFileSync(metaPath(key), enc(meta));
@@ -179,27 +198,50 @@ export function getReview(id) {
179
198
  return existsSync(reviewPath(id)) ? readJSON(reviewPath(id), null) : null;
180
199
  }
181
200
 
182
- /** Resolve a review; for reject, compile its version's comments into the reason. */
201
+ /**
202
+ * Resolve a review.
203
+ * - reject → compile the version's comments into `reason` (fed back via deny).
204
+ * - approve → if the version has comments, compile them into `notes`
205
+ * (delivered to Claude as additionalContext on the allow — "approve with comments").
206
+ * Returns the review including `commentCount` so callers can reflect it in the UI.
207
+ */
183
208
  export function resolveReview(id, decision) {
184
209
  const review = getReview(id);
185
210
  if (!review) return null;
186
211
  review.status = decision === "approve" ? "approved" : "rejected";
187
212
  review.resolvedAt = now();
188
- if (decision === "reject") review.reason = compileReason(review.projectKey, review.version);
213
+ review.commentCount = listComments(review.projectKey, review.version).length;
214
+ if (decision === "reject") {
215
+ review.reason = compileComments(review.projectKey, review.version, "reject");
216
+ } else {
217
+ const notes = compileComments(review.projectKey, review.version, "approve");
218
+ if (notes) review.notes = notes;
219
+ }
189
220
  writeFileSync(reviewPath(id), enc(review));
190
221
  return review;
191
222
  }
192
223
 
193
- function compileReason(key, n) {
224
+ /**
225
+ * Format a version's comments for the model.
226
+ * mode "reject" → "address these, then re-present" (always returns text, even with no comments).
227
+ * mode "approve" → "plan is approved, incorporate these notes" (returns "" when there are none).
228
+ */
229
+ function compileComments(key, n, mode) {
194
230
  const comments = listComments(key, n);
195
231
  const lineComments = comments.filter((c) => c.line != null).sort((a, b) => a.line - b.line);
196
232
  const general = comments.filter((c) => c.line == null);
233
+
234
+ if (mode === "approve" && !lineComments.length && !general.length) return "";
235
+
197
236
  const md = getVersion(key, n)?.markdown ?? "";
198
237
  const lines = md.split("\n");
199
238
 
200
239
  const parts = [
201
- "The reviewer requested changes to this plan in the plan-review UI. " +
202
- "Address each comment below, then re-present the revised plan with ExitPlanMode.",
240
+ mode === "approve"
241
+ ? "The reviewer APPROVED this plan in the plan-review UI and left the notes below. " +
242
+ "Proceed with implementation, incorporating each note as you go."
243
+ : "The reviewer requested changes to this plan in the plan-review UI. " +
244
+ "Address each comment below, then re-present the revised plan with ExitPlanMode.",
203
245
  ];
204
246
  if (lineComments.length) {
205
247
  parts.push("\nLine comments:");
@@ -220,7 +262,7 @@ function compileReason(key, n) {
220
262
  parts.push("\nGeneral comments:");
221
263
  for (const c of general) parts.push(`- ${c.body}`);
222
264
  }
223
- if (!lineComments.length && !general.length) {
265
+ if (mode === "reject" && !lineComments.length && !general.length) {
224
266
  parts.push("\n(No specific comments were left — please reconsider and improve the plan.)");
225
267
  }
226
268
  return parts.join("\n");
package/src/ui/app.js CHANGED
@@ -6,7 +6,12 @@ const esc = (s) =>
6
6
 
7
7
  async function api(path, opts) {
8
8
  const res = await fetch(path, opts);
9
- if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.statusText);
9
+ if (!res.ok) {
10
+ const body = await res.json().catch(() => ({}));
11
+ const err = new Error(body.error || res.statusText);
12
+ err.fixCommand = body.fixCommand ?? null;
13
+ throw err;
14
+ }
10
15
  return res.status === 204 ? null : res.json();
11
16
  }
12
17
 
@@ -23,6 +28,8 @@ const state = {
23
28
  diffMode: "split",
24
29
  sel: { anchor: null, start: null, end: null }, // source line-range selection
25
30
  dragging: false,
31
+ storage: {}, // channel bindings for the current project, e.g. { gist: {...} }
32
+ channels: [], // channel readiness (loaded when the save modal opens)
26
33
  };
27
34
 
28
35
  function toast(msg) {
@@ -60,6 +67,7 @@ function showEmpty(msg) {
60
67
  $("panel-preview").classList.add("active");
61
68
  $("preview").innerHTML = `<div class="empty">${esc(msg)}</div>`;
62
69
  $("reviewActions").hidden = true;
70
+ $("saveActions").hidden = true;
63
71
  }
64
72
 
65
73
  async function loadProjects() {
@@ -80,6 +88,7 @@ async function selectProject(key, wantV) {
80
88
  $("projectSel").value = key;
81
89
  const { versions } = await api(`/api/projects/${encodeURIComponent(key)}`);
82
90
  state.versions = versions;
91
+ state.storage = await api(`/api/projects/${encodeURIComponent(key)}/storage`).catch(() => ({}));
83
92
  if (!versions.length) {
84
93
  showEmpty("This project has no plan versions yet.");
85
94
  return;
@@ -111,6 +120,7 @@ async function loadVersion(n) {
111
120
  renderSource();
112
121
  renderGeneral();
113
122
  updateCommentCount();
123
+ refreshSaveUI();
114
124
  await refreshReview();
115
125
  if (state.tab === "diff") loadDiff();
116
126
  }
@@ -349,13 +359,128 @@ async function refreshReview() {
349
359
  }
350
360
 
351
361
  async function resolve(decision) {
352
- await api(`/api/reviews/${state.reviewId}/decision`, {
362
+ const review = await api(`/api/reviews/${state.reviewId}/decision`, {
353
363
  method: "POST",
354
364
  headers: { "content-type": "application/json" },
355
365
  body: JSON.stringify({ decision }),
356
366
  });
357
367
  await refreshReview();
358
- toast(decision === "approve" ? "Plan approved — Claude will proceed." : "Sent back to Claude with your comments.");
368
+ if (decision === "approve") {
369
+ const n = review?.commentCount || 0;
370
+ toast(
371
+ n
372
+ ? `Approved with ${n} comment${n === 1 ? "" : "s"} — Claude will proceed and incorporate them.`
373
+ : "Plan approved — Claude will proceed.",
374
+ );
375
+ } else {
376
+ toast("Sent back to Claude with your comments.");
377
+ }
378
+ }
379
+
380
+ // ---------- storage / save ----------
381
+ function projectName() {
382
+ return state.projects?.find((p) => p.key === state.key)?.name || "plan";
383
+ }
384
+
385
+ function refreshSaveUI() {
386
+ const box = $("saveActions");
387
+ if (!state.version) {
388
+ box.hidden = true;
389
+ return;
390
+ }
391
+ box.hidden = false;
392
+ const g = state.storage?.gist;
393
+ const btn = $("saveBtn");
394
+ const link = $("savedLink");
395
+ if (g?.id) {
396
+ btn.textContent = state.version === g.savedVersion ? "Update gist" : `Save v${state.version} → gist`;
397
+ link.hidden = false;
398
+ link.href = g.htmlUrl;
399
+ link.textContent = `gist · holds v${g.savedVersion}`;
400
+ } else {
401
+ btn.textContent = "Save to gist";
402
+ link.hidden = true;
403
+ }
404
+ }
405
+
406
+ function renderChannelStatus(ch) {
407
+ const box = $("channelStatus");
408
+ const confirm = $("saveConfirm");
409
+ if (!ch) {
410
+ box.className = "channelStatus warn";
411
+ box.textContent = "Could not read channel status.";
412
+ confirm.disabled = true;
413
+ return;
414
+ }
415
+ if (ch.ready) {
416
+ box.className = "channelStatus ok";
417
+ box.textContent = "✓ GitHub CLI ready — gist scope present.";
418
+ confirm.disabled = false;
419
+ } else {
420
+ box.className = "channelStatus warn";
421
+ const cmd = ch.fixCommand ? ` Run: ${ch.fixCommand}` : ch.fixUrl ? ` See ${ch.fixUrl}` : "";
422
+ box.textContent = `⚠ ${ch.reason || "Channel not ready."}${cmd}`;
423
+ confirm.disabled = true;
424
+ }
425
+ }
426
+
427
+ async function openSaveModal() {
428
+ const g = state.storage?.gist;
429
+ $("saveTitle").textContent = g?.id ? "Update gist" : "Save plan to a gist";
430
+ $("saveDesc").value = g?.description ?? `${projectName()} — plan`;
431
+ $("saveFilename").value = g?.filename ?? "plan.md";
432
+ $("saveHint").textContent = g?.id
433
+ ? `Overwrites your secret gist (currently holds v${g.savedVersion}) with v${state.version}. GitHub keeps the gist's revision history.`
434
+ : `Creates a secret (unlisted) gist with v${state.version}'s markdown. Only people with the link can see it.`;
435
+ $("saveModal").hidden = false;
436
+
437
+ const sel = $("saveChannel");
438
+ $("channelStatus").textContent = "Checking GitHub CLI…";
439
+ $("channelStatus").className = "channelStatus";
440
+ $("saveConfirm").disabled = true;
441
+ try {
442
+ state.channels = await api("/api/channels");
443
+ } catch {
444
+ state.channels = [];
445
+ }
446
+ sel.innerHTML = state.channels.map((c) => `<option value="${esc(c.id)}">${esc(c.label)}</option>`).join("");
447
+ const current = state.channels.find((c) => c.id === (sel.value || "gist")) || state.channels[0];
448
+ if (current) sel.value = current.id;
449
+ renderChannelStatus(current);
450
+ }
451
+
452
+ function closeSaveModal() {
453
+ $("saveModal").hidden = true;
454
+ }
455
+
456
+ async function doSave() {
457
+ const channel = $("saveChannel").value || "gist";
458
+ const description = $("saveDesc").value.trim();
459
+ const filename = $("saveFilename").value.trim() || "plan.md";
460
+ const btn = $("saveConfirm");
461
+ btn.disabled = true;
462
+ const prev = btn.textContent;
463
+ btn.textContent = "Saving…";
464
+ try {
465
+ const r = await api(
466
+ `/api/projects/${encodeURIComponent(state.key)}/versions/${state.version}/save`,
467
+ {
468
+ method: "POST",
469
+ headers: { "content-type": "application/json" },
470
+ body: JSON.stringify({ channel, description, filename }),
471
+ },
472
+ );
473
+ state.storage[channel] = r;
474
+ closeSaveModal();
475
+ refreshSaveUI();
476
+ toast(`Saved v${state.version} to ${channel}.`);
477
+ } catch (e) {
478
+ // surface the remediation command if the channel handed one back
479
+ renderChannelStatus({ ready: false, reason: e.message, fixCommand: e.fixCommand });
480
+ } finally {
481
+ btn.textContent = prev;
482
+ btn.disabled = false;
483
+ }
359
484
  }
360
485
 
361
486
  // ---------- polling (reflect external resolution) ----------
@@ -469,6 +594,12 @@ function wireUI() {
469
594
  $("generalInput").value = "";
470
595
  };
471
596
 
597
+ $("saveBtn").onclick = () => openSaveModal();
598
+ $("saveCancel").onclick = () => closeSaveModal();
599
+ $("saveConfirm").onclick = () => doSave();
600
+ $("saveChannel").onchange = () =>
601
+ renderChannelStatus(state.channels.find((c) => c.id === $("saveChannel").value));
602
+
472
603
  $("approveBtn").onclick = () => resolve("approve");
473
604
  $("rejectBtn").onclick = () => ($("rejectModal").hidden = false);
474
605
  $("rejectCancel").onclick = () => ($("rejectModal").hidden = true);
package/src/ui/index.html CHANGED
@@ -25,6 +25,10 @@
25
25
  <select id="versionSel"></select>
26
26
  </label>
27
27
  </div>
28
+ <div class="saveActions" id="saveActions" hidden>
29
+ <button class="btn" id="saveBtn">Save to gist</button>
30
+ <a class="savedlink" id="savedLink" href="#" target="_blank" rel="noopener" hidden></a>
31
+ </div>
28
32
  <div class="reviewActions" id="reviewActions" hidden>
29
33
  <span class="pill pending" id="reviewPill">● review pending</span>
30
34
  <button class="btn approve" id="approveBtn">Approve plan</button>
@@ -87,6 +91,27 @@
87
91
  </div>
88
92
  </div>
89
93
 
94
+ <div class="saveModal" id="saveModal" hidden>
95
+ <div class="box">
96
+ <h3 id="saveTitle">Save plan to a gist</h3>
97
+ <div class="channelStatus" id="channelStatus"></div>
98
+ <label class="field">Storage channel
99
+ <select id="saveChannel"></select>
100
+ </label>
101
+ <label class="field">Gist description
102
+ <input type="text" id="saveDesc" placeholder="Short description shown in your gist list" />
103
+ </label>
104
+ <label class="field">File name
105
+ <input type="text" id="saveFilename" placeholder="plan.md" />
106
+ </label>
107
+ <p class="hint" id="saveHint"></p>
108
+ <div class="row">
109
+ <button class="btn" id="saveCancel">Cancel</button>
110
+ <button class="btn primary" id="saveConfirm">Save</button>
111
+ </div>
112
+ </div>
113
+ </div>
114
+
90
115
  <div class="toast" id="toast" hidden></div>
91
116
  </div>
92
117
  <script src="/app.js"></script>
package/src/ui/style.css CHANGED
@@ -173,6 +173,25 @@ textarea {
173
173
  .rejectModal textarea { width: 100%; min-height: 80px; }
174
174
  .rejectModal .row { display: flex; justify-content: flex-end; gap: 10px; margin-top: 12px; }
175
175
 
176
+ /* save-to-channel control + modal */
177
+ .saveActions { display: flex; align-items: center; gap: 8px; }
178
+ .savedlink { font-size: 11px; color: var(--fg-muted); font-family: var(--mono); text-decoration: none; white-space: nowrap; }
179
+ .savedlink:hover { color: var(--accent); text-decoration: underline; }
180
+
181
+ .saveModal { position: fixed; inset: 0; background: #00000066; display: flex; align-items: center; justify-content: center; z-index: 50; }
182
+ .saveModal .box { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 20px; width: 480px; max-width: 92vw; }
183
+ .saveModal h3 { margin-top: 0; }
184
+ .saveModal .field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--fg-muted); margin-bottom: 12px; }
185
+ .saveModal .field input, .saveModal .field select { font-family: var(--sans); font-size: 13px; padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); }
186
+ .saveModal .hint { color: var(--fg-muted); font-size: 12px; margin: 4px 0 0; }
187
+ .saveModal .row { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
188
+ .saveModal .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
189
+ .saveModal .btn.primary:hover { filter: brightness(1.08); }
190
+ .saveModal .btn:disabled { opacity: .5; cursor: not-allowed; filter: none; }
191
+ .channelStatus { font-size: 12px; padding: 8px 10px; border-radius: 6px; margin-bottom: 14px; white-space: pre-wrap; word-break: break-word; }
192
+ .channelStatus.ok { background: var(--green-bg); color: var(--green); }
193
+ .channelStatus.warn { background: #fff8c5; color: #7d4e00; font-family: var(--mono); }
194
+
176
195
  .toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); background: var(--fg); color: var(--bg);
177
196
  padding: 10px 18px; border-radius: 8px; font-size: 13px; z-index: 60; box-shadow: 0 4px 16px #0004; }
178
197