@fullstackhouse/cerber 0.3.0 → 0.6.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.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Which reviews this process is running right now.
3
+ *
4
+ * `cerber serve --daemon` starts reviews from a timer while the cockpit can
5
+ * start them from a click. Both write the same artifact file, so two runs for
6
+ * one PR would race and the loser's work would vanish. The daemon's `pool`
7
+ * only bounds its own concurrency; this is what the two paths share.
8
+ *
9
+ * In-process only: a `cerber review` in another terminal is invisible here.
10
+ * That's the pre-existing situation and the artifact write is atomic, so the
11
+ * cost is a wasted run, not a corrupt file.
12
+ */
13
+ const running = new Set();
14
+ export class ReviewInProgressError extends Error {
15
+ id;
16
+ constructor(id) {
17
+ super(`a review of ${id} is already running`);
18
+ this.id = id;
19
+ this.name = "ReviewInProgressError";
20
+ }
21
+ }
22
+ /** Claim the slot for an artifact id. Throws if it is already claimed. */
23
+ export function beginReview(id) {
24
+ if (running.has(id))
25
+ throw new ReviewInProgressError(id);
26
+ running.add(id);
27
+ }
28
+ export function endReview(id) {
29
+ running.delete(id);
30
+ }
31
+ export function isReviewRunning(id) {
32
+ return running.has(id);
33
+ }
@@ -8,12 +8,12 @@ export function buildReviewPrompt(pr, diff) {
8
8
  Respond with ONLY a JSON object (no markdown fences, no prose before or after) matching exactly this shape:
9
9
 
10
10
  {
11
- "summary": "markdown — what this PR actually does and why, written top-down: problem first, then the solution, then notable technical details. 1-3 paragraphs.",
11
+ "summary": "markdown — what this PR actually does and why, written top-down: problem first in plain language, then the solution, then notable technical details. A reader should be able to stop after any paragraph with a correct, just less detailed, picture. 1-3 paragraphs.",
12
12
  "chapters": [
13
13
  {
14
14
  "id": "short-kebab-id",
15
15
  "title": "Human title of this logical group of changes",
16
- "explanation": "markdown — what this group changes and why, what to pay attention to",
16
+ "explanation": "markdown — 2-3 plain sentences: what this group changes, why, and what to look at closely",
17
17
  "files": ["path/to/file.ts", "path/other.ts"]
18
18
  }
19
19
  ],
@@ -21,7 +21,7 @@ Respond with ONLY a JSON object (no markdown fences, no prose before or after) m
21
21
  {
22
22
  "path": "path/to/file.ts",
23
23
  "line": 42,
24
- "body": "markdown — the review comment. Concrete, actionable, kind.",
24
+ "body": "markdown — the review comment. First sentence states the problem plainly; then the evidence or fix. Concrete, actionable, kind.",
25
25
  "chapterId": "short-kebab-id"
26
26
  }
27
27
  ],
@@ -32,6 +32,13 @@ Respond with ONLY a JSON object (no markdown fences, no prose before or after) m
32
32
  }
33
33
  }
34
34
 
35
+ Writing style (applies to every markdown field):
36
+ - Write like one human explaining to another: plain words, short sentences, no jargon. Someone who has NOT read the diff should follow every sentence.
37
+ - Lead with the point. State the claim first, support it after. No hedging chains, no em-dash clause stacks.
38
+ - Keep comments to ~3 sentences unless a code suggestion needs more. Length must buy clarity, not cover uncertainty — if you are unsure, say so in one plain sentence.
39
+ - Do NOT imitate the PR description's writing style. Keep this plain register even when the PR itself is dense or writerly.
40
+ - Describe the effect a person would see before the mechanism behind it. Not: "A stray closing code fence with prose on the same line left the block open, rendering the conditions below as code." But: "Also fixes a markdown typo: a code block was never closed, so everything after it displayed as code."
41
+
35
42
  Rules:
36
43
  - Group ALL changed files into chapters that tell the story of the PR (schema first, then logic, then tests, etc). Every changed file must appear in exactly one chapter.
37
44
  - "line" is the line number in the NEW version of the file (the "+" side of the diff). Use null for file-level comments. Only reference lines that appear in the diff.
@@ -1,8 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { AiReviewSchema, SCHEMA_VERSION, artifactId, } from "../core/artifact.js";
3
3
  import { fetchPrDiff, fetchPrInfo } from "../core/gh.js";
4
+ import { carryOverComments } from "../core/refresh.js";
4
5
  import { loadArtifact, saveArtifact } from "../core/state.js";
5
6
  import { extractJson, runClaude } from "./claude.js";
7
+ import { beginReview, endReview } from "./inflight.js";
6
8
  import { buildReviewPrompt, buildRetryPrompt } from "./prompt.js";
7
9
  /** Statuses that a fresh artifact can keep without a re-run. */
8
10
  const FRESH_STATUSES = new Set(["ready", "reviewed", "sent", "skipped"]);
@@ -11,12 +13,21 @@ const FRESH_STATUSES = new Set(["ready", "reviewed", "sent", "skipped"]);
11
13
  * Everything stays local — nothing is ever written to GitHub.
12
14
  */
13
15
  export async function reviewPr(ref, opts = {}) {
16
+ beginReview(artifactId(ref));
17
+ try {
18
+ return await runReview(ref, opts);
19
+ }
20
+ finally {
21
+ endReview(artifactId(ref));
22
+ }
23
+ }
24
+ async function runReview(ref, opts) {
14
25
  const log = opts.onProgress ?? (() => { });
15
26
  const now = () => new Date().toISOString();
16
27
  log(`Fetching ${ref.owner}/${ref.repo}#${ref.number}…`);
17
28
  const pr = await fetchPrInfo(ref);
29
+ const existing = await loadArtifact(artifactId(pr));
18
30
  if (!opts.force) {
19
- const existing = await loadArtifact(artifactId(pr));
20
31
  if (existing &&
21
32
  FRESH_STATUSES.has(existing.status) &&
22
33
  existing.pr.headSha !== "" &&
@@ -30,7 +41,7 @@ export async function reviewPr(ref, opts = {}) {
30
41
  schemaVersion: SCHEMA_VERSION,
31
42
  id: artifactId(pr),
32
43
  status: "running",
33
- createdAt: now(),
44
+ createdAt: existing?.createdAt ?? now(),
34
45
  updatedAt: now(),
35
46
  pr,
36
47
  diff,
@@ -40,6 +51,8 @@ export async function reviewPr(ref, opts = {}) {
40
51
  verdict: null,
41
52
  run: { model: opts.model ?? null, startedAt: now(), finishedAt: null, costUsd: null, error: null },
42
53
  sent: null,
54
+ refresh: null,
55
+ calibration: null,
43
56
  };
44
57
  await saveArtifact(artifact);
45
58
  const { prompt, truncated } = buildReviewPrompt(pr, diff);
@@ -70,6 +83,9 @@ export async function reviewPr(ref, opts = {}) {
70
83
  chapterId: c.chapterId ?? null,
71
84
  origin: "ai",
72
85
  status: "draft",
86
+ editedByUser: false,
87
+ originalLine: null,
88
+ drifted: false,
73
89
  })),
74
90
  verdict: review.ai.verdict,
75
91
  run: {
@@ -80,6 +96,16 @@ export async function reviewPr(ref, opts = {}) {
80
96
  error: null,
81
97
  },
82
98
  };
99
+ // A re-review regenerates the AI's comments, but the human's own comments
100
+ // and their rewrites of AI ones are work the run must not throw away.
101
+ if (existing) {
102
+ const { comments, carried, drifted } = carryOverComments(existing, artifact);
103
+ if (carried > 0) {
104
+ artifact = { ...artifact, comments };
105
+ log(`Carried over ${carried} comment(s) you wrote or edited` +
106
+ (drifted > 0 ? `; ${drifted} no longer match the diff and will post in the body.` : "."));
107
+ }
108
+ }
83
109
  }
84
110
  catch (err) {
85
111
  artifact = {
@@ -0,0 +1,142 @@
1
+ import { artifactKey } from "../core/artifact.js";
2
+ import { evaluateAutoSend } from "../core/autosend.js";
3
+ import { searchAwaitingMe, submitReview } from "../core/gh.js";
4
+ import { buildReviewPayload, computeCalibration } from "../core/send.js";
5
+ import { appendAutoSendLog, updateArtifactByKey } from "../core/state.js";
6
+ import { ReviewInProgressError } from "../runner/inflight.js";
7
+ import { pool, reviewPr } from "../runner/review.js";
8
+ /**
9
+ * Poll GitHub for PRs awaiting the user's review and keep their artifacts
10
+ * warm. Reviews are read-only; the daemon NEVER sends anything.
11
+ */
12
+ export function startDaemon(opts) {
13
+ let timer = null;
14
+ let stopped = false;
15
+ const status = {
16
+ enabled: true,
17
+ polling: false,
18
+ repos: opts.repos,
19
+ intervalMs: opts.intervalMs,
20
+ polls: 0,
21
+ errors: 0,
22
+ lastPollAt: null,
23
+ nextPollAt: new Date(Date.now()).toISOString(),
24
+ lastSummary: null,
25
+ autoSend: opts.autoSend,
26
+ autoSendThreshold: opts.autoSendThreshold,
27
+ autoSent: 0,
28
+ autoSendCandidates: 0,
29
+ };
30
+ const log = (m) => console.log(`[daemon] ${m}`);
31
+ async function poll() {
32
+ if (status.polling)
33
+ return;
34
+ status.polling = true;
35
+ status.lastPollAt = new Date().toISOString();
36
+ try {
37
+ const filters = opts.repos.length > 0 ? opts.repos : [undefined];
38
+ const seen = new Set();
39
+ const refs = (await Promise.all(filters.map((repo) => searchAwaitingMe(repo))))
40
+ .flat()
41
+ .filter((r) => {
42
+ const id = `${r.owner}/${r.repo}#${r.number}`;
43
+ if (seen.has(id))
44
+ return false;
45
+ seen.add(id);
46
+ return true;
47
+ });
48
+ let reviewed = 0;
49
+ let skipped = 0;
50
+ let failed = 0;
51
+ await pool(refs, opts.parallel, async (ref) => {
52
+ const label = `${ref.owner}/${ref.repo}#${ref.number}`;
53
+ try {
54
+ const result = await reviewPr(ref, {
55
+ model: opts.model,
56
+ onProgress: (m) => log(`[${label}] ${m}`),
57
+ });
58
+ if (result.skipped)
59
+ skipped++;
60
+ else {
61
+ reviewed++;
62
+ await handleAutoSend(result.artifact);
63
+ }
64
+ }
65
+ catch (err) {
66
+ if (err instanceof ReviewInProgressError) {
67
+ // The cockpit (or an earlier, slower poll) is already on it.
68
+ skipped++;
69
+ return;
70
+ }
71
+ failed++;
72
+ status.errors++;
73
+ log(`[${label}] failed: ${err instanceof Error ? err.message : err}`);
74
+ }
75
+ });
76
+ status.lastSummary = `${refs.length} awaiting; ${reviewed} reviewed, ${skipped} up to date${failed > 0 ? `, ${failed} failed` : ""}`;
77
+ log(status.lastSummary);
78
+ }
79
+ catch (err) {
80
+ status.errors++;
81
+ status.lastSummary = `poll failed: ${err instanceof Error ? err.message : err}`;
82
+ log(status.lastSummary);
83
+ }
84
+ finally {
85
+ status.polls++;
86
+ status.polling = false;
87
+ if (!stopped) {
88
+ status.nextPollAt = new Date(Date.now() + opts.intervalMs).toISOString();
89
+ timer = setTimeout(poll, opts.intervalMs);
90
+ }
91
+ }
92
+ }
93
+ async function handleAutoSend(artifact) {
94
+ const decision = evaluateAutoSend(artifact, opts.autoSendThreshold);
95
+ if (decision.eligible)
96
+ status.autoSendCandidates++;
97
+ const entryBase = {
98
+ at: new Date().toISOString(),
99
+ id: artifact.id,
100
+ recommendation: artifact.verdict?.recommendation ?? null,
101
+ confidence: artifact.verdict?.confidence ?? null,
102
+ mode: opts.autoSend,
103
+ };
104
+ if (!decision.eligible) {
105
+ await appendAutoSendLog({ ...entryBase, decision: decision.reason, sent: false });
106
+ return;
107
+ }
108
+ if (opts.autoSend === "shadow") {
109
+ log(`[${artifact.id}] WOULD auto-send: ${decision.reason} (shadow mode — nothing sent)`);
110
+ await appendAutoSendLog({ ...entryBase, decision: `would send: ${decision.reason}`, sent: false });
111
+ return;
112
+ }
113
+ try {
114
+ const payload = buildReviewPayload(artifact, "APPROVE");
115
+ const { url } = await submitReview({ owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number }, { event: payload.event, body: payload.body, comments: payload.comments, commitId: payload.commitId });
116
+ await updateArtifactByKey(artifactKey(artifact.id), (a) => ({
117
+ ...a,
118
+ status: "sent",
119
+ sent: { at: new Date().toISOString(), event: "APPROVE", url, auto: true },
120
+ calibration: computeCalibration(a, "APPROVE"),
121
+ }));
122
+ status.autoSent++;
123
+ log(`[${artifact.id}] AUTO-SENT approve (${decision.reason})${url ? ` — ${url}` : ""}`);
124
+ await appendAutoSendLog({ ...entryBase, decision: `auto-sent: ${decision.reason}`, sent: true });
125
+ }
126
+ catch (err) {
127
+ status.errors++;
128
+ const message = err instanceof Error ? err.message : String(err);
129
+ log(`[${artifact.id}] auto-send failed: ${message}`);
130
+ await appendAutoSendLog({ ...entryBase, decision: `auto-send failed: ${message}`, sent: false });
131
+ }
132
+ }
133
+ void poll();
134
+ return {
135
+ status: () => ({ ...status }),
136
+ stop: () => {
137
+ stopped = true;
138
+ if (timer)
139
+ clearTimeout(timer);
140
+ },
141
+ };
142
+ }
@@ -1,18 +1,39 @@
1
1
  import { serve } from "@hono/node-server";
2
2
  import { serveStatic } from "@hono/node-server/serve-static";
3
3
  import { Hono } from "hono";
4
+ import { getCookie, setCookie } from "hono/cookie";
4
5
  import { promises as fs } from "node:fs";
5
6
  import path from "node:path";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import { randomUUID } from "node:crypto";
8
9
  import { ArtifactStatusSchema, VerdictSchema, artifactKey } from "../core/artifact.js";
9
10
  import { toMarkdown } from "../core/export.js";
10
- import { submitReview } from "../core/gh.js";
11
- import { buildReviewPayload } from "../core/send.js";
12
- import { listArtifacts, loadArtifactByKey, updateArtifactByKey } from "../core/state.js";
11
+ import { fetchPrDiff, fetchPrInfo, submitReview } from "../core/gh.js";
12
+ import { refreshArtifact } from "../core/refresh.js";
13
+ import { buildReviewPayload, computeCalibration } from "../core/send.js";
14
+ import { listArtifacts, loadArtifactByKey, reconcileRunning, updateArtifactByKey, } from "../core/state.js";
15
+ import { isReviewRunning } from "../runner/inflight.js";
16
+ import { reviewPr } from "../runner/review.js";
13
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
- export async function startServer(opts) {
18
+ export async function buildApp(opts) {
15
19
  const app = new Hono();
20
+ if (opts.token) {
21
+ const token = opts.token;
22
+ app.use("*", async (c, next) => {
23
+ const query = c.req.query("token");
24
+ const cookie = getCookie(c, "cerber_token");
25
+ const header = c.req.header("authorization");
26
+ if (query === token || cookie === token || header === `Bearer ${token}`) {
27
+ if (query === token && cookie !== token) {
28
+ // First visit via ?token=… — set the cookie so the SPA's asset and API requests pass.
29
+ setCookie(c, "cerber_token", token, { httpOnly: true, sameSite: "Strict", path: "/" });
30
+ }
31
+ return next();
32
+ }
33
+ return c.text("unauthorized — pass ?token=… or Authorization: Bearer …\n", 401);
34
+ });
35
+ }
36
+ app.get("/api/daemon", (c) => c.json(opts.daemon ? opts.daemon.status() : { enabled: false }));
16
37
  app.get("/api/reviews", async (c) => {
17
38
  const artifacts = await listArtifacts();
18
39
  return c.json(artifacts.map((a) => ({
@@ -74,6 +95,9 @@ export async function startServer(opts) {
74
95
  ...(body.status !== undefined
75
96
  ? { status: body.status }
76
97
  : {}),
98
+ ...(body.body !== undefined && body.body !== cm.body && cm.origin === "ai"
99
+ ? { editedByUser: true }
100
+ : {}),
77
101
  }
78
102
  : cm),
79
103
  }));
@@ -95,6 +119,9 @@ export async function startServer(opts) {
95
119
  chapterId: body.chapterId ?? null,
96
120
  origin: "user",
97
121
  status: "draft",
122
+ editedByUser: false,
123
+ originalLine: null,
124
+ drifted: false,
98
125
  },
99
126
  ],
100
127
  }));
@@ -112,6 +139,76 @@ export async function startServer(opts) {
112
139
  return c.json({ error: "not found" }, 404);
113
140
  return c.json(updated);
114
141
  });
142
+ // ---- Freshness: pull a review forward onto the PR's current head ----
143
+ // Read-only against GitHub (pr view + pr diff); writes only the local artifact.
144
+ app.post("/api/reviews/:key/refresh", async (c) => {
145
+ const artifact = await loadArtifactByKey(c.req.param("key"));
146
+ if (!artifact)
147
+ return c.json({ error: "not found" }, 404);
148
+ const ref = { owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number };
149
+ let pr;
150
+ try {
151
+ pr = await fetchPrInfo(ref);
152
+ }
153
+ catch (err) {
154
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 502);
155
+ }
156
+ const stale = artifact.pr.headSha !== "" && artifact.pr.headSha !== pr.headSha;
157
+ // A sent review is a record of what was posted — never rewrite it. A running
158
+ // one is mid-run and would be overwritten from under the runner.
159
+ if (!stale || artifact.sent || artifact.status === "running") {
160
+ return c.json({ stale, changed: false, prState: pr.state, artifact });
161
+ }
162
+ try {
163
+ const diff = await fetchPrDiff(ref);
164
+ const result = refreshArtifact(artifact, pr, diff);
165
+ const saved = await updateArtifactByKey(c.req.param("key"), () => result.artifact);
166
+ return c.json({
167
+ stale: true,
168
+ changed: result.changed,
169
+ prState: pr.state,
170
+ moved: result.moved,
171
+ drifted: result.drifted,
172
+ artifact: saved,
173
+ });
174
+ }
175
+ catch (err) {
176
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 502);
177
+ }
178
+ });
179
+ // ---- Re-review: start an AI run for a PR that has moved on ----
180
+ app.post("/api/reviews/:key/rerun", async (c) => {
181
+ const key = c.req.param("key");
182
+ const artifact = await loadArtifactByKey(key);
183
+ if (!artifact)
184
+ return c.json({ error: "not found" }, 404);
185
+ if (artifact.sent)
186
+ return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
187
+ if (isReviewRunning(artifact.id)) {
188
+ return c.json({ error: "a review of this PR is already running" }, 409);
189
+ }
190
+ const ref = { owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number };
191
+ // Mark it running before responding, so the cockpit's next poll can't catch
192
+ // the old "ready" status and conclude the run already finished.
193
+ const running = await updateArtifactByKey(key, (a) => ({
194
+ ...a,
195
+ status: "running",
196
+ run: { model: null, startedAt: new Date().toISOString(), finishedAt: null, costUsd: null, error: null },
197
+ }));
198
+ // Minutes-long: run it detached and let the cockpit poll the artifact.
199
+ void reviewPr(ref, { force: true, onProgress: (m) => console.log(`[rerun ${artifact.id}] ${m}`) })
200
+ .catch(async (err) => {
201
+ const message = err instanceof Error ? err.message : String(err);
202
+ console.error(`[rerun ${artifact.id}] failed: ${message}`);
203
+ // The runner marks the artifact failed once it owns it; a failure before
204
+ // that (fetching the PR, or a run already in flight) would otherwise
205
+ // leave it stuck at "running" with nothing to clear it.
206
+ await updateArtifactByKey(key, (a) => a.status === "running"
207
+ ? { ...a, status: "failed", run: a.run ? { ...a.run, error: message } : null }
208
+ : a).catch(() => { });
209
+ });
210
+ return c.json(running, 202);
211
+ });
115
212
  // ---- Export (local file download) ----
116
213
  app.get("/api/reviews/:key/export", async (c) => {
117
214
  const artifact = await loadArtifactByKey(c.req.param("key"));
@@ -138,11 +235,12 @@ export async function startServer(opts) {
138
235
  }
139
236
  const payload = buildReviewPayload(artifact, event);
140
237
  try {
141
- const { url } = await submitReview({ owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number }, { event: payload.event, body: payload.body, comments: payload.comments });
238
+ const { url } = await submitReview({ owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number }, { event: payload.event, body: payload.body, comments: payload.comments, commitId: payload.commitId });
142
239
  const updated = await updateArtifactByKey(c.req.param("key"), (a) => ({
143
240
  ...a,
144
241
  status: "sent",
145
- sent: { at: new Date().toISOString(), event: payload.event, url },
242
+ sent: { at: new Date().toISOString(), event: payload.event, url, auto: false },
243
+ calibration: computeCalibration(a, payload.event),
146
244
  }));
147
245
  return c.json(updated);
148
246
  }
@@ -172,7 +270,18 @@ export async function startServer(opts) {
172
270
  else {
173
271
  app.get("/", (c) => c.text("cerber API is running, but the web cockpit is not built.\nRun `pnpm build` in the cerber repo, or use the API: GET /api/reviews\n"));
174
272
  }
273
+ return app;
274
+ }
275
+ export async function startServer(opts) {
276
+ const cleared = await reconcileRunning();
277
+ if (cleared > 0) {
278
+ console.log(`Cleared ${cleared} review(s) left mid-run by a previous process — re-review to retry.`);
279
+ }
280
+ const app = await buildApp(opts);
175
281
  serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
176
- console.log(`cerber cockpit: http://${opts.host}:${info.port}`);
282
+ const tokenHint = opts.token ? `/?token=${opts.token}` : "";
283
+ console.log(`cerber cockpit: http://${opts.host}:${info.port}${tokenHint}`);
284
+ if (opts.daemon)
285
+ console.log("daemon: polling for PRs awaiting your review");
177
286
  });
178
287
  }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.3.0",
3
+ "version": "0.6.0",
4
4
  "description": "AI code-review cockpit: Claude reviews your PRs into local artifacts; nothing reaches GitHub until you say so.",
5
5
  "license": "MIT",
6
6
  "repository": "github:fullstackhouse/cerber",
7
7
  "type": "module",
8
+ "packageManager": "pnpm@10.33.2",
8
9
  "bin": {
9
10
  "cerber": "dist/cli/index.js"
10
11
  },
@@ -36,6 +37,8 @@
36
37
  "@types/react": "^19.0.4",
37
38
  "@types/react-dom": "^19.0.2",
38
39
  "@vitejs/plugin-react": "^4.3.4",
40
+ "dompurify": "^3.4.13",
41
+ "marked": "^18.0.10",
39
42
  "react": "^19.0.0",
40
43
  "react-dom": "^19.0.0",
41
44
  "tsx": "^4.19.2",