@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.
package/README.md CHANGED
@@ -5,8 +5,8 @@ a summary, a chaptered walkthrough of the changes, draft inline comments, and a
5
5
  verdict with a confidence score. You go through it in a local web cockpit.
6
6
 
7
7
  **Nothing reaches GitHub until you explicitly say so.** Cerber's only GitHub
8
- writes happen when you hit Send (coming in a later phase) — reviewing is 100%
9
- local and read-only.
8
+ write is the Send button (plus opt-in daemon auto-send you turn on yourself) —
9
+ reviewing is 100% local and read-only.
10
10
 
11
11
  Named after Cerberus, the gatekeeper — and a sibling of
12
12
  [cezar](https://github.com/open-mercato/cezar): cezar runs coding agents,
@@ -48,20 +48,60 @@ Each review is a plain JSON artifact in `~/.cerber/reviews/` (override with
48
48
  The cockpit (`cerber serve`) renders the queue and the per-PR walkthrough with
49
49
  diffs. Artifacts are plain JSON you can `cat`, edit, or pipe into anything.
50
50
 
51
+ ### When the PR moves under you
52
+
53
+ A review is written against one commit, but authors keep pushing. Opening a
54
+ review checks the PR's head and pulls the review forward: comments follow their
55
+ code to its new line numbers, and any whose code is gone are flagged and post
56
+ in the review body instead of inline — GitHub rejects an entire review over one
57
+ comment on a line that is no longer in the diff. Sends carry the reviewed
58
+ commit's SHA, so inline comments land where they were written.
59
+
60
+ The AI's summary and verdict still describe the commit that was reviewed. To
61
+ get its opinion of the new code, hit **Re-review at the new head** in the
62
+ cockpit (or `cerber review <pr> --force`) — comments you wrote or rewrote are
63
+ carried into the fresh review.
64
+
51
65
  ## Status / roadmap
52
66
 
53
- Early. Current phase: review + read-only cockpit.
67
+ Early, but all six planned phases have shipped.
54
68
 
55
69
  1. ✅ `cerber review <pr>` + artifact schema + cockpit (read-only)
56
70
  2. ✅ Comment editing in the cockpit (edit/approve/drop/add), verdict override,
57
- gated **Send** (double-confirm; posts the review via your `gh` — the only
58
- GitHub write in the codebase) + `cerber export` to markdown
71
+ gated **Send** (one deliberate click; posts the review via your `gh` — the
72
+ only GitHub write in the codebase) + `cerber export` to markdown
59
73
  3. ✅ `--awaiting-me` discovery, parallel runs (`--parallel`, default 3),
60
74
  freshness skip (same head SHA → no re-run; `--force` overrides), queue stats
61
- 4. Daemon/VPS mode: poll for PRs awaiting your review, review them
62
- automatically, cockpit always warm
63
- 5. Confidence calibration → shadow-mode auto-send → opt-in auto-send above a
64
- threshold
75
+ 4. ✅ Daemon/VPS mode: `cerber serve --daemon` polls for PRs awaiting your
76
+ review and reviews them automatically (read-only); token auth for
77
+ non-localhost binds
78
+ 5. ✅ Confidence calibration (`cerber stats`), shadow-mode auto-send (default
79
+ in daemon: logs what would be sent), opt-in `--auto-send` (approve-only,
80
+ `--auto-send-threshold`, default 90%)
81
+ 6. ✅ Reviews keep up with the PR: opening one re-anchors its comments onto new
82
+ commits, flags the ones whose code is gone, and offers a re-review that
83
+ keeps everything you wrote
84
+
85
+ ## Running on a VPS
86
+
87
+ ```bash
88
+ cerber serve --daemon --host 0.0.0.0 --token "$(openssl rand -hex 16)" \
89
+ --repo you/repo-a --repo you/repo-b --interval 10
90
+ ```
91
+
92
+ The daemon polls GitHub, reviews anything new (skipping PRs whose artifact
93
+ already matches the head SHA), and the cockpit is always warm — open it any
94
+ minute and see pending/ready/sent reviews. Auth: `?token=…` once in the
95
+ browser (sets a cookie) or `Authorization: Bearer …`. Binding a non-localhost
96
+ host without a token is refused. By default the daemon never sends reviews — Send stays a human click.
97
+
98
+ **Auto-send** (opt-in): add `--auto-send --auto-send-threshold 90` and the
99
+ daemon will submit APPROVE verdicts at/above the threshold as you. It never
100
+ auto-sends COMMENT or REQUEST_CHANGES. Before enabling, run the default
101
+ shadow mode for a while: every would-send decision lands in
102
+ `~/.cerber/autosend.ndjson`, and `cerber stats` shows how often the AI's
103
+ verdicts and comments survive your review — enable auto-send when its 90%
104
+ actually means 90%.
65
105
 
66
106
  ## Development
67
107
 
package/dist/cli/index.js CHANGED
@@ -4,15 +4,16 @@ import readline from "node:readline/promises";
4
4
  import { artifactId, artifactKey } from "../core/artifact.js";
5
5
  import { toMarkdown } from "../core/export.js";
6
6
  import { parsePrRef, searchAwaitingMe, submitReview } from "../core/gh.js";
7
- import { buildReviewPayload, eventForRecommendation } from "../core/send.js";
8
- import { cerberHome, listArtifacts, loadArtifact, updateArtifactByKey } from "../core/state.js";
7
+ import { buildReviewPayload, computeCalibration, eventForRecommendation } from "../core/send.js";
8
+ import { cerberHome, listArtifacts, loadArtifact, readAutoSendLog, updateArtifactByKey, } from "../core/state.js";
9
9
  import { pool, reviewPr } from "../runner/review.js";
10
+ import { startDaemon } from "../server/daemon.js";
10
11
  import { startServer } from "../server/index.js";
11
12
  const program = new Command();
12
13
  program
13
14
  .name("cerber")
14
15
  .description("AI code-review cockpit. Claude reviews PRs into local artifacts; nothing reaches GitHub until you explicitly send it.")
15
- .version("0.3.0");
16
+ .version("0.5.0");
16
17
  program
17
18
  .command("review")
18
19
  .description("Run an AI review of one or more PRs (local artifact only — no GitHub writes)")
@@ -150,22 +151,102 @@ program
150
151
  event: payload.event,
151
152
  body: payload.body,
152
153
  comments: payload.comments,
154
+ commitId: payload.commitId,
153
155
  });
154
156
  await updateArtifactByKey(artifactKey(id), (a) => ({
155
157
  ...a,
156
158
  status: "sent",
157
- sent: { at: new Date().toISOString(), event, url },
159
+ sent: { at: new Date().toISOString(), event, url, auto: false },
160
+ calibration: computeCalibration(a, event),
158
161
  }));
159
162
  console.log(`✔ Review sent${url ? `: ${url}` : "."}`);
160
163
  });
164
+ program
165
+ .command("stats")
166
+ .description("How well-calibrated the AI reviews are: verdict agreement and comment survival by confidence")
167
+ .action(async () => {
168
+ const artifacts = await listArtifacts();
169
+ const sent = artifacts.filter((a) => a.calibration);
170
+ if (sent.length === 0) {
171
+ console.log("No sent reviews with calibration data yet. Stats appear after you send reviews.");
172
+ }
173
+ else {
174
+ const buckets = [
175
+ [" <60%", (c) => c < 60],
176
+ ["60-79%", (c) => c >= 60 && c < 80],
177
+ ["80-89%", (c) => c >= 80 && c < 90],
178
+ [" 90%+", (c) => c >= 90],
179
+ ];
180
+ console.log(`Calibration over ${sent.length} sent review(s):\n`);
181
+ console.log("confidence n verdict-agree ai-comments kept/edited/dropped");
182
+ for (const [label, match] of buckets) {
183
+ const group = sent.filter((a) => a.calibration.aiConfidence != null && match(a.calibration.aiConfidence));
184
+ if (group.length === 0)
185
+ continue;
186
+ const agree = group.filter((a) => a.calibration.aiRecommendation &&
187
+ eventForRecommendation(a.calibration.aiRecommendation) === a.calibration.sentEvent).length;
188
+ const totals = group.reduce((acc, a) => {
189
+ const c = a.calibration;
190
+ acc.total += c.aiCommentsTotal;
191
+ acc.dropped += c.aiCommentsDropped;
192
+ acc.edited += c.aiCommentsEdited;
193
+ return acc;
194
+ }, { total: 0, dropped: 0, edited: 0 });
195
+ const kept = totals.total - totals.dropped;
196
+ console.log(`${label} ${String(group.length).padEnd(3)} ${`${agree}/${group.length}`.padEnd(14)}` +
197
+ `${kept}/${totals.edited}/${totals.dropped} of ${totals.total}`);
198
+ }
199
+ }
200
+ const log = await readAutoSendLog();
201
+ if (log.length > 0) {
202
+ const wouldSend = log.filter((e) => e.decision.startsWith("would send")).length;
203
+ const autoSent = log.filter((e) => e.sent).length;
204
+ console.log(`\nAuto-send log (${cerberHome()}/autosend.ndjson): ${log.length} decision(s), ` +
205
+ `${wouldSend} shadow candidate(s), ${autoSent} auto-sent.`);
206
+ }
207
+ });
161
208
  program
162
209
  .command("serve")
163
- .description("Start the local review cockpit")
210
+ .description("Start the review cockpit; --daemon keeps the queue warm by polling GitHub")
164
211
  .option("-p, --port <port>", "port", "4820")
165
- .option("-H, --host <host>", "host to bind", "127.0.0.1")
212
+ .option("-H, --host <host>", "host to bind (0.0.0.0 for VPS — requires --token)", "127.0.0.1")
213
+ .option("-t, --token <token>", "require this token on every request (env: CERBER_TOKEN)")
214
+ .option("-d, --daemon", "poll for PRs awaiting your review and auto-review them (read-only)")
215
+ .option("-i, --interval <minutes>", "daemon poll interval", "5")
216
+ .option("-R, --repo <owner/repo>", "repo(s) the daemon watches (repeatable; default: all)", collect, [])
217
+ .option("-P, --parallel <n>", "daemon review concurrency", "3")
218
+ .option("-m, --model <model>", "Claude model override for daemon reviews")
219
+ .option("--auto-send", "ACTUALLY auto-send APPROVE verdicts at/above --auto-send-threshold (default: shadow mode, which only logs what would be sent)")
220
+ .option("--auto-send-threshold <pct>", "confidence needed to auto-send", "90")
221
+ .option("--insecure", "allow binding a non-localhost host without a token (NOT recommended)")
166
222
  .action(async (opts) => {
167
- await startServer({ port: Number(opts.port), host: opts.host });
223
+ const token = opts.token ?? process.env.CERBER_TOKEN;
224
+ const isLocal = ["127.0.0.1", "localhost", "::1"].includes(opts.host);
225
+ if (!isLocal && !token && !opts.insecure) {
226
+ console.error(`Refusing to bind ${opts.host} without auth: anyone reaching this port could send reviews as you.\n` +
227
+ `Pass --token <secret> (or CERBER_TOKEN), or --insecure if the network is trusted.`);
228
+ process.exit(1);
229
+ }
230
+ const threshold = Math.min(100, Math.max(50, Number(opts.autoSendThreshold) || 90));
231
+ if (opts.autoSend) {
232
+ console.log(`⚠ auto-send is ON: APPROVE verdicts with confidence ≥ ${threshold}% will be sent to GitHub as you, without a per-review confirmation.\n` +
233
+ ` Every decision is logged to ${cerberHome()}/autosend.ndjson. COMMENT/REQUEST_CHANGES always wait for a human.`);
234
+ }
235
+ const daemon = opts.daemon
236
+ ? startDaemon({
237
+ repos: opts.repo,
238
+ intervalMs: Math.max(1, Number(opts.interval) || 5) * 60_000,
239
+ parallel: Math.max(1, Number(opts.parallel) || 1),
240
+ model: opts.model,
241
+ autoSend: opts.autoSend ? "on" : "shadow",
242
+ autoSendThreshold: threshold,
243
+ })
244
+ : undefined;
245
+ await startServer({ port: Number(opts.port), host: opts.host, token, daemon });
168
246
  });
247
+ function collect(value, previous) {
248
+ return [...previous, value];
249
+ }
169
250
  program.parseAsync().catch((err) => {
170
251
  console.error(err instanceof Error ? err.message : err);
171
252
  process.exit(1);
@@ -0,0 +1,73 @@
1
+ import { newSideLineText } from "./diff.js";
2
+ export function reanchorComments(comments, oldDiff, newDiff) {
3
+ const before = newSideLineText(oldDiff);
4
+ const after = newSideLineText(newDiff);
5
+ const results = [];
6
+ const next = comments.map((c) => {
7
+ const result = reanchorOne(c, before, after);
8
+ results.push(result);
9
+ switch (result.state) {
10
+ case "moved":
11
+ return {
12
+ ...c,
13
+ line: result.to,
14
+ // Keep pointing at where the human wrote it, however far it travels.
15
+ originalLine: c.originalLine ?? result.from,
16
+ drifted: false,
17
+ };
18
+ case "drifted":
19
+ // Keep the stale line number: it is the only trace of what the comment
20
+ // was about, and the send path folds it into the body either way.
21
+ return { ...c, drifted: true };
22
+ default:
23
+ return c.drifted ? { ...c, drifted: false } : c;
24
+ }
25
+ });
26
+ return {
27
+ comments: next,
28
+ results,
29
+ moved: results.filter((r) => r.state === "moved").length,
30
+ drifted: results.filter((r) => r.state === "drifted").length,
31
+ };
32
+ }
33
+ function reanchorOne(comment, before, after) {
34
+ const { id, path, line } = comment;
35
+ if (line == null)
36
+ return { id, state: "unanchored", from: null, to: null };
37
+ const oldLines = before.get(path);
38
+ const newLines = after.get(path);
39
+ const oldText = oldLines?.get(line);
40
+ // No text to match on (the comment was already off-diff), or the file is not
41
+ // in the new diff at all — either way there is nothing to anchor to.
42
+ if (!oldLines || !newLines || oldText === undefined) {
43
+ return { id, state: "drifted", from: line, to: null };
44
+ }
45
+ if (newLines.get(line) === oldText) {
46
+ return { id, state: "unchanged", from: line, to: line };
47
+ }
48
+ const candidates = [...newLines].filter(([, text]) => text === oldText).map(([n]) => n);
49
+ if (candidates.length === 0)
50
+ return { id, state: "drifted", from: line, to: null };
51
+ if (candidates.length === 1)
52
+ return { id, state: "moved", from: line, to: candidates[0] };
53
+ // The line alone is ambiguous ("}", a blank line, a repeated call). Ask the
54
+ // neighbours: keep only candidates whose surrounding lines match too.
55
+ const withContext = candidates.filter((n) => contextMatches(oldLines, line, newLines, n));
56
+ if (withContext.length === 1)
57
+ return { id, state: "moved", from: line, to: withContext[0] };
58
+ return { id, state: "drifted", from: line, to: null };
59
+ }
60
+ const CONTEXT = 3;
61
+ function contextMatches(oldLines, oldLine, newLines, newLine) {
62
+ for (let offset = -CONTEXT; offset <= CONTEXT; offset++) {
63
+ if (offset === 0)
64
+ continue;
65
+ const oldText = oldLines.get(oldLine + offset);
66
+ // Outside the old hunk there is nothing to compare — treat as neutral.
67
+ if (oldText === undefined)
68
+ continue;
69
+ if (newLines.get(newLine + offset) !== oldText)
70
+ return false;
71
+ }
72
+ return true;
73
+ }
@@ -10,6 +10,12 @@ export const CommentSchema = z.object({
10
10
  chapterId: z.string().nullable().default(null),
11
11
  origin: z.enum(["ai", "user"]),
12
12
  status: z.enum(["draft", "approved", "dropped"]).default("draft"),
13
+ /** Set when the user edits an AI comment's body — calibration signal. */
14
+ editedByUser: z.boolean().default(false),
15
+ /** Line this comment was written against, when a refresh has since moved it. */
16
+ originalLine: z.number().int().positive().nullable().default(null),
17
+ /** The commented line no longer exists in the diff — it can't post inline. */
18
+ drifted: z.boolean().default(false),
13
19
  });
14
20
  /** A logical group of changes — the unit of the review walkthrough. */
15
21
  export const ChapterSchema = z.object({
@@ -35,6 +41,8 @@ export const PrInfoSchema = z.object({
35
41
  baseRefName: z.string(),
36
42
  headRefName: z.string(),
37
43
  headSha: z.string().default(""),
44
+ /** OPEN | CLOSED | MERGED, as of the last fetch. */
45
+ state: z.enum(["OPEN", "CLOSED", "MERGED"]).default("OPEN"),
38
46
  additions: z.number().int().default(0),
39
47
  deletions: z.number().int().default(0),
40
48
  changedFiles: z.number().int().default(0),
@@ -59,6 +67,28 @@ export const SentInfoSchema = z.object({
59
67
  at: z.string(),
60
68
  event: z.enum(["APPROVE", "COMMENT", "REQUEST_CHANGES"]),
61
69
  url: z.string().nullable().default(null),
70
+ /** True when the daemon auto-sent this review (opt-in --auto-send). */
71
+ auto: z.boolean().default(false),
72
+ });
73
+ /** Result of pulling a review forward onto a newer head commit. */
74
+ export const RefreshInfoSchema = z.object({
75
+ at: z.string(),
76
+ fromSha: z.string(),
77
+ toSha: z.string(),
78
+ /** Comments whose line number changed but whose code was found again. */
79
+ moved: z.number().int().default(0),
80
+ /** Comments whose line is gone from the new diff — they now post in the body. */
81
+ drifted: z.number().int().default(0),
82
+ });
83
+ /** What actually happened vs what the AI proposed — recorded at send time. */
84
+ export const CalibrationSchema = z.object({
85
+ aiRecommendation: z.enum(["approve", "comment", "request_changes"]).nullable(),
86
+ aiConfidence: z.number().nullable(),
87
+ sentEvent: z.enum(["APPROVE", "COMMENT", "REQUEST_CHANGES"]),
88
+ aiCommentsTotal: z.number().int(),
89
+ aiCommentsDropped: z.number().int(),
90
+ aiCommentsEdited: z.number().int(),
91
+ userCommentsAdded: z.number().int(),
62
92
  });
63
93
  export const ArtifactSchema = z.object({
64
94
  schemaVersion: z.literal(SCHEMA_VERSION),
@@ -76,8 +106,11 @@ export const ArtifactSchema = z.object({
76
106
  comments: z.array(CommentSchema).default([]),
77
107
  verdict: VerdictSchema.nullable().default(null),
78
108
  run: RunInfoSchema.nullable().default(null),
79
- /** Set once the user explicitly sent the review to GitHub. */
109
+ /** Set once the review was sent to GitHub (explicitly, or via opt-in auto-send). */
80
110
  sent: SentInfoSchema.nullable().default(null),
111
+ /** Last time this review was pulled forward onto a newer head commit. */
112
+ refresh: RefreshInfoSchema.nullable().default(null),
113
+ calibration: CalibrationSchema.nullable().default(null),
81
114
  });
82
115
  /** What the AI must return. Subset of the artifact; cerber owns the rest. */
83
116
  export const AiReviewSchema = z.object({
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Auto-send policy — deliberately narrow:
3
+ * only APPROVE verdicts, at or above the confidence threshold, never a
4
+ * re-send. COMMENT and REQUEST_CHANGES always wait for a human.
5
+ */
6
+ export function evaluateAutoSend(artifact, threshold) {
7
+ if (artifact.sent)
8
+ return { eligible: false, reason: "already sent" };
9
+ if (artifact.status !== "ready")
10
+ return { eligible: false, reason: `status is ${artifact.status}` };
11
+ if (!artifact.verdict)
12
+ return { eligible: false, reason: "no verdict" };
13
+ if (artifact.verdict.recommendation !== "approve") {
14
+ return {
15
+ eligible: false,
16
+ reason: `recommendation is ${artifact.verdict.recommendation} — only approve auto-sends`,
17
+ };
18
+ }
19
+ if (artifact.verdict.confidence < threshold) {
20
+ return {
21
+ eligible: false,
22
+ reason: `confidence ${artifact.verdict.confidence}% < threshold ${threshold}%`,
23
+ };
24
+ }
25
+ return { eligible: true, reason: `approve at ${artifact.verdict.confidence}% ≥ ${threshold}%` };
26
+ }
package/dist/core/diff.js CHANGED
@@ -43,13 +43,13 @@ export function patchForFiles(diff, files) {
43
43
  .join("\n");
44
44
  }
45
45
  /**
46
- * Line numbers (new side of the diff) that a GitHub review comment can attach
47
- * to, per file: added and context lines inside hunks.
46
+ * New-side lines of the diff with their text, per file: added and context
47
+ * lines inside hunks — exactly the lines a GitHub comment can attach to.
48
48
  */
49
- export function newSideLines(diff) {
49
+ export function newSideLineText(diff) {
50
50
  const result = new Map();
51
51
  for (const { path, patch } of splitDiffByFile(diff)) {
52
- const lines = new Set();
52
+ const lines = new Map();
53
53
  let newLine = 0;
54
54
  let inHunk = false;
55
55
  const patchLines = patch.split("\n");
@@ -68,7 +68,7 @@ export function newSideLines(diff) {
68
68
  if (line.startsWith("\\"))
69
69
  continue; // ""
70
70
  if (line.startsWith("+") || line.startsWith(" ") || line === "") {
71
- lines.add(newLine);
71
+ lines.set(newLine, line === "" ? "" : line.slice(1));
72
72
  newLine++;
73
73
  }
74
74
  // "-" lines belong to the old side only.
@@ -77,6 +77,17 @@ export function newSideLines(diff) {
77
77
  }
78
78
  return result;
79
79
  }
80
+ /**
81
+ * Line numbers (new side of the diff) that a GitHub review comment can attach
82
+ * to, per file: added and context lines inside hunks.
83
+ */
84
+ export function newSideLines(diff) {
85
+ const result = new Map();
86
+ for (const [path, lines] of newSideLineText(diff)) {
87
+ result.set(path, new Set(lines.keys()));
88
+ }
89
+ return result;
90
+ }
80
91
  /** Paths present in the diff but not claimed by any chapter. */
81
92
  export function unclaimedFiles(diff, chapters) {
82
93
  const claimed = new Set(chapters.flatMap((c) => c.files));
package/dist/core/gh.js CHANGED
@@ -45,7 +45,7 @@ export async function fetchPrInfo(ref) {
45
45
  "--repo",
46
46
  `${ref.owner}/${ref.repo}`,
47
47
  "--json",
48
- "title,url,author,body,baseRefName,headRefName,headRefOid,additions,deletions,changedFiles",
48
+ "title,url,author,body,baseRefName,headRefName,headRefOid,additions,deletions,changedFiles,state",
49
49
  ]);
50
50
  const raw = JSON.parse(out);
51
51
  return PrInfoSchema.parse({
@@ -59,6 +59,7 @@ export async function fetchPrInfo(ref) {
59
59
  baseRefName: raw.baseRefName,
60
60
  headRefName: raw.headRefName,
61
61
  headSha: raw.headRefOid ?? "",
62
+ state: raw.state ?? "OPEN",
62
63
  additions: raw.additions ?? 0,
63
64
  deletions: raw.deletions ?? 0,
64
65
  changedFiles: raw.changedFiles ?? 0,
@@ -119,15 +120,19 @@ export async function submitReview(ref, payload) {
119
120
  "--input",
120
121
  "-",
121
122
  ];
123
+ const { commitId, ...rest } = payload;
124
+ const body = commitId ? { ...rest, commit_id: commitId } : rest;
122
125
  const { execFile: ef } = await import("node:child_process");
123
126
  const out = await new Promise((resolve, reject) => {
124
127
  const child = ef("gh", args, { maxBuffer: MAX_BUFFER }, (err, stdout, stderr) => {
128
+ // gh only prints the top-level "message" ("Unprocessable Entity"); the useful
129
+ // part is the errors[] array it leaves in the response body on stdout.
125
130
  if (err)
126
- reject(new Error(`gh api reviews failed: ${stderr?.trim() || err.message}`));
131
+ reject(new Error(`gh api reviews failed: ${ghErrorDetail(stderr, stdout) || err.message}`));
127
132
  else
128
133
  resolve(stdout);
129
134
  });
130
- child.stdin.write(JSON.stringify(payload));
135
+ child.stdin.write(JSON.stringify(body));
131
136
  child.stdin.end();
132
137
  });
133
138
  try {
@@ -138,3 +143,24 @@ export async function submitReview(ref, payload) {
138
143
  return { url: null };
139
144
  }
140
145
  }
146
+ /** Combine gh's stderr with the details GitHub returns in the response body. */
147
+ export function ghErrorDetail(stderr, stdout) {
148
+ const head = stderr?.trim() ?? "";
149
+ let details = [];
150
+ try {
151
+ const res = JSON.parse(stdout ?? "");
152
+ details = (Array.isArray(res.errors) ? res.errors : [])
153
+ .map((e) => (typeof e === "string" ? e : describeGhError(e)))
154
+ .filter((s) => s.length > 0 && !head.includes(s));
155
+ }
156
+ catch {
157
+ // Non-JSON body (rate-limit HTML, empty response) — stderr is all we have.
158
+ }
159
+ return [head, ...details].filter(Boolean).join(" — ");
160
+ }
161
+ function describeGhError(e) {
162
+ if (typeof e.message === "string")
163
+ return e.message;
164
+ const where = [e.resource, e.field].filter((v) => typeof v === "string").join(".");
165
+ return [where, e.code].filter(Boolean).join(": ");
166
+ }
@@ -0,0 +1,54 @@
1
+ import { reanchorComments } from "./anchor.js";
2
+ /**
3
+ * Pull a review forward onto the PR's current head: swap in the new diff and
4
+ * carry every comment across, re-anchored to the code it was written about.
5
+ *
6
+ * The AI's summary, chapters and verdict still describe the commit that was
7
+ * reviewed — this only keeps the comments postable. Re-reviewing is the
8
+ * (expensive) way to get the AI's opinion of the new code.
9
+ */
10
+ export function refreshArtifact(artifact, pr, diff) {
11
+ if (artifact.pr.headSha !== "" && artifact.pr.headSha === pr.headSha) {
12
+ return { artifact, changed: false, moved: 0, drifted: 0, results: [] };
13
+ }
14
+ const { comments, results, moved, drifted } = reanchorComments(artifact.comments, artifact.diff, diff);
15
+ return {
16
+ artifact: {
17
+ ...artifact,
18
+ pr,
19
+ diff,
20
+ comments,
21
+ updatedAt: new Date().toISOString(),
22
+ refresh: {
23
+ at: new Date().toISOString(),
24
+ fromSha: artifact.pr.headSha,
25
+ toSha: pr.headSha,
26
+ moved,
27
+ drifted,
28
+ },
29
+ },
30
+ changed: true,
31
+ moved,
32
+ drifted,
33
+ results,
34
+ };
35
+ }
36
+ /**
37
+ * Comments a human put work into: their own, and AI ones they rewrote.
38
+ * A re-review regenerates everything the AI said, but must not silently bin
39
+ * these — see carryOverComments.
40
+ */
41
+ export function humanComments(artifact) {
42
+ return artifact.comments.filter((c) => c.origin === "user" || c.editedByUser);
43
+ }
44
+ /**
45
+ * Carry a previous review's human comments into a fresh one, re-anchored to
46
+ * the new diff. AI comments are dropped: the new run just regenerated them.
47
+ */
48
+ export function carryOverComments(previous, fresh) {
49
+ const human = humanComments(previous);
50
+ if (human.length === 0)
51
+ return { comments: fresh.comments, carried: 0, drifted: 0 };
52
+ const { comments, drifted } = reanchorComments(human, previous.diff, fresh.diff);
53
+ return { comments: [...fresh.comments, ...comments], carried: comments.length, drifted };
54
+ }
package/dist/core/send.js CHANGED
@@ -13,7 +13,10 @@ export function buildReviewPayload(artifact, event) {
13
13
  const inline = [];
14
14
  const folded = [];
15
15
  for (const c of active) {
16
- if (c.line != null && anchorable.get(c.path)?.has(c.line)) {
16
+ // A drifted comment's line number may still exist in the diff, but it now
17
+ // holds different code — posting there would point the author at something
18
+ // the comment was never about.
19
+ if (!c.drifted && c.line != null && anchorable.get(c.path)?.has(c.line)) {
17
20
  inline.push({ path: c.path, line: c.line, side: "RIGHT", body: c.body });
18
21
  }
19
22
  else {
@@ -33,9 +36,31 @@ export function buildReviewPayload(artifact, event) {
33
36
  if (folded.length > 0) {
34
37
  parts.push("", "## Additional notes", "");
35
38
  for (const c of folded) {
36
- parts.push(`- \`${c.path}${c.line != null ? `:${c.line}` : ""}\` — ${c.body}`);
39
+ // A drifted comment's line no longer exists, so name it as "was at" —
40
+ // pointing a reader at a line number that moved is worse than no number.
41
+ const at = c.line != null ? `:${c.drifted ? `~${c.line}` : c.line}` : "";
42
+ parts.push(`- \`${c.path}${at}\` — ${c.body}`);
37
43
  }
38
44
  }
39
45
  parts.push("", "---", "_Reviewed with [cerber](https://github.com/fullstackhouse/cerber) 🐕 — drafted by AI, sent by a human._");
40
- return { event, body: parts.join("\n").trim(), comments: inline, folded };
46
+ return {
47
+ event,
48
+ body: parts.join("\n").trim(),
49
+ comments: inline,
50
+ folded,
51
+ commitId: artifact.pr.headSha || undefined,
52
+ };
53
+ }
54
+ /** Snapshot of AI-proposal vs human-final, recorded at send time for `cerber stats`. */
55
+ export function computeCalibration(artifact, event) {
56
+ const ai = artifact.comments.filter((c) => c.origin === "ai");
57
+ return {
58
+ aiRecommendation: artifact.verdict?.recommendation ?? null,
59
+ aiConfidence: artifact.verdict?.confidence ?? null,
60
+ sentEvent: event,
61
+ aiCommentsTotal: ai.length,
62
+ aiCommentsDropped: ai.filter((c) => c.status === "dropped").length,
63
+ aiCommentsEdited: ai.filter((c) => c.editedByUser).length,
64
+ userCommentsAdded: artifact.comments.filter((c) => c.origin === "user").length,
65
+ };
41
66
  }
@@ -41,6 +41,32 @@ export async function loadArtifactByKey(key) {
41
41
  throw err;
42
42
  }
43
43
  }
44
+ /** Append to ~/.cerber/autosend.ndjson — the shadow/auto-send audit log. */
45
+ export async function appendAutoSendLog(entry) {
46
+ await fs.mkdir(cerberHome(), { recursive: true });
47
+ await fs.appendFile(path.join(cerberHome(), "autosend.ndjson"), JSON.stringify(entry) + "\n");
48
+ }
49
+ export async function readAutoSendLog() {
50
+ try {
51
+ const raw = await fs.readFile(path.join(cerberHome(), "autosend.ndjson"), "utf8");
52
+ return raw
53
+ .split("\n")
54
+ .filter(Boolean)
55
+ .flatMap((line) => {
56
+ try {
57
+ return [JSON.parse(line)];
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ });
63
+ }
64
+ catch (err) {
65
+ if (err.code === "ENOENT")
66
+ return [];
67
+ throw err;
68
+ }
69
+ }
44
70
  /** Load by key, apply a mutation, bump updatedAt, save. Returns the new artifact. */
45
71
  export async function updateArtifactByKey(key, mutate) {
46
72
  const artifact = await loadArtifactByKey(key);
@@ -50,6 +76,31 @@ export async function updateArtifactByKey(key, mutate) {
50
76
  await saveArtifact(updated);
51
77
  return updated;
52
78
  }
79
+ /**
80
+ * Clear "running" statuses left behind by a process that died mid-review.
81
+ * Nothing in a freshly started process is running, so anything still marked
82
+ * running is a leftover — without this it stays wedged forever, with no way to
83
+ * retry it from the cockpit. A `cerber review` running in another terminal is
84
+ * the one false positive; it overwrites the artifact when it finishes anyway.
85
+ */
86
+ export async function reconcileRunning() {
87
+ const artifacts = await listArtifacts();
88
+ let cleared = 0;
89
+ for (const artifact of artifacts) {
90
+ if (artifact.status !== "running")
91
+ continue;
92
+ await saveArtifact({
93
+ ...artifact,
94
+ status: "failed",
95
+ updatedAt: new Date().toISOString(),
96
+ run: artifact.run
97
+ ? { ...artifact.run, error: "interrupted — cerber restarted while this review was running" }
98
+ : null,
99
+ });
100
+ cleared++;
101
+ }
102
+ return cleared;
103
+ }
53
104
  export async function listArtifacts() {
54
105
  let entries;
55
106
  try {