@fullstackhouse/cerber 0.12.1 → 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
@@ -78,9 +78,11 @@ Each review is a plain JSON artifact in `~/.cerber/reviews/` (override with
78
78
  - **Chapters** — the changed files grouped into a logical walkthrough, each
79
79
  with a title, an explanation, and its slice of the diff
80
80
  - **Draft comments** — inline, anchored to file/line, only things worth a
81
- 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
82
84
  - **Verdict** — approve / comment / request changes, with a 0–100 confidence
83
- score and reasoning
85
+ score and reasoning that names the worst finding still standing
84
86
  - **Run metadata** — model, whether it read the source or only the diff, and
85
87
  the token spend as an API-rate equivalent
86
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
  }
@@ -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,
@@ -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.1",
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)",