@fullstackhouse/cerber 0.12.0 → 0.13.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
@@ -41,6 +41,24 @@ for reviews you sent, one for the ones you marked reviewed or skipped, one for
41
41
  merged and closed PRs — and PRs in archived repos are never picked up at all,
42
42
  since the repo is read-only and a review could never be sent.
43
43
 
44
+ Settling a review is your decision, not GitHub's: skip a PR, or mark one
45
+ reviewed without sending, and GitHub still holds its review request open,
46
+ because nothing cerber does here reaches it. Those rows stay marked where they
47
+ sit, counted on the drawer they went into, and named in place of the empty
48
+ inbox — cerber never tells you nothing awaits you while its own poll says
49
+ otherwise. It is a report, not a nag: what you decided here stands, and the
50
+ mark clears itself the moment GitHub hears from you.
51
+
52
+ An open request doesn't mean the ball is yours, though. You may have argued the
53
+ whole thing out in the PR conversation and never pressed GitHub's review
54
+ button, which leaves the request open with nothing blocked on you. So cerber
55
+ reads the conversation and says whose move it is: `you haven't replied` (they
56
+ are waiting), `waiting on them` (you had the last word), or `they replied last`
57
+ (it is back with you). Bots don't count as an answer, or a chatty CI would put
58
+ every PR back on you. If the conversation can't be read it says `unanswered on
59
+ GitHub` and claims nothing further — guessing "you never replied" at someone
60
+ who did is the one mistake worth designing against.
61
+
44
62
  The cockpit is a background tab most of the day, so it tells you when the queue
45
63
  grows: a desktop notification naming the PR that arrived — click it to open the
46
64
  review — folded into one popup when several land at once. It stays quiet while
@@ -60,9 +78,11 @@ Each review is a plain JSON artifact in `~/.cerber/reviews/` (override with
60
78
  - **Chapters** — the changed files grouped into a logical walkthrough, each
61
79
  with a title, an explanation, and its slice of the diff
62
80
  - **Draft comments** — inline, anchored to file/line, only things worth a
63
- human's time
81
+ human's time. Findings are graded **blocker / minor / nit** — the words mean
82
+ what they mean everywhere, and only a blocker stands between the PR and
83
+ approval; a question or note carries no grade at all
64
84
  - **Verdict** — approve / comment / request changes, with a 0–100 confidence
65
- score and reasoning
85
+ score and reasoning that names the worst finding still standing
66
86
  - **Run metadata** — model, whether it read the source or only the diff, and
67
87
  the token spend as an API-rate equivalent
68
88
 
@@ -1,5 +1,12 @@
1
1
  import { z } from "zod";
2
2
  export const SCHEMA_VERSION = 1;
3
+ /**
4
+ * How hard a finding presses on the merge. The words carry their own
5
+ * semantics: a blocker is the only tier that stops approval; minor is
6
+ * "should fix, author's call"; nit is taste. Absent (null) means the comment
7
+ * is not a finding at all — a question, a note, praise — not "unknown".
8
+ */
9
+ export const SeveritySchema = z.enum(["blocker", "minor", "nit"]);
3
10
  /** A draft inline comment. Never sent to GitHub unless the user explicitly sends the review. */
4
11
  export const CommentSchema = z.object({
5
12
  id: z.string(),
@@ -8,6 +15,8 @@ export const CommentSchema = z.object({
8
15
  line: z.number().int().positive().nullable(),
9
16
  body: z.string(),
10
17
  chapterId: z.string().nullable().default(null),
18
+ /** Merge impact of this finding; null for remarks that are not findings. */
19
+ severity: SeveritySchema.nullable().default(null),
11
20
  origin: z.enum(["ai", "user"]),
12
21
  status: z.enum(["draft", "approved", "dropped"]).default("draft"),
13
22
  /** Set when the user edits an AI comment's body — calibration signal. */
@@ -111,7 +120,13 @@ export const RevisionSchema = z.discriminatedUnion("kind", [
111
120
  z.object({ kind: z.literal("summary"), body: z.string() }),
112
121
  z.object({ kind: z.literal("verdict"), verdict: VerdictSchema }),
113
122
  z.object({ kind: z.literal("chapter"), chapterId: z.string(), title: z.string().optional(), explanation: z.string().optional() }),
114
- z.object({ kind: z.literal("comment-edit"), commentId: z.string(), body: z.string() }),
123
+ z.object({
124
+ kind: z.literal("comment-edit"),
125
+ commentId: z.string(),
126
+ body: z.string(),
127
+ /** Re-grade while editing; omitted = grade unchanged, null = not a finding. */
128
+ severity: SeveritySchema.nullable().optional(),
129
+ }),
115
130
  z.object({ kind: z.literal("comment-drop"), commentId: z.string() }),
116
131
  z.object({
117
132
  kind: z.literal("comment-add"),
@@ -119,6 +134,7 @@ export const RevisionSchema = z.discriminatedUnion("kind", [
119
134
  line: z.number().int().positive().nullable(),
120
135
  body: z.string(),
121
136
  chapterId: z.string().nullable().default(null),
137
+ severity: SeveritySchema.nullable().default(null),
122
138
  }),
123
139
  ]);
124
140
  /** A revision the run declined to make, and why — shown in the transcript. */
@@ -217,6 +233,7 @@ export const AiReviewSchema = z.object({
217
233
  line: z.number().int().positive().nullable(),
218
234
  body: z.string(),
219
235
  chapterId: z.string().nullable().optional(),
236
+ severity: SeveritySchema.nullable().optional(),
220
237
  })),
221
238
  verdict: VerdictSchema,
222
239
  });
@@ -16,6 +16,15 @@ export function evaluateAutoSend(artifact, threshold) {
16
16
  reason: `recommendation is ${artifact.verdict.recommendation} — only approve auto-sends`,
17
17
  };
18
18
  }
19
+ // An approve verdict standing next to a live blocker finding contradicts
20
+ // itself; a contradiction is a human's to resolve, never auto-sent.
21
+ const blockers = artifact.comments.filter((c) => c.status !== "dropped" && c.severity === "blocker");
22
+ if (blockers.length > 0) {
23
+ return {
24
+ eligible: false,
25
+ reason: `verdict says approve but ${blockers.length} blocker finding(s) still stand — a human resolves that`,
26
+ };
27
+ }
19
28
  if (artifact.verdict.confidence < threshold) {
20
29
  return {
21
30
  eligible: false,
@@ -1,3 +1,7 @@
1
+ /** Same convention as Send: the grade travels in the text, ungraded stays bare. */
2
+ function grade(c) {
3
+ return c.severity ? `**${c.severity}:** ` : "";
4
+ }
1
5
  /** Render an artifact as a standalone markdown review document. */
2
6
  export function toMarkdown(artifact) {
3
7
  const lines = [];
@@ -29,7 +33,7 @@ export function toMarkdown(artifact) {
29
33
  lines.push("");
30
34
  const comments = artifact.comments.filter((c) => c.chapterId === ch.id && c.status !== "dropped");
31
35
  for (const c of comments) {
32
- lines.push(`> **${c.path}${c.line != null ? `:${c.line}` : ""}** — ${c.body.replace(/\n/g, "\n> ")}`);
36
+ lines.push(`> **${c.path}${c.line != null ? `:${c.line}` : ""}** — ${grade(c)}${c.body.replace(/\n/g, "\n> ")}`);
33
37
  lines.push("");
34
38
  }
35
39
  }
package/dist/core/gh.js CHANGED
@@ -114,6 +114,58 @@ export function mapSearchResults(raw) {
114
114
  ];
115
115
  });
116
116
  }
117
+ /** GitHub types bot accounts, and names them `something[bot]` besides. */
118
+ export const isBot = (c) => c.bot || c.author.endsWith("[bot]");
119
+ /** The PR's conversation comments, oldest first. Not the inline review ones —
120
+ * those come attached to a review, which would have cleared the request. */
121
+ export async function fetchConversation(ref) {
122
+ const out = await gh([
123
+ "api",
124
+ "--paginate",
125
+ `repos/${ref.owner}/${ref.repo}/issues/${ref.number}/comments`,
126
+ "--jq",
127
+ '.[] | {author: .user.login, at: .created_at, bot: (.user.type == "Bot")}',
128
+ ]);
129
+ // --jq streams one object per line rather than a JSON array.
130
+ return out
131
+ .split("\n")
132
+ .filter((line) => line.trim() !== "")
133
+ .map((line) => JSON.parse(line));
134
+ }
135
+ /**
136
+ * Whose move it is, from the conversation: you have not spoken at all, you
137
+ * spoke last (so it is on them), or a person answered after you (back to you).
138
+ *
139
+ * Bots are ignored outright. A repo whose CI posts on every push would
140
+ * otherwise report "they replied last" on everything, which is the fastest way
141
+ * to make a signal worthless.
142
+ */
143
+ export function classifyReply(comments, login) {
144
+ const human = comments.filter((c) => !isBot(c));
145
+ const mine = human.filter((c) => c.author === login);
146
+ if (mine.length === 0)
147
+ return "none";
148
+ const lastMine = mine[mine.length - 1].at;
149
+ // A comment of your own posted at the same instant can't be someone else's
150
+ // answer to it, so only strictly-later comments hand the move back.
151
+ return human.some((c) => c.author !== login && c.at > lastMine) ? "them" : "you";
152
+ }
153
+ /** The login `@me` resolves to. Asked once — it cannot change mid-process. */
154
+ let cachedLogin = null;
155
+ export function currentLogin() {
156
+ cachedLogin ??= gh(["api", "user", "--jq", ".login"]).then((out) => out.trim(), (err) => {
157
+ // A login we could not fetch is not an answer worth keeping. gh may be
158
+ // offline or mid-re-auth; caching the rejection would leave every later
159
+ // poll reporting "unknown" until the process restarts.
160
+ cachedLogin = null;
161
+ throw err;
162
+ });
163
+ return cachedLogin;
164
+ }
165
+ /** Test seam: forget the cached login so the next call asks again. */
166
+ export function resetLoginCache() {
167
+ cachedLogin = null;
168
+ }
117
169
  export function searchAwaitingArgs(repoFilter, limit = 50) {
118
170
  const args = [
119
171
  "search",
@@ -39,7 +39,7 @@ export function mergeConcurrentEdits(before, after, current) {
39
39
  // Absent from `before` means the user added it while the turn ran.
40
40
  if (!then)
41
41
  return true;
42
- return then.body !== now.body || then.status !== now.status;
42
+ return then.body !== now.body || then.status !== now.status || then.severity !== now.severity;
43
43
  };
44
44
  const merged = current.comments.map((now) => userTouched(now.id, now) ? now : fromTurn.get(now.id) ?? now);
45
45
  // Comments the turn itself added are not in `current` yet. Only those: a
@@ -115,7 +115,14 @@ export function applyRevisions(artifact, revisions, opts = {}) {
115
115
  }
116
116
  next = {
117
117
  ...next,
118
- comments: next.comments.map((c) => c.id === revision.commentId ? { ...c, body: revision.body } : c),
118
+ comments: next.comments.map((c) => c.id === revision.commentId
119
+ ? {
120
+ ...c,
121
+ body: revision.body,
122
+ // Omitted = the turn is not re-grading; null = "not a finding".
123
+ severity: revision.severity === undefined ? c.severity : revision.severity,
124
+ }
125
+ : c),
119
126
  };
120
127
  applied.push(revision);
121
128
  break;
@@ -149,6 +156,7 @@ export function applyRevisions(artifact, revisions, opts = {}) {
149
156
  line: revision.line,
150
157
  body: revision.body,
151
158
  chapterId: revision.chapterId,
159
+ severity: revision.severity,
152
160
  origin: "ai",
153
161
  status: "draft",
154
162
  editedByUser: false,
package/dist/core/send.js CHANGED
@@ -2,6 +2,14 @@ import { newSideLines } from "./diff.js";
2
2
  export function eventForRecommendation(rec) {
3
3
  return { approve: "APPROVE", comment: "COMMENT", request_changes: "REQUEST_CHANGES" }[rec];
4
4
  }
5
+ /**
6
+ * The grade travels in the text: to a reader used to review convention, an
7
+ * unprefixed comment says "please change this", so leaving a nit bare would
8
+ * invert its meaning. Ungraded remarks (questions, notes) stay bare.
9
+ */
10
+ function withGrade(c) {
11
+ return c.severity ? `**${c.severity}:** ${c.body}` : c.body;
12
+ }
5
13
  /**
6
14
  * Build the GitHub review payload from an artifact.
7
15
  * Includes every comment that is not dropped. Comments whose line is not part
@@ -17,7 +25,7 @@ export function buildReviewPayload(artifact, event) {
17
25
  // holds different code — posting there would point the author at something
18
26
  // the comment was never about.
19
27
  if (!c.drifted && c.line != null && anchorable.get(c.path)?.has(c.line)) {
20
- inline.push({ path: c.path, line: c.line, side: "RIGHT", body: c.body });
28
+ inline.push({ path: c.path, line: c.line, side: "RIGHT", body: withGrade(c) });
21
29
  }
22
30
  else {
23
31
  folded.push(c);
@@ -39,7 +47,7 @@ export function buildReviewPayload(artifact, event) {
39
47
  // A drifted comment's line no longer exists, so name it as "was at" —
40
48
  // pointing a reader at a line number that moved is worse than no number.
41
49
  const at = c.line != null ? `:${c.drifted ? `~${c.line}` : c.line}` : "";
42
- parts.push(`- \`${c.path}${at}\` — ${c.body}`);
50
+ parts.push(`- \`${c.path}${at}\` — ${withGrade(c)}`);
43
51
  }
44
52
  }
45
53
  parts.push("", "---", "_Reviewed with [cerber](https://github.com/fullstackhouse/cerber) 🐕 — drafted by AI, sent by a human._");
@@ -49,6 +49,7 @@ Respond with ONLY a JSON object (no markdown fences, no prose before or after) m
49
49
  "path": "path/to/file.ts",
50
50
  "line": 42,
51
51
  "body": "markdown — the review comment. First sentence states the problem plainly; then the evidence or fix. Concrete, actionable, kind.",
52
+ "severity": "blocker" | "minor" | "nit" | null,
52
53
  "chapterId": "short-kebab-id"
53
54
  }
54
55
  ],
@@ -70,6 +71,13 @@ Rules:
70
71
  - 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.
71
72
  - "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.
72
73
  - Only write comments that are worth a human reading: real bugs, risky patterns, missing tests, unclear naming. No nitpicks a linter would catch. An empty comments array is a perfectly good review of a clean PR.
74
+ - "severity" is the finding's merge impact. The words mean what they mean everywhere:
75
+ - "blocker" — you would not approve with this in: data loss, a security hole, wrong behavior on a path people actually hit, a broken build. The only tier that blocks.
76
+ - "minor" — should be fixed, but the author's call; you would approve and not chase it.
77
+ - "nit" — taste: naming, wording, formatting.
78
+ - null — not a finding at all: a question, a note, praise.
79
+ - Grade severity as if the finding is true. If you do not believe it enough to grade it, do not write it — or ask it as a question with severity null. Never lower a grade to hedge doubt; doubt belongs in the verdict's "confidence", nowhere else.
80
+ - The verdict must follow from the worst finding still standing: any blocker → "request_changes"; none → "approve" (or "comment" when you genuinely take no position). Name the deciding finding in "reasoning". Never request changes without a blocker in the list, and never approve past one.
73
81
  ${confidenceRule}
74
82
  - Never invent files or lines not present in the diff.
75
83
 
@@ -122,7 +130,8 @@ function renderReview(artifact) {
122
130
  .map((c) => {
123
131
  const mine = c.origin === "user" || c.editedByUser ? " [THE USER'S OWN WRITING]" : "";
124
132
  const dropped = c.status === "dropped" ? " [dropped]" : "";
125
- return `- ${c.id} — ${c.path}:${c.line ?? "file-level"}${mine}${dropped}\n ${c.body}`;
133
+ const graded = c.severity ? ` [${c.severity}]` : "";
134
+ return `- ${c.id} — ${c.path}:${c.line ?? "file-level"}${graded}${mine}${dropped}\n ${c.body}`;
126
135
  })
127
136
  .join("\n");
128
137
  const verdict = artifact.verdict
@@ -192,9 +201,9 @@ Respond with ONLY a JSON object (no markdown fences, no prose before or after) m
192
201
  { "kind": "summary", "body": "the rewritten summary" },
193
202
  { "kind": "verdict", "verdict": { "recommendation": "approve" | "comment" | "request_changes", "confidence": 0-100, "reasoning": "..." } },
194
203
  { "kind": "chapter", "chapterId": "...", "title": "optional new title", "explanation": "optional new explanation" },
195
- { "kind": "comment-edit", "commentId": "...", "body": "the rewritten comment" },
204
+ { "kind": "comment-edit", "commentId": "...", "body": "the rewritten comment", "severity": "blocker" | "minor" | "nit" | null (optional — omit to leave the grade as it is) },
196
205
  { "kind": "comment-drop", "commentId": "..." },
197
- { "kind": "comment-add", "path": "path/to/file.ts", "line": 42, "body": "...", "chapterId": "..." }
206
+ { "kind": "comment-add", "path": "path/to/file.ts", "line": 42, "body": "...", "severity": "blocker" | "minor" | "nit" | null, "chapterId": "..." }
198
207
  ]
199
208
  }
200
209
 
@@ -205,6 +214,7 @@ Rules:
205
214
  - Comments marked [THE USER'S OWN WRITING] are theirs. Do not edit or drop them. If one of them is what needs changing, say so in your reply and let them do it.
206
215
  - Every "commentId" and "chapterId" must be one listed below. Never invent one.
207
216
  - "line" is a line in the NEW version of the file and must appear in the diff. Use null for a file-level comment.
217
+ - "severity" is the finding's merge impact — "blocker" (would not approve with this in; the only tier that blocks), "minor" (should fix, author's call), "nit" (taste), null (not a finding: a question, a note). Grade as if the finding is true; hedge in the reply, never in the grade. A verdict revision must still follow from the worst finding left standing.
208
218
  - Your reply is prose to a person, not a changelog. The cockpit already shows what you changed — do not list your own edits back to them.
209
219
 
210
220
  Writing style:
@@ -164,6 +164,7 @@ async function runReview(ref, opts) {
164
164
  line: c.line,
165
165
  body: c.body,
166
166
  chapterId: c.chapterId ?? null,
167
+ severity: c.severity ?? null,
167
168
  origin: "ai",
168
169
  status: "draft",
169
170
  editedByUser: false,
@@ -1,7 +1,7 @@
1
1
  import { SCHEMA_VERSION, artifactId, artifactKey } from "../core/artifact.js";
2
2
  import { evaluateAutoSend } from "../core/autosend.js";
3
3
  import { loadConfig } from "../core/config.js";
4
- import { fetchPrInfo, searchAwaitingMe, submitReview } from "../core/gh.js";
4
+ import { classifyReply, currentLogin, fetchConversation, fetchPrInfo, searchAwaitingMe, submitReview, } from "../core/gh.js";
5
5
  import { buildReviewPayload, computeCalibration } from "../core/send.js";
6
6
  import { appendAutoSendLog, deleteArtifact, listArtifacts, loadArtifact, saveArtifact, updateArtifactByKey, } from "../core/state.js";
7
7
  import { ReviewInProgressError } from "../runner/inflight.js";
@@ -78,6 +78,7 @@ export function startDaemon(opts) {
78
78
  lastPollAt: null,
79
79
  nextPollAt: new Date(Date.now()).toISOString(),
80
80
  lastSummary: null,
81
+ awaiting: [],
81
82
  lastPollError: null,
82
83
  autoSend: opts.autoSend,
83
84
  autoSendThreshold: opts.autoSendThreshold,
@@ -170,6 +171,34 @@ export function startDaemon(opts) {
170
171
  }
171
172
  return { discovered };
172
173
  }
174
+ /**
175
+ * Whose move it is on each awaiting PR, one conversation read apiece.
176
+ *
177
+ * GitHub's `commenter:` search qualifier would answer this in a single call,
178
+ * but it demonstrably disagrees with the REST API — it misses PRs whose only
179
+ * comment is the newest thing on them — and a tag that says "you never
180
+ * replied" to someone who did is the exact bug this whole change fixes. So
181
+ * the conversation is read per PR, bounded by the review concurrency. A read
182
+ * that fails yields `unknown`, which claims nothing.
183
+ */
184
+ async function classifyAll(refs) {
185
+ let login;
186
+ try {
187
+ login = await currentLogin();
188
+ }
189
+ catch {
190
+ // Without knowing who you are, no comment can be attributed to you.
191
+ return refs.map((ref) => ({ id: artifactId(ref), reply: "unknown" }));
192
+ }
193
+ return pool(refs, opts.parallel, async (ref) => {
194
+ try {
195
+ return { id: artifactId(ref), reply: classifyReply(await fetchConversation(ref), login) };
196
+ }
197
+ catch {
198
+ return { id: artifactId(ref), reply: "unknown" };
199
+ }
200
+ });
201
+ }
173
202
  async function reviewAll(refs) {
174
203
  let reviewed = 0;
175
204
  let skipped = 0;
@@ -222,13 +251,19 @@ export function startDaemon(opts) {
222
251
  // Recomputed too, so a trust rule added mid-run shows up in the strip.
223
252
  status.trustedRuns = autoReview && opts.trust !== false && config.trust.length > 0;
224
253
  if (!knobs.poll) {
225
- // Deliberately off is not an outage — don't leave a stale error up.
254
+ // Deliberately off is not an outage — don't leave a stale error up, or
255
+ // a list of PRs we are no longer checking are still awaiting you.
226
256
  status.lastPollError = null;
257
+ status.awaiting = [];
227
258
  status.lastSummary = "polling off (settings) — the queue only shows what you review by hand";
228
259
  return;
229
260
  }
230
261
  const refs = await discover();
231
262
  status.lastPollError = null;
263
+ // The same refs behind lastSummary's count, so the number the strip shows
264
+ // and the rows the cockpit can name always come from one answer.
265
+ // A failed poll leaves the last good list up; lastPollError says so.
266
+ status.awaiting = await classifyAll(refs);
232
267
  const { discovered } = await syncQueue(refs);
233
268
  if (autoReview) {
234
269
  const { reviewed, skipped, failed } = await reviewAll(refs);
@@ -6,7 +6,7 @@ import { promises as fs } from "node:fs";
6
6
  import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { randomUUID } from "node:crypto";
9
- import { ArtifactStatusSchema, VerdictSchema, artifactKey } from "../core/artifact.js";
9
+ import { ArtifactStatusSchema, SeveritySchema, VerdictSchema, artifactKey } from "../core/artifact.js";
10
10
  import { toMarkdown } from "../core/export.js";
11
11
  import { fetchPrDiff, fetchPrInfo, submitReview } from "../core/gh.js";
12
12
  import { z } from "zod";
@@ -194,6 +194,9 @@ export async function buildApp(opts) {
194
194
  ? {
195
195
  ...cm,
196
196
  ...(body.body !== undefined ? { body: String(body.body) } : {}),
197
+ ...(body.severity !== undefined
198
+ ? { severity: SeveritySchema.nullable().parse(body.severity) }
199
+ : {}),
197
200
  ...(body.status !== undefined
198
201
  ? { status: body.status }
199
202
  : {}),
@@ -219,6 +222,7 @@ export async function buildApp(opts) {
219
222
  line: body.line != null ? Number(body.line) : null,
220
223
  body: String(body.body ?? ""),
221
224
  chapterId: body.chapterId ?? null,
225
+ severity: SeveritySchema.nullable().parse(body.severity ?? null),
222
226
  origin: "user",
223
227
  status: "draft",
224
228
  editedByUser: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.12.0",
3
+ "version": "0.13.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
  "author": "Full Stack House (https://fullstack.house)",