@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 +4 -2
- package/dist/core/artifact.js +18 -1
- package/dist/core/autosend.js +9 -0
- package/dist/core/export.js +5 -1
- package/dist/core/revise.js +10 -2
- package/dist/core/send.js +10 -2
- package/dist/runner/prompt.js +13 -3
- package/dist/runner/review.js +1 -0
- package/dist/server/index.js +5 -1
- package/package.json +1 -1
- package/web/dist/assets/{index-Dc2YYn67.js → index-akC55Qya.js} +70 -70
- package/web/dist/assets/{index-ConapON7.css → index-s4kXKj8C.css} +1 -1
- package/web/dist/index.html +2 -2
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
|
|
package/dist/core/artifact.js
CHANGED
|
@@ -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({
|
|
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
|
});
|
package/dist/core/autosend.js
CHANGED
|
@@ -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,
|
package/dist/core/export.js
CHANGED
|
@@ -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/revise.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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._");
|
package/dist/runner/prompt.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
package/dist/runner/review.js
CHANGED
package/dist/server/index.js
CHANGED
|
@@ -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.
|
|
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)",
|