claude-plan-review 0.1.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/LICENSE +21 -0
- package/README.md +119 -0
- package/package.json +45 -0
- package/src/cli.js +141 -0
- package/src/hook.js +135 -0
- package/src/paths.js +25 -0
- package/src/server.js +187 -0
- package/src/store.js +237 -0
- package/src/ui/app.js +486 -0
- package/src/ui/index.html +94 -0
- package/src/ui/style.css +179 -0
package/src/store.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { basename, join } from "node:path";
|
|
11
|
+
import { PROJECTS_DIR, REVIEWS_DIR, projectDir, projectKey } from "./paths.js";
|
|
12
|
+
|
|
13
|
+
// ---------- helpers ----------
|
|
14
|
+
const enc = (o) => JSON.stringify(o, null, 2);
|
|
15
|
+
const now = () => new Date().toISOString();
|
|
16
|
+
const sha = (s) => createHash("sha256").update(s).digest("hex");
|
|
17
|
+
const pad = (n) => String(n).padStart(4, "0");
|
|
18
|
+
|
|
19
|
+
function readJSON(path, fallback) {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
22
|
+
} catch {
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function ensureDirs(key) {
|
|
28
|
+
for (const d of ["versions", "comments"]) {
|
|
29
|
+
mkdirSync(join(projectDir(key), d), { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
mkdirSync(REVIEWS_DIR, { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const metaPath = (key) => join(projectDir(key), "meta.json");
|
|
35
|
+
const versionMd = (key, n) => join(projectDir(key), "versions", `${pad(n)}.md`);
|
|
36
|
+
const versionMeta = (key, n) => join(projectDir(key), "versions", `${pad(n)}.json`);
|
|
37
|
+
const commentsPath = (key, n) => join(projectDir(key), "comments", `${pad(n)}.json`);
|
|
38
|
+
const reviewPath = (id) => join(REVIEWS_DIR, `${id}.json`);
|
|
39
|
+
|
|
40
|
+
// ---------- projects / versions ----------
|
|
41
|
+
export function getProjectMeta(key) {
|
|
42
|
+
return existsSync(metaPath(key)) ? readJSON(metaPath(key), null) : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Record a freshly-presented plan. De-dupes by content hash: an identical
|
|
47
|
+
* re-presentation reuses the existing version (and keeps its comments),
|
|
48
|
+
* otherwise a new immutable version is written. Always creates a new review.
|
|
49
|
+
*/
|
|
50
|
+
export function recordPlan(opts) {
|
|
51
|
+
const key = projectKey(opts.cwd);
|
|
52
|
+
ensureDirs(key);
|
|
53
|
+
const hash = sha(opts.plan);
|
|
54
|
+
|
|
55
|
+
let meta = getProjectMeta(key);
|
|
56
|
+
let version;
|
|
57
|
+
|
|
58
|
+
if (meta && meta.latestHash === hash) {
|
|
59
|
+
// identical plan re-presented → reuse latest version
|
|
60
|
+
version = meta.currentVersion;
|
|
61
|
+
meta.updatedAt = now();
|
|
62
|
+
} else {
|
|
63
|
+
version = (meta?.currentVersion ?? 0) + 1;
|
|
64
|
+
writeFileSync(versionMd(key, version), opts.plan);
|
|
65
|
+
writeFileSync(
|
|
66
|
+
versionMeta(key, version),
|
|
67
|
+
enc({
|
|
68
|
+
n: version,
|
|
69
|
+
createdAt: now(),
|
|
70
|
+
sessionId: opts.sessionId,
|
|
71
|
+
toolUseId: opts.toolUseId,
|
|
72
|
+
hash,
|
|
73
|
+
planFilePath: opts.planFilePath,
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
writeFileSync(commentsPath(key, version), enc([]));
|
|
77
|
+
meta = {
|
|
78
|
+
key,
|
|
79
|
+
cwd: opts.cwd,
|
|
80
|
+
name: basename(opts.cwd) || key,
|
|
81
|
+
createdAt: meta?.createdAt ?? now(),
|
|
82
|
+
updatedAt: now(),
|
|
83
|
+
currentVersion: version,
|
|
84
|
+
latestHash: hash,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
writeFileSync(metaPath(key), enc(meta));
|
|
88
|
+
|
|
89
|
+
const reviewId = randomBytes(8).toString("hex");
|
|
90
|
+
writeFileSync(
|
|
91
|
+
reviewPath(reviewId),
|
|
92
|
+
enc({
|
|
93
|
+
id: reviewId,
|
|
94
|
+
projectKey: key,
|
|
95
|
+
version,
|
|
96
|
+
sessionId: opts.sessionId,
|
|
97
|
+
toolUseId: opts.toolUseId,
|
|
98
|
+
status: "pending",
|
|
99
|
+
createdAt: now(),
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
return { key, version, reviewId };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function listProjects() {
|
|
106
|
+
if (!existsSync(PROJECTS_DIR)) return [];
|
|
107
|
+
const pendingByKey = new Map();
|
|
108
|
+
for (const r of listReviews()) {
|
|
109
|
+
if (r.status === "pending")
|
|
110
|
+
pendingByKey.set(r.projectKey, (pendingByKey.get(r.projectKey) ?? 0) + 1);
|
|
111
|
+
}
|
|
112
|
+
return readdirSync(PROJECTS_DIR)
|
|
113
|
+
.map((key) => getProjectMeta(key))
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.map((m) => ({
|
|
116
|
+
...m,
|
|
117
|
+
versions: listVersions(m.key).length,
|
|
118
|
+
pending: pendingByKey.get(m.key) ?? 0,
|
|
119
|
+
}))
|
|
120
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function listVersions(key) {
|
|
124
|
+
const dir = join(projectDir(key), "versions");
|
|
125
|
+
if (!existsSync(dir)) return [];
|
|
126
|
+
return readdirSync(dir)
|
|
127
|
+
.filter((f) => f.endsWith(".json"))
|
|
128
|
+
.map((f) => readJSON(join(dir, f), null))
|
|
129
|
+
.filter(Boolean)
|
|
130
|
+
.sort((a, b) => a.n - b.n);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function getVersion(key, n) {
|
|
134
|
+
if (!existsSync(versionMd(key, n))) return null;
|
|
135
|
+
return {
|
|
136
|
+
markdown: readFileSync(versionMd(key, n), "utf8"),
|
|
137
|
+
meta: readJSON(versionMeta(key, n), { n, createdAt: "", hash: "" }),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------- comments ----------
|
|
142
|
+
export function listComments(key, n) {
|
|
143
|
+
return readJSON(commentsPath(key, n), []);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function addComment(key, n, c) {
|
|
147
|
+
const comments = listComments(key, n);
|
|
148
|
+
const comment = {
|
|
149
|
+
id: randomBytes(6).toString("hex"),
|
|
150
|
+
line: c.line,
|
|
151
|
+
lineEnd: c.line == null ? null : (c.lineEnd ?? c.line),
|
|
152
|
+
body: c.body,
|
|
153
|
+
author: c.author || "me",
|
|
154
|
+
createdAt: now(),
|
|
155
|
+
};
|
|
156
|
+
comments.push(comment);
|
|
157
|
+
writeFileSync(commentsPath(key, n), enc(comments));
|
|
158
|
+
return comment;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function deleteComment(key, n, id) {
|
|
162
|
+
const comments = listComments(key, n);
|
|
163
|
+
const next = comments.filter((c) => c.id !== id);
|
|
164
|
+
if (next.length === comments.length) return false;
|
|
165
|
+
writeFileSync(commentsPath(key, n), enc(next));
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------- reviews ----------
|
|
170
|
+
export function listReviews() {
|
|
171
|
+
if (!existsSync(REVIEWS_DIR)) return [];
|
|
172
|
+
return readdirSync(REVIEWS_DIR)
|
|
173
|
+
.filter((f) => f.endsWith(".json"))
|
|
174
|
+
.map((f) => readJSON(join(REVIEWS_DIR, f), null))
|
|
175
|
+
.filter(Boolean);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function getReview(id) {
|
|
179
|
+
return existsSync(reviewPath(id)) ? readJSON(reviewPath(id), null) : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Resolve a review; for reject, compile its version's comments into the reason. */
|
|
183
|
+
export function resolveReview(id, decision) {
|
|
184
|
+
const review = getReview(id);
|
|
185
|
+
if (!review) return null;
|
|
186
|
+
review.status = decision === "approve" ? "approved" : "rejected";
|
|
187
|
+
review.resolvedAt = now();
|
|
188
|
+
if (decision === "reject") review.reason = compileReason(review.projectKey, review.version);
|
|
189
|
+
writeFileSync(reviewPath(id), enc(review));
|
|
190
|
+
return review;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function compileReason(key, n) {
|
|
194
|
+
const comments = listComments(key, n);
|
|
195
|
+
const lineComments = comments.filter((c) => c.line != null).sort((a, b) => a.line - b.line);
|
|
196
|
+
const general = comments.filter((c) => c.line == null);
|
|
197
|
+
const md = getVersion(key, n)?.markdown ?? "";
|
|
198
|
+
const lines = md.split("\n");
|
|
199
|
+
|
|
200
|
+
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.",
|
|
203
|
+
];
|
|
204
|
+
if (lineComments.length) {
|
|
205
|
+
parts.push("\nLine comments:");
|
|
206
|
+
for (const c of lineComments) {
|
|
207
|
+
const s = c.line;
|
|
208
|
+
const e = c.lineEnd ?? s;
|
|
209
|
+
const label = e !== s ? `lines ${s}-${e}` : `line ${s}`;
|
|
210
|
+
const snippet = lines
|
|
211
|
+
.slice(s - 1, e)
|
|
212
|
+
.map((l) => l.trim())
|
|
213
|
+
.filter(Boolean)
|
|
214
|
+
.slice(0, 3)
|
|
215
|
+
.join(" / ");
|
|
216
|
+
parts.push(`- [${label}] "${snippet}"\n → ${c.body}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (general.length) {
|
|
220
|
+
parts.push("\nGeneral comments:");
|
|
221
|
+
for (const c of general) parts.push(`- ${c.body}`);
|
|
222
|
+
}
|
|
223
|
+
if (!lineComments.length && !general.length) {
|
|
224
|
+
parts.push("\n(No specific comments were left — please reconsider and improve the plan.)");
|
|
225
|
+
}
|
|
226
|
+
return parts.join("\n");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------- maintenance ----------
|
|
230
|
+
export function pruneResolvedReviews(maxAgeMs = 1000 * 60 * 60 * 24 * 7) {
|
|
231
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
232
|
+
for (const r of listReviews()) {
|
|
233
|
+
if (r.status !== "pending" && r.resolvedAt && Date.parse(r.resolvedAt) < cutoff) {
|
|
234
|
+
rmSync(reviewPath(r.id), { force: true });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|