Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,17 @@ left unwritten. Code and tests win when they disagree.
rather than overwriting whatever they edited while the turn ran. The
difference is deliberate: a re-review is asking for a new draft, a chat turn
is asking for an edit to this one
- Don't put a second control over one decision. What Send posts follows the
verdict and the verdict buttons are right above it, so the cockpit has no
"send as…" switch beside the button — the way to post an approve is to say
the review approves. The summary is cerber's own reading of the PR, not
something to hand-edit; the body that posts is the thing worth editing.
- Don't let what posts drift from what the cockpit shows without saying so.
The GitHub body is composed from the draft (summary, walkthrough, the
comments that can't post inline, footer). The one exception is a body the
user wrote by hand in the send panel (`bodyOverride`) — it replaces the
composed one outright, footer included, and every line of that panel that
describes the payload has to say which of the two is about to post.
- Don't add an accept step to a revision the user asked for. The chat agent
writes the draft directly; Send is where a human vouches for what reaches
GitHub, and one pre-chat snapshot is the way back. A per-turn undo is
Expand Down
47 changes: 40 additions & 7 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ absent on read and materialize with the stated value.
| `chapters` | Chapter[] | default `[]` |
| `comments` | Comment[] | default `[]` |
| `verdict` | Verdict \| null | default `null` |
| `bodyOverride` | string \| null | default `null`; the review body to post, written by hand — null means composed at send time (§14.4) |
| `run` | RunInfo \| null | default `null` |
| `sent` | SentInfo \| null | default `null` |
| `filed` | FiledInfo \| null | default `null`; never set on a sent artifact |
Expand Down Expand Up @@ -389,9 +390,10 @@ review began at that moment.
and next artifacts over a fixed watchlist — appearance in the inbox, status
changes, head movement, PR state/draft flips, run start (with its shape:
model, source, trust, who asked) and finish (reviewed SHA, cost) or failure,
verdict set/changed, comment churn summarized as one line (added from the
review / written by you / edited / re-graded / dropped / restored / gone),
send, filing, refresh. A generic diff would bury the timeline under a running
verdict set/changed, the body to post leaving the review's composition or
returning to it, comment churn summarized as one line
(added from the review / written by you / edited / re-graded / dropped /
restored / gone), send, filing, refresh. A generic diff would bury the timeline under a running
turn's narration, which is rewritten every couple of seconds.

**Decision notes.** The poll's deliberate silences — the only kind of history
Expand Down Expand Up @@ -621,8 +623,10 @@ another look — and then a new head means a new run.

### 8.5 What a Re-Review Destroys and What Survives

A re-review replaces the whole draft — summary, chapters, verdict, and **all
comments, the user's own included**. That is a decision, not a gap:
A re-review replaces the whole draft — summary, chapters, verdict, a
hand-written `bodyOverride` (it described a body for a
draft that no longer exists — the same reason the pre-chat snapshot goes), and
**all comments, the user's own included**. That is a decision, not a gap:
half-keeping them (carrying on success, losing on failure) costs the code and
still loses the work, so cerber does neither and says so plainly.

Expand Down Expand Up @@ -1112,7 +1116,8 @@ folded onto the *current* artifact, three-way (`before` = at turn start,
the first rule.
- `status` is always `current`'s — a "mark reviewed" clicked mid-turn stands.
- The verdict is the turn's only if the turn actually revised it; otherwise
`current`'s.
`current`'s. `bodyOverride` is always `current`'s — a turn never writes one, so writing or
clearing one mid-turn is the user's decision and stands.

### 12.6 Snapshot and Reset

Expand Down Expand Up @@ -1225,6 +1230,16 @@ Payload construction (pure, previewable without side effects):
- The body is `## Summary`, then `## Walkthrough` (chapter titles and
explanations), then the folded notes, then a fixed footer crediting cerber:
"drafted by AI, sent by a human."
- **Unless the user wrote one.** A non-null `bodyOverride` replaces the
composed body outright — footer and folded notes included. Half-honouring it
(keeping a footer they deleted, re-appending notes they cut) would post
something nobody wrote. It is posted **verbatim**: only the composed body is
trimmed, because a hand-written one that opens on an indented line is a
markdown code block, and trimming would silently repaint it as a paragraph. It changes the body alone: inline comments still
post, the event still applies, and the draft it replaced is untouched and
can compose the body again at any time. The split into inline and folded is
still computed, because the cockpit needs it to say which comments have no
line of their own to land on.
- Grades are rendered into comment text via the one badge function (§7.4).
- `commit_id` is the artifact's `pr.headSha` — so a review of a stale head
fails with a 422 rather than landing inline comments on the wrong code.
Expand Down Expand Up @@ -1311,7 +1326,7 @@ static assets included. No CORS: same-origin only.
| `GET /api/reviews` | queue list items | derived counts: comments, drifted, blockers, graded |
| `POST /api/reviews` | pull a PR in by URL/ref | **202** + artifact (run started); 200 existing; 409 in flight; 502 fetch failed, nothing left behind |
| `GET /api/reviews/:key` | one artifact | 404 |
| `PATCH /api/reviews/:key` | settle | only `reviewed`/`skipped` accepted (§8.1); stamps `settledAt`, clears `filed` |
| `PATCH /api/reviews/:key` | settle · set the verdict · write the body to post | only `reviewed`/`skipped` accepted (§8.1); stamps `settledAt`, clears `filed`; `bodyOverride` takes a string or `null` (back to composed), and **400** on any other type — it is posted verbatim, so coercing `{}` into `"[object Object]"` is worse than refusing it |
| `POST/PATCH/DELETE …/comments[/:id]` | comment CRUD | delete is user-origin only in the UI |
| `POST …/refresh` | §13.2 | `{stale, changed, …}`; never an error for "nothing to do" |
| `POST …/rerun?source=0\|1` | re-review, always forced | **202**; 409 sent; 409 in flight |
Expand Down Expand Up @@ -1423,6 +1438,11 @@ one is reported over the next.
or request-changes with none), the cockpit points it out and offers a
one-click chat turn asking the reviewer to re-true the verdict. It MUST
NOT rewrite the verdict itself.
- What Send posts follows the verdict, with nothing in between and no second
control for it: the verdict buttons sit directly above the button, so a
"send as…" switch beside it would be two controls over one decision — and
the way to post an approve is to say the review approves. (`cerber send -e`
keeps its own override; a terminal has no verdict buttons above it.)
- A comment that cannot post inline — drifted, or with no line at all, which
is what a comment on a line the PR *removed* becomes, GitHub taking inline
comments on the new side only — MUST say so where it is read; nothing else
Expand All @@ -1442,6 +1462,19 @@ one is reported over the next.
that is the body GitHub gets. It MUST be suppressed when the render reads
back word-for-word as the source (whitespace runs flattened): a plain note
is told nothing by a second copy of itself.
- The review body — GitHub's own comment on the review, the one part of the
payload attached to no line — is writable by hand from the send panel, which
is where the user is standing when they read that it says the wrong thing.
The editor MUST be seeded with the composed body, so writing one starts from
what would otherwise post. Because this is the one place what GitHub gets
stops being derived from what the cockpit shows, the panel MUST say which of
the two is about to be posted wherever it says anything about the payload:
the body strip says who wrote it (never "N folded into the body" over a body
the user may have cut those notes out of), the note under it says the body no
longer follows the summary or the comments, and building it from the review
again is always one click away. A body the user wrote MUST open shown rather
than behind "see what gets posted" — it is the one fact about the payload
that nothing else on the page carries.
- The review's history renders as a collapsed card at the foot of the review,
newest first — it is what you open when a review is not where you expected
it, not part of reading one — with a rail jump that opens it on the way. An
Expand Down
10 changes: 8 additions & 2 deletions docs/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,8 @@ push convinced the guard the draft was current and the poll never re-reviewed
that PR again. An artifact written before `reviewedSha` existed has none, and
falls back to the old comparison.

**A re-review replaces the whole draft, including comments you wrote.** They
are dropped when the run starts and they do not come back — not on success, not
**A re-review replaces the whole draft, including comments you wrote and a send
body you wrote yourself.** They are dropped when the run starts and they do not come back — not on success, not
if the run fails. This is a decision rather than an oversight, and it is stated
here rather than left to be discovered: if you have written comments you want
to keep, send the review or copy them out before pressing re-review.
Expand Down Expand Up @@ -414,6 +414,12 @@ Written by other paths:
- Every comment edit, add and drop is saved immediately (`editedByUser` is set
when you rewrite an AI comment — it is the calibration signal *and* what
makes the comment survive a re-review).
- A review body you write yourself (`bodyOverride`, from the send panel) is
stored beside the draft rather than replacing it: the summary, walkthrough and
comments stay as they read, and clearing the field composes the body again.
While it is set, that text is what posts — the folded comments included in the
composed body are only sent if the body you wrote kept them, which is why the
panel stops claiming them. A re-review clears it.
- A chat turn writes `pendingChat` **before** it starts (so a reload mid-turn
still shows it), streams `pendingChat.progress`, then folds its result onto
whatever the artifact says now (`mergeConcurrentEdits`) and appends a
Expand Down
13 changes: 13 additions & 0 deletions src/core/artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,19 @@ export const ArtifactSchema = z.object({
chapters: z.array(ChapterSchema).default([]),
comments: z.array(CommentSchema).default([]),
verdict: VerdictSchema.nullable().default(null),
/**
* The review body to post, written by hand. Null — the normal case — means
* the body is composed from the draft at send time (§14.4: summary,
* walkthrough, the comments that cannot post inline, footer).
*
* Everywhere else in cerber, what GitHub gets is derived from what the
* cockpit shows. This is the one place the user can cut that link, so it is
* a field rather than a rewrite of the summary: the draft underneath stays
* exactly as the review wrote it, the body can always be built again, and
* the send panel says which of the two is about to be posted. A re-review
* clears it — it described a body for a draft that no longer exists.
*/
bodyOverride: z.string().nullable().default(null),
run: RunInfoSchema.nullable().default(null),
/** Set once the review was sent to GitHub (explicitly, or via opt-in auto-send). */
sent: SentInfoSchema.nullable().default(null),
Expand Down
1 change: 1 addition & 0 deletions src/core/autosend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function makeArtifact(overrides: Partial<Artifact> = {}): Artifact {
chapters: [],
comments: [],
verdict: { recommendation: "approve", confidence: 95, reasoning: "clean" },
bodyOverride: null,
run: null,
sent: null,
refresh: null,
Expand Down
8 changes: 8 additions & 0 deletions src/core/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function artifact(over: Partial<Artifact> = {}): Artifact {
chapters: [],
comments: [],
verdict: null,
bodyOverride: null,
run: null,
sent: null,
refresh: null,
Expand Down Expand Up @@ -89,6 +90,13 @@ describe("describeChange", () => {
]);
});

it("records the body that posts leaving the review, and coming back", () => {
const plain = artifact();
const own = artifact({ bodyOverride: "Ran it locally, ship it." });
expect(describeChange(plain, own)).toEqual(["the body to post was written by hand"]);
expect(describeChange(own, plain)).toEqual(["the body to post follows the review again"]);
});

it("records a push under the review", () => {
const before = artifact();
const after = artifact({ pr: { ...before.pr, headSha: "5502944aaaa" } });
Expand Down
9 changes: 9 additions & 0 deletions src/core/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,15 @@ export function describeChange(before: Artifact | null, after: Artifact): string
);
}

// What posts stopped following the draft, or started following it again.
if ((before?.bodyOverride ?? null) !== (after.bodyOverride ?? null)) {
lines.push(
after.bodyOverride == null
? "the body to post follows the review again"
: "the body to post was written by hand",
);
}

const comments = describeComments(before?.comments ?? [], after.comments);
if (comments) lines.push(comments);

Expand Down
1 change: 1 addition & 0 deletions src/core/refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ function makeArtifact(overrides: Partial<Artifact> = {}): Artifact {
chapters: [],
comments: [],
verdict: null,
bodyOverride: null,
run: null,
sent: null,
refresh: null,
Expand Down
9 changes: 9 additions & 0 deletions src/core/revise.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function artifact(over: Partial<Artifact> = {}): Artifact {
chapters: [{ id: "one", title: "One", explanation: "Explains one.", files: ["src/a.ts"] }],
comments: [comment()],
verdict: { recommendation: "comment", confidence: 70, reasoning: "because" },
bodyOverride: null,
run: null,
sent: null,
refresh: null,
Expand Down Expand Up @@ -331,6 +332,14 @@ describe("mergeConcurrentEdits", () => {
expect(merged.verdict).toEqual(mine);
});

it("keeps a send body the user wrote while the turn was running", () => {
// A turn never writes one, so whatever the artifact says now is the user's.
const b = before();
const { artifact: after } = applyRevisions(b, [{ kind: "summary", body: "Rewritten." }]);
const current = artifact({ ...b, bodyOverride: "My own words." });
expect(mergeConcurrentEdits(b, after, current).bodyOverride).toBe("My own words.");
});

it("takes the turn's verdict when the turn did revise it", () => {
const b = before();
const theirs = { recommendation: "request_changes" as const, confidence: 80, reasoning: "found one" };
Expand Down
3 changes: 3 additions & 0 deletions src/core/revise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ export function mergeConcurrentEdits(
// there only when it actually revised one.
status: current.status,
verdict: after.verdict === before.verdict ? current.verdict : after.verdict,
// A turn never writes a send body, so one written while it ran is the
// user's and stands — as does clearing one.
bodyOverride: current.bodyOverride,
};
}

Expand Down
40 changes: 37 additions & 3 deletions src/core/send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,16 @@ function makeArtifact(overrides: Partial<Artifact> = {}): Artifact {
chapters: [{ id: "core", title: "Core", explanation: "Adds a const.", files: ["src/a.ts"] }],
comments: [],
verdict: { recommendation: "comment", confidence: 80, reasoning: "ok" },
bodyOverride: null,
run: null,
sent: null,
refresh: null,
filed: null,
settledAt: null,
calibration: null,
chat: [],
preChat: null,
pendingChat: null,
chat: [],
preChat: null,
pendingChat: null,
...overrides,
};
}
Expand Down Expand Up @@ -117,6 +118,39 @@ describe("buildReviewPayload", () => {
expect(payload.body).toContain("src/a.ts:~2");
});

it("posts the body the user wrote instead of the composed one", () => {
// The whole body, footer included: a body half-honoured is one nobody
// wrote. The draft underneath is untouched — only what posts changed.
const artifact = makeArtifact({
bodyOverride: "Looks good to me. I ran the migration locally.\n",
comments: [
{ id: "1", path: "src/a.ts", line: 2, body: "inline ok", chapterId: "core", severity: null, origin: "ai", status: "draft", editedByUser: false, originalLine: null, drifted: false },
{ id: "2", path: "src/a.ts", line: 999, body: "bad line", chapterId: "core", severity: null, origin: "ai", status: "draft", editedByUser: false, originalLine: null, drifted: false },
],
});
const payload = buildReviewPayload(artifact, "APPROVE");
// Verbatim, trailing newline and all: the panel says this is exactly what
// posts, and a body that opens on an indented line is a markdown code
// block — trimming it would silently repaint it as a paragraph.
expect(payload.body).toBe("Looks good to me. I ran the migration locally.\n");
expect(payload.body).not.toContain("## Summary");
expect(payload.body).not.toContain("cerber");
// Inline comments are a separate half of the payload and still post, and
// the cockpit still needs to know which comments had no line to land on.
expect(payload.comments).toEqual([{ path: "src/a.ts", line: 2, side: "RIGHT", body: "inline ok" }]);
expect(payload.folded.map((c) => c.id)).toEqual(["2"]);
});

it("keeps a body that opens on an indented code block", () => {
const body = " const a = 1;\n\nThat is all it needed.\n";
expect(buildReviewPayload(makeArtifact({ bodyOverride: body }), "COMMENT").body).toBe(body);
});

it("composes the body again once the override is cleared", () => {
const payload = buildReviewPayload(makeArtifact({ bodyOverride: null }), "COMMENT");
expect(payload.body).toContain("## Summary");
});

it("anchors the review to the reviewed head commit", () => {
expect(buildReviewPayload(makeArtifact(), "COMMENT").commitId).toBe("abc");
const noSha = makeArtifact({ pr: { ...makeArtifact().pr, headSha: "" } });
Expand Down
11 changes: 10 additions & 1 deletion src/core/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,16 @@ export function buildReviewPayload(artifact: Artifact, event: ReviewEvent): Revi

return {
event,
body: parts.join("\n").trim(),
// A body the user wrote by hand replaces the composed one outright,
// footer and folded notes included: it is the review's own comment, and
// half-honouring it — keeping a footer they deleted, re-appending notes
// they cut — would post something nobody wrote. It is not trimmed either,
// which is not fussiness: a body opening on an indented line is a markdown
// code block, and trimming it would silently repaint it as a paragraph.
// Only the composed body is trimmed — the parts above put a blank line in
// front of it. The composition still runs regardless, because `folded` is
// what the cockpit uses to say which comments have no line to land on.
body: artifact.bodyOverride ?? parts.join("\n").trim(),
comments: inline,
folded,
commitId: artifact.pr.headSha || undefined,
Expand Down
1 change: 1 addition & 0 deletions src/core/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ function artifact(over: Partial<Artifact> = {}): Artifact {
chapters: [],
comments: [],
verdict: null,
bodyOverride: null,
run: null,
sent: null,
refresh: null,
Expand Down
1 change: 1 addition & 0 deletions src/runner/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ function artifact(over: Partial<Artifact> = {}): Artifact {
chapters: [{ id: "one", title: "One", explanation: "Explains one.", files: ["src/a.ts"] }],
comments: [comment()],
verdict: { recommendation: "comment", confidence: 70, reasoning: "because" },
bodyOverride: null,
run: {
model: "opus",
startedAt: "2026-08-19T00:00:00Z",
Expand Down
1 change: 1 addition & 0 deletions src/runner/review-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ function ready(comments: Comment[], headSha = "old-sha"): Artifact {
chapters: [],
comments,
verdict: null,
bodyOverride: null,
run: null,
sent: null,
filed: null,
Expand Down
1 change: 1 addition & 0 deletions src/runner/review.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ function artifact(status: ArtifactStatus, headSha: string): Artifact {
chapters: [],
comments: [],
verdict: null,
bodyOverride: null,
run: null,
sent: null,
filed: null,
Expand Down
3 changes: 3 additions & 0 deletions src/runner/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,9 @@ async function performReview(
chapters: [],
comments: [],
verdict: null,
// Not carried over. A hand-written send body described the draft this run
// is replacing — the same reason the pre-chat snapshot goes.
bodyOverride: null,
run: {
model: opts.model ?? null,
startedAt: now(),
Expand Down
Loading
Loading