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 +38 -4
- package/package.json +4 -4
- package/src/channels/gh.js +84 -0
- package/src/channels/gist.js +97 -0
- package/src/channels/index.js +24 -0
- package/src/cli.js +25 -0
- package/src/hook.js +46 -18
- package/src/server.js +54 -0
- package/src/store.js +48 -6
- package/src/ui/app.js +134 -3
- package/src/ui/index.html +25 -0
- package/src/ui/style.css +19 -0
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-
|
|
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"}
|
|
115
|
-
|
|
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.
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
27
|
-
process.stdout.write(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
149
|
+
allow(toolInput, review.notes);
|
|
118
150
|
process.exit(0);
|
|
119
151
|
}
|
|
120
152
|
if (review && review.status === "rejected") {
|
|
121
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
"
|
|
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)
|
|
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
|
-
|
|
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
|
|