From 75dd6efcbbdcce2ba657e128fd3b4ed23a7c9053 Mon Sep 17 00:00:00 2001 From: Jacek Tomaszewski Date: Tue, 25 Aug 2026 09:23:48 +0000 Subject: [PATCH] docs: an RFC-style SPEC.md for the whole product MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full service specification — schemas, lifecycle, tool policy, the send path — precise enough to reimplement cerber from, written against the code and tests (which win on disagreement; known divergences are recorded in its Appendix B rather than silently resolved). It already describes the review history (#42) and the settled-row reopen rule (#43), so it should land after them. README and CLAUDE.md point at it, CLAUDE.md with the keep-it-true rule that stops it rotting. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 6 + README.md | 6 + SPEC.md | 1641 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1653 insertions(+) create mode 100644 SPEC.md diff --git a/CLAUDE.md b/CLAUDE.md index 738a75b..1636fa0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,12 @@ pending reviews, no comments, no reactions — reviewing is read-only.** rules, filing/archiving, and the re-review guards. Keep it true when you change any of them — it names the files each rule lives in. +`SPEC.md` is the RFC-style specification of the whole product — schemas, +lifecycle, tool policy, the send path, in normative language. A PR that +changes behavior the spec describes updates the spec in the same PR; a +divergence you find but don't fix goes in its Appendix B rather than being +left unwritten. Code and tests win when they disagree. + - `src/core/` — artifact schema (zod, versioned — the contract between AI and cockpit), state store (`~/.cerber/reviews/*.json`, plain JSON, no DB), diff --git a/README.md b/README.md index a64016e..4f9b38d 100644 --- a/README.md +++ b/README.md @@ -391,6 +391,12 @@ above: the seven statuses and what sets each, which tab a PR lands in and why, when the poll files or archives a row on its own, when a re-review happens and what it will not overwrite, and what gets written to disk at each step. +[**SPEC.md**](SPEC.md) is the full service specification — the artifact +schema, every lifecycle and filing rule, the runner's tool policy and +credential hygiene, the send path — written precisely enough to reimplement +cerber from. lifecycle.md is the operator's view of the product; SPEC.md is +the engineer's. + ## Development ```bash diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..f46cc83 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,1641 @@ +# Cerber Service Specification + +**Status:** Draft v1, derived from the reference implementation at schema version 1. + +**Purpose:** This document specifies cerber — an AI code-review cockpit — precisely +enough that an independent implementation could be built in any language and +behave the same way where it matters: what reaches GitHub and when, what is +stored and how, how a review is produced, revised, refreshed and retired, and +what the operator can rely on. It is written against the behavior of the +TypeScript reference implementation in this repository; where the two disagree, +the reference implementation's *code and tests* are authoritative and the +divergence is listed in [Appendix B](#appendix-b-known-divergences-in-the-reference-implementation). + +**Normative language:** The key words MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, +REQUIRED and OPTIONAL are to be interpreted as described in RFC 2119. Behavior +marked *implementation-defined* is left to each implementation, which SHOULD +document its choice. `docs/lifecycle.md` remains the operator-facing reference +for statuses, queue visibility, filing and re-review guards; this specification +restates those rules normatively and MUST be kept in agreement with it. + +--- + +## 1. Problem Statement + +Reviewing pull requests with an AI model creates a specific danger: a system +that can both draft opinions and hold GitHub credentials will eventually post +one without a human deciding it should. Cerber exists to make AI-drafted review +useful while making that outcome structurally impossible rather than merely +discouraged. + +Cerber runs on the reviewer's machine. It polls GitHub for pull requests +awaiting the user's review, checks each one out, has a coding agent (Claude, +riding the user's existing login) draft a full review into a local JSON +artifact, and presents the drafts in a local web cockpit where the user reads, +edits, discusses and finally — by an explicit click — sends the review to +GitHub. Everything before that click is read-only with respect to GitHub. + +**The one hard boundary:** cerber never writes to GitHub except (a) an +explicit, user-confirmed Send, or (b) daemon auto-send that the user explicitly +enabled with `--auto-send` — approve-only, confidence-threshold-gated, every +decision logged. No pending reviews, no comments, no reactions. Reviewing is +read-only. + +## 2. Goals and Non-Goals + +### 2.1 Goals + +- Discover PRs awaiting the user's review automatically and keep a local queue + in sync with GitHub, including retiring entries GitHub has moved past. +- Draft a complete review per PR — summary, chaptered walkthrough, graded + inline comments, verdict with confidence — into a versioned local artifact. +- Give the AI reviewer real evidence: a source checkout of the PR head by + default, command execution only for PRs whose authors the user trusts. +- Let the user edit, re-grade, drop and add comments, discuss the draft with + the reviewer that wrote it, and keep every human decision safe from + concurrent machine writes. +- Keep drafts honest as PRs move: re-anchor comments onto new heads + deterministically, and never let a stale draft post inline comments onto + code it was not written about. +- Make the send a single, fully-previewed, explicitly confirmed action — + and make auto-send a narrow, auditable, opt-in exception. +- Work with zero configuration: ride existing `gh` and `claude` logins, + plain JSON files, sane defaults, graceful degradation. + +### 2.2 Non-Goals + +- Cerber is not a CI system, a merge bot, or a PR authoring tool. It never + modifies the code under review and never pushes. +- Cerber is not a sandbox. Trusted runs execute PR code with the user's OS + account; the specification requires credential hygiene (§14), not isolation, + and implementations MUST NOT imply otherwise. +- Cerber does not manage API keys or tokens. GitHub auth belongs to `gh`; + model auth belongs to the `claude` CLI. +- Cerber does not use a database, a config wizard, or a migration framework. + State is hand-editable JSON; readers are defensive, writers are atomic. +- Cerber does not attempt multi-user operation. One state directory serves one + reviewer on one machine. + +## 3. System Overview + +### 3.1 Components + +1. **Artifact store** — one JSON file per PR under the state directory; the + contract between the AI runner and every UI surface (§5). +2. **GitHub client** — shells out to the `gh` CLI for all reads and the single + write; never handles tokens itself (§14). +3. **Checkout manager** — shallow per-PR working trees of `refs/pull/N/head`, + LRU-cached, with agent-config quarantine for untrusted PRs (§10). +4. **Review runner** — builds the review prompt, invokes the coding agent + headlessly, validates its JSON output, and folds the result onto the + artifact (§11). +5. **Chat runner** — one conversational turn about a finished draft, resuming + the review's own agent session and revising the artifact directly (§12). +6. **Re-anchoring engine** — pulls a draft's comments onto a newer PR head by + exact line-text matching, never a fuzzy guess (§13). +7. **Inbox daemon** — the poll loop: discovery, stub creation, auto-review, + archiving, filing, reopening, whose-move classification, desktop + announcements, and (opt-in) auto-send (§9, §15). +8. **HTTP server** — a local API plus static serving for the cockpit; long AI + work is detached behind `202` responses (§16). +9. **Cockpit** — a browser SPA: queue, review detail, settings, arrival bell, + favicon badge (§17). +10. **CLI** — `review`, `list`, `export`, `send`, `trust`, `prune`, `stats`, + and the default `serve` (§18). + +### 3.2 External Dependencies + +- **`gh` CLI**, authenticated — REQUIRED for all GitHub access. The `read:org` + scope is additionally required for org/team trust rules. +- **`claude` CLI**, logged in — REQUIRED for review and chat runs. +- **`git`** — REQUIRED for checkouts; a checkout failure MUST degrade to a + diff-only review, never fail the run (§10.4). +- A POSIX-ish filesystem with atomic same-directory rename. +- OPTIONAL: a desktop notifier (`osascript` on macOS, `notify-send` on Linux). + +### 3.3 The State Directory + +All durable state lives under one directory, `CERBER_HOME` if that environment +variable is set, else `~/.cerber`: + +| Path | Contents | +|---|---| +| `reviews/.json` | one review artifact per PR (§4, §5) | +| `config.json` | user settings, zod-validated (§6) | +| `autosend.ndjson` | append-only auto-send decision log (§15.3) | +| `src/____/` | PR checkouts, LRU cache of 8 (§10) | +| `run/run-*/` | per-run scratch directories, swept after 24 h (§11.4) | + +## 4. Core Domain Model + +### 4.1 Identifiers + +- **Artifact id**: `"/#"` (e.g. `acme/widgets#42`). +- **Artifact key**: the id with every `/` and `#` replaced by `__` + (`acme__widgets__42`) — filesystem- and URL-safe; it is both the artifact's + filename stem and the HTTP path parameter. + +### 4.2 Schema Versioning + +The artifact carries `schemaVersion`, currently the literal `1`. Evolution +within a version MUST be purely additive: new fields carry defaults so that +every previously written artifact still parses. Making a field required, +renaming it, or narrowing its type REQUIRES a version bump plus a migration +for artifacts already on disk. There is no migration machinery today because +none has been needed. One deliberate deviation from defaults-for-everything: +`history` is optional with *no* default, because absent (predates the log) +and empty mean different things (§5.4). + +### 4.3 Enumerations (exhaustive) + +| Enum | Literals | +|---|---| +| Artifact status | `awaiting`, `running`, `ready`, `reviewed`, `sent`, `skipped`, `failed` | +| Severity | `blocker`, `minor`, `nit` (or `null` = not a finding) | +| Comment origin | `ai`, `user` | +| Comment status | `draft`, `approved`, `dropped` | +| Verdict recommendation | `approve`, `comment`, `request_changes` | +| PR state | `OPEN`, `CLOSED`, `MERGED` | +| Run trigger | `daemon`, `user` (nullable; `null` = legacy) | +| Sent event | `APPROVE`, `COMMENT`, `REQUEST_CHANGES` | +| Filed reason | `own-review`, `own-reply`, `request-withdrawn` | +| Chat role | `user`, `assistant` | +| Revision kind | `summary`, `verdict`, `chapter`, `comment-edit`, `comment-drop`, `comment-add` | +| Reply classification | `none`, `you`, `them`, `unknown` | +| Auto-send mode | `shadow`, `on` | +| History actor | `daemon`, `cockpit`, `cli`, `runner`, `unknown` | + +### 4.4 The Artifact + +The root object. Required fields have no default; defaulted fields MAY be +absent on read and materialize with the stated value. + +| Field | Type | Requirement | +|---|---|---| +| `schemaVersion` | literal `1` | REQUIRED | +| `id` | string, `owner/repo#N` | REQUIRED | +| `status` | status enum | REQUIRED | +| `createdAt`, `updatedAt` | ISO-8601 string | REQUIRED | +| `pr` | PrInfo | REQUIRED | +| `diff` | string — the full unified diff as fetched | REQUIRED | +| `summary` | markdown string | default `""` | +| `chapters` | Chapter[] | default `[]` | +| `comments` | Comment[] | default `[]` | +| `verdict` | Verdict \| null | default `null` | +| `run` | RunInfo \| null | default `null` | +| `sent` | SentInfo \| null | default `null` | +| `filed` | FiledInfo \| null | default `null`; never set on a sent artifact | +| `settledAt` | ISO-8601 string \| null | default `null`; when the row was settled — see below | +| `refresh` | RefreshInfo \| null | default `null` | +| `calibration` | Calibration \| null | default `null` | +| `chat` | ChatTurn[] | default `[]`; never sent to GitHub | +| `pendingChat` | PendingChat \| null | default `null` | +| `preChat` | ReviewSnapshot \| null | default `null` | +| `history` | HistoryEntry[] | OPTIONAL, **no default** — absent means the artifact predates history being kept, which surfaces MUST say rather than showing an empty log (§5.4) | + +**PrInfo:** `owner`, `repo`, `number` (int > 0), `title`, `url`, `author`, +`baseRefName`, `headRefName` REQUIRED; `body` default `""`; `headSha` default +`""` (moved forward by refresh, §13); `state` default `"OPEN"`; `isDraft` +default `false` (GitHub reports drafts as `OPEN`, so it is a separate flag); +`additions`, `deletions`, `changedFiles` default `0`. + +**Chapter:** `id`, `title`, `explanation`, `files: string[]` — all REQUIRED. +The unit of the review walkthrough; each changed file belongs to exactly one +chapter (enforced by de-duplication on ingest, §11.6). + +**Comment:** + +| Field | Type | Requirement | +|---|---|---| +| `id` | string (UUID in the reference) | REQUIRED | +| `path` | string | REQUIRED | +| `line` | int > 0 \| null | REQUIRED; `null` = file-level; numbering is always the NEW side of the diff | +| `body` | string | REQUIRED | +| `chapterId` | string \| null | default `null` | +| `severity` | severity \| null | default `null` — `null` means *not a finding* (question, note, praise), never "unknown" | +| `origin` | `ai` \| `user` | REQUIRED | +| `status` | `draft` \| `approved` \| `dropped` | default `draft` | +| `editedByUser` | boolean | default `false`; set when the user rewrites an AI comment (a calibration signal, and an ownership marker for chat, §12.4) | +| `originalLine` | int \| null | default `null`; where the comment was originally written when a refresh moved it | +| `drifted` | boolean | default `false`; the commented line is no longer in the diff, so the comment cannot post inline | + +Comments are drafts. They MUST NOT reach GitHub except inside an explicitly +sent review. Both `draft` and `approved` comments are included in a send; +`dropped` ones are excluded but retained in the artifact (struck through in +the cockpit) — dropping is a marking, deleting is removal, and only the user +may delete (and only their own comments). + +**Verdict:** `recommendation` (enum), `confidence` (number 0–100 inclusive), +`reasoning` (string) — all REQUIRED. Confidence is the review's confidence in +*itself* — findings real, grades right, nothing blocking missed — never a +judgment on whether the PR should merge (the recommendation carries that). It +is also the dial the auto-send threshold reads (§15). + +**RunInfo:** `startedAt` REQUIRED; `model`, `finishedAt`, `costUsd`, `error`, +`sessionId`, `reviewedSha` nullable with default `null`; `withSource`, +`trusted` default `false`; `trigger` (`daemon`/`user`/null) default `null`. +Two fields are load-bearing: + +- `reviewedSha` — the head commit the run actually read, set on completion. + This, not `pr.headSha`, is the freshness comparand (§8.4), because refresh + moves `pr.headSha` forward without any re-reading. +- `sessionId` — the agent session id, recorded **only for source-backed + runs** (a session keyed to an empty directory is useless to resume); it is + what chat turns resume (§12.2). + +**SentInfo:** `at`, `event` REQUIRED; `url` default `null`; `auto` default +`false` (true only for daemon auto-send). + +**FiledInfo:** `at` REQUIRED; `reason` default `"own-review"` (the +first-shipped reason, so legacy artifacts keep their meaning); `review` +(`{at, state ∈ {APPROVED, CHANGES_REQUESTED, COMMENTED}, url}`) set when the +reason is `own-review`; `reply` (`{at, url}`) set when `own-reply`; nothing +extra for `request-withdrawn`. + +**`settledAt`** dates the settling decision — marked `reviewed` or `skipped` +by the user, or filed by the poll — and is null on anything not settled. It +exists because a settle answers the review request that was *open at the +time*, and cannot have answered one that arrived afterwards; the poll needs to +know which side of the decision a request falls on (§9.6). `updatedAt` cannot +stand in for it: merely opening a settled review refreshes it onto the new +head, moving that field forward long after the decision it would be dating. +Every settling path MUST stamp it (§8.2) and every reopening path MUST clear +it. + +**RefreshInfo:** `at`, `fromSha`, `toSha` REQUIRED; `moved`, `drifted` +counters default `0`. + +**Calibration** (written at send time, §14.4): `aiRecommendation` (nullable), +`aiConfidence` (nullable), `sentEvent`, `aiCommentsTotal`, +`aiCommentsDropped`, `aiCommentsEdited`, `userCommentsAdded`. + +**ChatTurn:** `id`, `role`, `at`, `body` REQUIRED; `refs` (user turns), +`revisions` (assistant turns), `refused` default `[]`; `costUsd` default +`null`. A **Ref** is `{target ∈ {summary, verdict, chapter, comment, line}, +id, path?, line?, side? ∈ {new, old}}` — `side: "old"` lets a removed line, +which exists only on the old side of the diff, still be pointed at. + +**Revision** — a discriminated union on `kind`: + +- `{kind: "summary", body}` +- `{kind: "verdict", verdict}` +- `{kind: "chapter", chapterId, title?, explanation?}` — partial patch +- `{kind: "comment-edit", commentId, body, severity?}` — **`severity` omitted + means "grade unchanged"; explicit `null` means "not a finding".** This is + the one place in the model where absent and null differ, and + implementations MUST preserve the distinction. +- `{kind: "comment-drop", commentId}` — sets `status: "dropped"`, never deletes +- `{kind: "comment-add", path, line, body, chapterId, severity}` — creates an + `origin: "ai"`, `status: "draft"` comment + +A **Refusal** is `{revision, reason}` — a revision the artifact would not +accept, recorded on the assistant turn and rendered in the transcript (§12.4). + +**PendingChat:** `{message, refs, startedAt, progress: string[], error: +string | null}` — the in-flight (or failed) chat turn. It is written before +the turn starts, survives page reloads, carries the turn's live progress +narration, and holds the failure when there is no HTTP response left to hold +it (§16.3). + +**ReviewSnapshot** (`preChat`): `{at, summary, chapters, comments, verdict}` — +exactly the fields a chat turn may rewrite; the target of chat reset (§12.6). + +**HistoryEntry:** `{at, by, what, cause}` — `at` REQUIRED; `by` a history +actor (default `unknown` — nothing claimed the write); `what` a plain-words +line ("status ready → skipped"); `cause` (default `null`) what was being done +at the time ("PATCH /api/reviews/…", "poll", "review"). The full history +contract lives in §5.4. + +### 4.5 AI Output Contracts + +The model's output is a strict subset of the artifact, validated before use: + +``` +review turn: { summary: string, + chapters: [{id, title, explanation, files: string[]}], + comments: [{path, line: int>0|null, body, + chapterId?: string|null, severity?: severity|null}], + verdict: {recommendation, confidence: 0-100, reasoning} } + +chat turn: { reply: string, revisions: Revision[] } +``` + +Cerber owns everything else on a comment — ids, origin, status, +`editedByUser`, anchor fields — and the model MUST NOT be able to set them. + +## 5. State Store + +### 5.1 Write Contract + +Artifact and config writes MUST be atomic: write to `.tmp` in the same +directory, then rename over the target. A reader must never observe a partial +file. Artifacts are serialized as pretty-printed JSON (2-space indent) because +the files are a user-facing surface — hand-editable, `cat`-able. There is no +lock file and no fsync; concurrency is handled above the store by fold-on-write +merges (§11.7, §12.5), so the worst concurrent outcome is a wasted run, never +a corrupt file. + +Every keyed update (`load → mutate → save`) MUST stamp `updatedAt` with the +current time, overriding whatever the mutator produced. Every save also +appends to the artifact's history as a property of writing itself (§5.4). + +### 5.2 Read Contract + +- Loading a single artifact: file-not-found returns null (absence is an + answer); every other failure — bad JSON, schema violation — throws. Silence + about corruption on a direct read would hide exactly the error the user + needs to see. +- Listing artifacts: a missing directory is an empty list; entries that fail + to parse are **skipped** so one corrupt file cannot take down the whole + queue; `.tmp` files are ignored. The list is sorted by `updatedAt` + descending. +- Deleting an artifact MUST only ever target a pure discovery stub (§9.3); + everything else holds work. + +### 5.3 Startup Reconciliation + +Before any polling starts, an implementation MUST reconcile crashed runs: for +every artifact with `status: "running"` not owned by a live in-process claim, +set `status: "failed"` and record +`run.error = "interrupted — cerber restarted while this review was running"`; +for every `pendingChat` with `error == null`, stamp the analogous chat +interruption error while leaving the status untouched (a pending chat does not +own the status, §12.1). Already-errored pending chats are left as they are. + +Known accepted false positive: a `cerber review` running in another terminal +gets stamped `failed`, and simply overwrites the artifact when it finishes. + +### 5.4 The History Log + +Every review keeps a record of what happened to it — cerber's side of the +story. The artifact keeps one `updatedAt`, so without this every write erases +the answer to "when did this become skipped, and did anything ask for it again +afterwards?". + +**Appended by the store, never by callers.** The save path itself derives and +appends history: it re-reads the file from disk on **every** save — even when +the caller just read it, because two writers share these files and appending +to the caller's copy would drop whatever the other recorded in between — and +**ignores any history the caller hands in**; disk is the only current copy. +Several write paths legitimately overwrite an artifact wholesale from a copy +built minutes earlier; a log any of them had to remember to carry would be +lost by the first that didn't, and a write path added later is recorded +without knowing history exists. If the prior file exists but cannot be read +(a hand-edit broke it), the save still overwrites it as before, but the +history MUST restart with an entry saying so rather than quietly claiming the +review began at that moment. + +**A watchlist, not a deep diff.** Entries are derived by comparing the prior +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 +turn's narration, which is rewritten every couple of seconds. + +**Decision notes.** The poll's deliberate silences — the only kind of history +a diff cannot see — are recorded as notes: a decision that changed nothing. +A note MUST NOT touch `updatedAt` (a note must not reorder the queue), MUST +be dropped when it repeats the log's last entry (a decision the poll re-takes +every few minutes is said once), and when dropped MUST skip the disk write +entirely. Repeat detection MUST compare against the last entry's text, never +the log's length — at the cap an appended entry trims an older one and leaves +the length unchanged. The notes the reference records: a settled row left +alone on a push ("you marked it skipped…"), an up-to-date head not +re-reviewed (carrying the SHA, so the note speaks again when the head moves), +someone having answered you (draft stays out for you), and the one fact +GitHub cannot be asked for later — the awaiting search said nobody is asking +while the PR itself still listed you as requested. + +**Writer attribution.** Each entry carries `by` (which part of cerber wrote +it) and `cause` (what it was doing). Attribution is ambient — set once per +entry point rather than threaded through every call site: the HTTP layer +labels every route `cockpit` with `METHOD /path`; the poll labels itself +`daemon`/`poll`; the CLI labels itself `cli`/``; an AI run labels +itself `runner`. Nested contexts win, so a run started by a cockpit click is +the runner's. The runner's context MUST begin *after* the freshness guards +(§8.4): a review that did not happen has no runner to blame, and "who wanted +one" is the fact the guard notes exist to keep. + +**Bounding and omissions.** The log is capped (500 entries in the reference) +as a backstop, with the oldest surviving entry preceded by a marker that +earlier entries were dropped. Two records are deliberately left out: GitHub's +own timeline (pushes, requests, reviews — `gh` can always be asked again; +mirroring it is a database wearing a different hat) and the chat, which +already carries its own turns, timestamps and revisions. + +Surfaces: the cockpit shows the history as a collapsed card at the foot of +the review, newest first; `cerber history ` prints it (§18). Both MUST +present an absent log as "predates history being kept", not as an empty one. + +## 6. Configuration + +### 6.1 File and Schema + +`/config.json`, one flat JSON document: + +| Field | Type | Default | Notes | +|---|---|---|---| +| `trust` | string[] | `[]` | trust rules, §14.2; each line validated at load | +| `daemon.poll` | boolean | `true` | discover awaiting PRs at all | +| `daemon.autoReview` | boolean | `true` | draft a review for what the poll finds | +| `daemon.notify` | boolean | `true` | desktop announcements on this machine | +| `daemon.intervalMinutes` | positive int | `5` | | +| `daemon.parallel` | positive int | `3` | concurrent AI runs | +| `daemon.repos` | string[] | `[]` | `owner/repo` filters; empty = everything `gh` can see | + +Auto-send has deliberately **no** config-file surface; its mode and threshold +exist only as CLI flags (§15.1), so enabling it requires a decision at every +launch. + +### 6.2 Load and Save Semantics + +- A missing file is full defaults — "no config" MUST be a working state + (polling and auto-review on, trust empty so every run is read-only). +- An *invalid* file MUST throw, never fall back to defaults: silently + reverting would be an invisible security-relevant change (a typo'd trust + file must not quietly become "trust nobody ran nothing" or vice versa). + The error names the file and lists each problem. +- Saves validate first, write atomically, and strip unknown keys. +- Partial documents are fine: absent fields take their defaults. + +### 6.3 Reload and Precedence + +- The daemon MUST re-read the config on **every poll tick**, so cockpit + toggles for `poll` and `autoReview` (and `notify`) apply on the next tick + without a restart. `intervalMinutes`, `parallel` and `repos` are captured at + daemon start and need a restart. Trust rules are re-read by every review run. +- CLI `--no-poll`, `--no-auto-review`, `--no-notify`, `--no-trust` **cap** the + config for that process — effective value = flag AND config — and can never + re-enable something the config disabled. `--no-source` is the one exception: + it sets the default a run starts from, and a per-re-review override exists + either way (§16.2). + +## 7. Severity, Verdict and Confidence + +This section is the review's semantic core; every surface must agree with it. + +### 7.1 Grades + +Findings are graded `blocker | minor | nit`. Only a blocker blocks approval. +`minor` is "should fix, author's call"; `nit` is taste. An ungraded comment +(`severity: null`) is **not a finding** — a question, a note, praise — and is +deliberately rendered bare, because to a reader fluent in review convention an +unprefixed comment reads as "please change this", so badging a question would +make it look like a finding and leaving a nit unbadged would invert it. + +Grades assume the finding is true. Doubt lives in the verdict's reasoning, +never in a softened grade; a model unsure of a finding should drop it or ask +it ungraded, not down-grade it. + +### 7.2 Verdict Derivation + +Once anything is graded, the verdict follows mechanically from the worst +finding still standing: any live blocker → `request_changes`; none → +`approve`. There is nothing in between — "I take no position" is the same +hedge as a softened grade, one field over. `comment` is reserved for the one +review this rule cannot reach: the review that graded nothing at all and only +asks questions. + +Severity is advisory: no code derives or enforces the verdict from it. The +cockpit only *points out* disagreement (§17.5), and the blocker count is shown +beside the verdict wherever there is room, because that is the fact a reader +can check the verdict against. + +### 7.3 Confidence + +Confidence (0–100) is a different claim from the verdict: how sure the review +is of itself. It rides along wherever the verdict is shown and is spelled out +("72% sure of the findings") beside the findings it is about. It is the +auto-send threshold's input and nothing else derives from it. + +### 7.4 Rendering + +The grade is stored once (`comment.severity`) and rendered, never written into +the body. One badge function produces ` **** — ` with +`blocker → 🚨`, `minor → ⚠️`, `nit → 🎨`, applied identically in the cockpit, +in the GitHub payload and in the markdown export — so a comment reads the same +in all three, the body the user edits is exactly the body that gets sent, and +the grade survives GitHub, where a UI chip would not exist. + +## 8. Review Lifecycle State Machine + +### 8.1 Statuses and Ownership + +Exactly one status at a time, and each status has one writer class: + +| Status | Meaning | Owner | +|---|---|---| +| `awaiting` | GitHub requests you; nothing drafted | the daemon (discovery stub) | +| `running` | an AI review run in flight | the runner (and the server, pre-202) | +| `ready` | a draft exists, wants your reading | the runner | +| `reviewed` | you are done with it — or the poll filed it | the user, or filing (§9.5) | +| `skipped` | you decided not to review | the user | +| `sent` | the review reached GitHub | the send path | +| `failed` | the run errored | the runner / startup reconciliation | + +Derived sets used throughout: + +- `SETTLED = {sent, reviewed, skipped}` — out of the live queue. +- `SETTLED_BY_YOU = {reviewed, skipped}` — a decision no push may undo; the + single thing that reopens one is a review request made *after* the settle + (§9.6). +- `HEAD_SENSITIVE = {ready, sent}` — the two statuses that track the PR head. +- **Archived is not a status**: it is `pr.state !== "OPEN"`, an orthogonal + fact that moves a row to the archive tab whatever its status. + +The status-set API (`PATCH`) MUST accept only `reviewed` and `skipped` — every +other status is a claim about machine work that only the responsible machine +path may make. + +### 8.2 Transitions + +Entering `running` (every review run passes through it): + +``` +(nothing) ── user pastes a URL / `cerber review` ──▶ running +awaiting ── the poll, when auto-review is on ──▶ running +ready | sent ── the poll, once the head has moved ──▶ running +failed ── the poll, next time round ──▶ running +any but sent ── the cockpit's re-review button (forces) ──▶ running +anything ── `cerber review --force` ──▶ running +``` + +Leaving `running`: AI answered → `ready`; errored → `failed`; process +restarted → `failed` (§5.3). + +Settling: the user marks any unsent row `reviewed`/`skipped`; the poll files a +`ready` draft GitHub moved past (§9.5); Send/auto-send makes it `sent`. +Skipping is offered on `awaiting`, `running` and `failed` too — settling needs +no draft. Every settling path MUST stamp `settledAt`: the status route stamps +it at the moment of the click (and clears any `filed` note — cerber's account +of why the row was settled stops being true the moment the user settles it +themselves), and filing stamps it with the filed time. + +Reopening — the one transition out of `SETTLED_BY_YOU` without a click: + +``` +reviewed | skipped ── the poll, a review request naming you + made after settledAt (§9.6) ──▶ ready | awaiting +``` + +`ready` if the row holds a finished, error-free draft; `awaiting` if not (the +poll then drafts it again). Reopening clears `settledAt` and `filed`. A push +alone still never reopens a settled row. + +A chat turn is an AI run but **never changes the status**; it lives on +`pendingChat` (§12.1). + +### 8.3 A Sent Artifact Is Immutable History + +Refresh no-ops on it, re-review refuses (`409`), chat refuses, chat reset +refuses, filing skips it, a second send refuses. The only thing that can +happen to a sent artifact is archiving when the PR closes. + +### 8.4 The Freshness Guard + +The single entry point for every review run checks, in order (all skipped +under `force`): + +1. Status in `SETTLED_BY_YOU` → **skip**, and note it in the history ("you + marked it skipped, so a new push or review request does not reopen it" — + the poll's most confusing silence, §5.4). A row dragged back into the + inbox every time the author pushes would be cerber overruling the user; + the ways back in are the re-review button, which forces, and a newer + review request (§9.6). +2. Status in `HEAD_SENSITIVE` and the head the last run *read* + (`run.reviewedSha`, falling back to `pr.headSha` for legacy artifacts, + with an empty string never matching) equals the PR's current head → + **skip** as up to date, with a history note carrying the SHA so the note + speaks again the next time the head moves. +3. Otherwise run. + +The comparand MUST be `run.reviewedSha`, not `pr.headSha`: refresh moves +`pr.headSha` forward whenever a draft is merely opened (§13.2), so comparing +against it would let *opening* a stale draft convince the guard the draft was +current. A `sent` row being head-sensitive is deliberate: submitting cleared +GitHub's request, so a fresh request only arrives when someone asks for +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: +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. + +What MUST survive any run, on both its success and failure paths, is every +*decision*: a send that landed mid-run, a `reviewed`/`skipped` set while the +run worked (together with the `settledAt` that dates it — the stamp travels +with the status, so a settle keeps both halves of itself and a forced +re-review clears both), the `filed` record, the `calibration`, and the chat +transcript. +The run's result is folded onto whatever the artifact says now rather than +written over it; on failure, the status becomes `failed` only if the user does +not own it (`sent`/`reviewed`/`skipped` stand, with `run.error` recorded +beside them). The pre-chat snapshot does not survive — it described a draft +that no longer exists. + +## 9. Discovery, Polling and Reconciliation + +### 9.1 The Poll Loop + +The daemon polls end-to-start: each tick is scheduled `intervalMinutes` after +the previous one *finishes*, ticks never overlap (a re-entrancy guard drops a +tick that arrives mid-poll), and the first tick fires immediately at startup — +after startup reconciliation (§5.3), which MUST run first. + +Each tick, in order: + +1. Re-read config (§6.3). If `daemon.poll` is off: publish an empty awaiting + list ("discovery you turned off makes no claim") and stop here. +2. **Discover**: search GitHub for open, non-archived-repo PRs with a review + requested from the current user (one search per repo filter, or one global; + results deduplicated by artifact id; draft PRs included — an explicit + request is an explicit request; archived repos excluded because a review + there could never be sent). +3. **Classify** whose move each awaiting PR is (§9.7) and publish the list on + the daemon status. +4. **Sync the queue** (§9.3–9.6): create stubs for new arrivals, archive + merged/closed PRs, reap dead stubs, file drafts GitHub moved past, and + reopen settled rows someone has asked about again. +5. **Announce** new arrivals on this machine (§9.8) — before drafting, because + the arrival is the news and drafting takes minutes. +6. If auto-review is on: run a review for every awaiting PR (bounded by + `parallel`; the freshness guard makes already-current drafts free), and + evaluate auto-send for each *fresh* result (§15). + +A failed poll keeps the last good awaiting list on display, with the error +published beside it — a network blip must not make the cockpit claim "nothing +awaits you". + +### 9.2 Discovery Stubs + +A newly discovered PR gets a stub artifact: `status: "awaiting"`, the search +result's PR fields, everything else empty. The stub is schema-valid and is +also the announcement ledger (§9.8). + +### 9.3 Pure Stubs and Reaping + +`isPureStub = status === "awaiting" && comments.length === 0 && run === null`. +All three conjuncts are load-bearing: a user comment or **any** run block — +even a failed one — makes the artifact non-stub, and the server's create +endpoint persists a run block before answering precisely so a hand-pulled +review is never reaped. + +A pure stub that has left the awaiting search is checked against the PR: if +the PR closed or merged, its state is written (→ archived); if it is still +open, the stub is **deleted** — it held no work, and GitHub no longer asks. +Search lag at worst re-creates a deleted stub on the next poll. + +### 9.4 Archiving and Rate-Limited State Checks + +Non-stub artifacts get their PR state re-checked on a leash: at most 10 +artifacts per poll, each at most once per 30 minutes (in-memory bookkeeping; a +restart merely re-checks sooner). A closed/merged PR gets `pr.state` (and +`isDraft`) patched — nothing else — which is what moves it to the archive tab. +An open one proceeds to the filing check. Errors are swallowed; bookkeeping +waits for the next poll. + +### 9.5 Filing: Settling a Draft the User Settled Elsewhere + +Only a **`ready`, unsent** draft can be filed. Three reasons, checked +most-telling first; the first applicable branch is terminal either way (a +found review that fails its guard MUST NOT fall through to weaker evidence): + +1. **`own-review`** — GitHub holds a submitted review of yours on this PR + (states `APPROVED`/`CHANGES_REQUESTED`/`COMMENTED`; `PENDING` and + `DISMISSED` do not count). Strongest: GitHub itself counts it. +2. **`own-reply`** — you commented on the PR conversation and no human has + answered since (§9.7). The ball is with the author. +3. **`request-withdrawn`** — you never spoke and nobody is asking any more. + Weakest — its only evidence is a search index's silence — so before + filing, the implementation MUST confirm against the PR itself that the + review request is really gone, and MUST treat *any* pending team request + as possibly-you (a team request names the team, never its members). + +Someone *answering* your comment deliberately files nothing: that reply is +addressed to you — and the decision is written into the history ("someone has +answered you on the PR — the draft stays out for you", §5.4). Likewise, when +the withdrawal confirmation finds the PR still listing you despite the search +index's silence, that disagreement is noted — the one fact GitHub cannot be +asked for later. + +Two guards protect drafts the user asked for: + +- Reasons 1–2 use `filedByYourAct`: a draft with `run.trigger === "user"` + whose run started **at or after** your GitHub act is a second opinion you + requested after having spoken, and is spared. Timestamps MUST be parsed, + not string-compared (run timestamps carry milliseconds; GitHub's do not). + A `null` trigger (legacy) files — GitHub's own timestamp is corroboration + enough, and the re-review that would un-file it records a trigger. +- Reason 3 uses `filedByWithdrawnRequest`: `run.trigger === "daemon"` + **only**. Here `null` refuses — with no corroborating fact, an unknown must + mean no. The two guards read `null` in opposite directions deliberately. + +Filing re-reads the artifact from disk and re-checks the predicate inside the +atomic update, so a run or send that started during the GitHub reads is not +filed out from under. A filed draft is set `reviewed` with a `filed` record +and `settledAt = filed.at`; it is never deleted, never sent, and remains +openable and sendable. + +### 9.6 Reopening: Asked Again After Settling + +Filing's mirror image. Filing retires rows GitHub has moved past; this brings +one back when GitHub has moved *to* it — both exist because the queue's +picture of who is waiting on what is local, while the thing it pictures lives +on github.com. + +The gap it closes: a skip answers the review request that was open at the +moment it was made. An author who withdraws the request mid-look, pushes, and +re-requests leaves the row sitting in settled — invisible — while someone +actively waits. Every ordinary read GitHub offers says only *whether* a +request is open, never *when* it was made, so the poll reads the PR's +timeline for the last review-requested event naming the user. + +Rules, all normative: + +- Only rows the awaiting search returned are checked at all — GitHub is + asking right now, so the extra read answers exactly one question: was that + request made after the settle? An unsettled row MUST cost nothing (no + GitHub call). +- The settle date is `settledAt` when present. Rows settled before the field + existed fall back to the latest moment the decision provably came after: + `filed.at`, else `run.finishedAt` (a draft cannot be settled before it + exists). Both are lower bounds, so the worst error is one row returning + once — against every pre-existing settled row staying unreachable forever, + which is the bug. A `sent` row is never a candidate. +- The timeline read SHOULD be one call regardless of PR history (the + reference uses GraphQL `timelineItems(last: 100, itemTypes: + [REVIEW_REQUESTED_EVENT])` — the page maximum, because a PR that cycled + through a dozen reviewers could push the request naming *you* out of a + smaller window, silently restoring the bug). +- **Only requests naming the user count.** Team requests are deliberately + ignored here even though the withdrawal confirmation (§9.5) honors them: + refusing to *file work away* is the safe side of that question, while this + one *undoes a decision of the user's*, where the safe side is doing nothing + unless somebody named them. +- Timestamps are parsed, never string-compared (same millisecond trap as + `filedByYourAct`). +- The check rides its own leash with the same bounds as the state checks + (§9.4): per-artifact minimum interval and a per-poll cap, with the stamp + spent *before* the call — so an outage cannot turn every settled row into + a retry on every poll. A failed read is evidence of nothing and changes + nothing; the next expired leash retries. +- The reopen is an atomic update that re-checks the predicate against the + fresh on-disk artifact (a re-review or send may have started during the + read). It sets status to `ready` when a finished, error-free draft exists, + else `awaiting`; clears `settledAt`; and clears `filed` — cerber's account + of why the row was settled is not the story of a row that is back. From + there the ordinary rules take over: the freshness guard re-drafts if the + head moved since the run read the code, and leaves a current draft alone. + +This is the **only** thing that reopens a `reviewed`/`skipped` row. A push +still does not. No dedicated UI is required: the row simply reappears in the +inbox, and the daemon logs the reopen with the request's date. + +### 9.7 Whose Move Is It + +For every awaiting PR, the poll reads the PR conversation (issue comments +only — inline review comments arrive attached to a review, which would have +cleared the request) and classifies: + +- `none` — you have no human comment there; +- `you` — your comment is the last human word (→ "waiting on them"); +- `them` — a human other than you spoke strictly later than your last word; +- `unknown` — the read failed; the implementation MUST claim nothing rather + than guess silence at someone who did reply. + +Bots are excluded on both signals GitHub offers (account type `Bot` and the +`[bot]` login suffix) — a CI bot posting on every push must not read as "they +replied last". Equal timestamps do not hand the move back. An open review +request only means you never pressed GitHub's review button, not that anyone +is blocked on you — so this classification is a label on the row, never a +filter. The filing reason `own-reply` and this label MUST be computed through +the same classification so they can never disagree about who spoke last. + +### 9.8 Desktop Announcements + +Each new PR is announced on the machine as it lands — `osascript` on macOS, +`notify-send` on Linux, quiet elsewhere — because the cockpit's own bell needs +a live, permitted tab, which is exactly what is missing when the user is away +from cerber. + +- **The stub artifact is the ledger**: a PR is announced only on the poll that + first writes its stub. Repeat polls announce nothing; a restart re-announces + nothing; a PR that arrived while cerber was down is still news on the next + run. +- One poll = at most one notification: a batch of arrivals is one interruption + ("N PRs await your review", first three named). +- Notification text contains PR titles — attacker-controlled strings — and + MUST be passed safely: escaped into the AppleScript literal on macOS; + behind `--` on Linux so a title like `--help me` cannot be parsed as a flag. +- The notifier call MUST be bounded (5 s in the reference) and never throw: + the poll awaits it, and an unbounded hang would stop discovery entirely. +- The daemon publishes whether announcements actually work here + (`status.notify`), recomputed immediately after each attempt, so the + cockpit's browser bell can stand down when the machine tap works and take + over the moment it does not (§17.4). + +## 10. Checkout Management + +### 10.1 Layout and Preparation + +One shallow working tree per PR at `/src/____`. +Preparation is idempotent: + +1. Init the directory if needed; remote = the **base** repo's HTTPS URL. +2. Fetch `refs/pull/N/head` with `--depth=1 --no-tags` — this resolves fork + PRs with no extra remotes. +3. Force-checkout `FETCH_HEAD` detached; `git clean -fdx` so every run starts + from the commit's own files, including wiping a previous run's quarantine. +4. **Quarantine** (untrusted PRs only): rename `.claude/settings.json`, + `.claude/settings.local.json` and `.mcp.json` to `.cerber-quarantined` + — renamed, not deleted, so the reviewer can still read and comment on + them. A trusted checkout keeps them: a trusted PR is deliberately allowed + to configure its own run. +5. Record the resolved SHA; touch the directory (mtime is the LRU key). + +### 10.2 Credential Hygiene + +The fetch credential (`gh`'s git credential helper) MUST be passed per-command +(`-c credential.helper=…` on the fetch invocation) and never written into the +clone's config; preparation additionally strips any credential helper a +previous version persisted. See §14.3 for the run-time environment. + +### 10.3 Cache Eviction + +The checkout directory is an LRU cache of **8**, evicted newest-kept as +reviews run; a checkout an active run is using consumes a keep slot and is +never evicted. `cerber prune` reclaims checkouts whose review is done (sent, +closed, or gone); `--all` removes everything. + +### 10.4 Checkout Is Never a Precondition + +If git or `gh` cannot produce a checkout, the implementation MUST log why and +review from the diff alone: `run.withSource = false`, every tool off (§11.3), +no session recorded. A missing checkout never fails a review. + +## 11. The Review Runner + +### 11.1 Run Options and the In-Flight Claim + +A run is parameterized by: model override, `force`, `withSource` (default +true), `trust` (tri-state: explicit true/false overrides config; undefined +defers to the trust rules), and `trigger` (`daemon`/`user`). + +No AI run — review or chat — may start without claiming the artifact id in +the process-local in-flight registry, and the claim MUST be released in a +`finally`. The registry is deliberately in-memory: cross-process collisions +(a `cerber review` in another terminal) are accepted, because artifact writes +are atomic and merges are fold-on-write — the worst case is a wasted run, not +corruption. The persisted `running` status is the only cross-process signal, +and the send paths check both (§14.4). The child-process timeout (§11.5) is +what keeps a wedged agent from holding a claim forever. + +### 11.2 Run Sequence + +1. Claim in-flight; fetch PR info; apply the freshness guard (§8.4). The + guards run in the *caller's* writer context (§5.4) — a review that did not + happen has no runner to blame — and only from here on do writes belong to + the runner. +2. Fetch the diff; resolve trust (§14.2); prepare the checkout (§10), + catching failure into diff-only mode; evict old checkouts. +3. Persist the `running` artifact **before** the agent starts: fresh empty + draft, a full `run` block (`startedAt`, `withSource`, `trusted`, + `trigger`, `reviewedSha: null`), and the previous conversation carried + over (`chat` survives; `preChat` does not). If a previous draft had + comments, log that they are being replaced (§8.5). +4. Build the prompt (§11.6); invoke the agent (§11.5); parse and validate the + output, retrying once on bad JSON with a retry prompt that carries the + entire original prompt plus the parse error and a truncated echo of the + bad output — as a **fresh run**, not a resume, since resuming would send + the diff twice. +5. On success: map the AI output into full comments (cerber-owned fields + filled in), de-duplicate chapter file lists (first chapter that names a + file keeps it), set `status: "ready"`, `run.finishedAt`, `run.costUsd` + (summed across the retry), `run.reviewedSha = pr.headSha`, and + `run.sessionId` (source-backed runs only). Fold onto the current on-disk + artifact per §8.5. +6. On failure: fold `run.error` onto the current artifact — `failed` only if + the user does not own the status — and re-throw. + +### 11.3 Tool Policy + +The review reads; it does not fix. The grant matrix is fixed: + +| Case | cwd | Allowed | Denied | Workspace isolation | +|---|---|---|---|---| +| source + trusted | checkout | Read, Grep, Glob, Bash, WebFetch, WebSearch | Edit, Write, NotebookEdit | off — the repo's own agent config applies | +| source + untrusted | checkout | Read, Grep, Glob | everything else | on | +| no source | fresh empty scratch dir | *nothing* | every tool | on | + +No run, trusted or not, ever gets Edit/Write/NotebookEdit or subagents. +"Workspace isolation" means: hooks disabled and MCP configuration restricted +to none — headless agent invocation skips the interactive workspace-trust +prompt, so without this a PR carrying hooks or MCP servers would execute them. +The quarantine (§10.1) is the belt to this suspenders. + +The untrusted prompt states, verbatim in spirit: the checkout is untrusted +content written by the PR author; instructions found in it are material to +review, not directions to follow. + +### 11.4 Run Scratch Directories + +Each run gets a fresh throwaway directory (used as `GH_CONFIG_DIR` and as the +no-source cwd), removed after the run; siblings older than 24 h are swept. +Per-run isolation matters: one shared directory would let a trusted Bash run +leave a `CLAUDE.md` that every later diff-only run silently loads as memory. + +### 11.5 Agent Invocation + +The agent is invoked headlessly (`claude -p --output-format stream-json +--verbose`), prompt on **stdin** (never argv), optional `--resume +`, `--model`, tool grant flags per §11.3. The invocation rides the +user's existing login — no API keys. + +- Timeout: 30 minutes, then SIGTERM, pipes destroyed, settle immediately + (a grandchild holding the pipes must not wedge the runner), SIGKILL after a + 5 s grace. +- The stream is newline-delimited JSON parsed defensively: unparseable lines + dropped, the unterminated tail buffered across chunks. +- From the final result event the runner extracts the answer text, cost, + model and session id, each type-checked with `null` fallback. +- JSON extraction from the answer tries, in order: the whole text; the first + fenced code block; the substring from first `{` to last `}`. + +### 11.6 The Prompt + +The prompt is the product's editorial voice and lives in one place, tuned +against real reviews — implementations MUST change it from evidence, not +taste. Normative content, in order: the output-JSON contract (§4.5, "respond +with ONLY a JSON object"); writing-style rules (plain words for someone who +has NOT read the diff, effect before mechanism, no hedging chains); the +summary shape (under two paragraphs → no headings; otherwise `### 🎯 / 🔧 / +🔍 / ⚠️` sections in that order, dropping any with nothing to say; the +summary describes the PR, never the review); the verdict shape (one lead +sentence, then 2–5 evidence bullets each opening with a bolded lead; the +verdict alone carries the review's own verification); the comment shape +(finding first, on its own line; never write the grade into the body); the +grading and verdict-derivation rules of §7; the confidence rule (source and +no-source variants); the source/trusted instructions of §11.3; then the PR +metadata, description and the diff (truncated at 300,000 characters, with a +marker that says whether the rest is readable in the checkout). + +### 11.7 Progress Narration + +The runner translates its own event stream into plain-English lines ("reading +src/core/diff.ts", "running pnpm test", "thinking…"), clipped at 180 chars, +with the model's own JSON answer and all machinery suppressed. For Bash the +line MUST show the *command*, not the model's self-description of it — the +command is the honest one. Review-run narration goes to the process log; chat +narration is persisted onto `pendingChat.progress` (§12.3). + +## 12. The Chat Protocol + +A chat turn is one conversational exchange about a finished draft, held to a +stricter line than a re-review: a re-review is asking for a new draft; a chat +turn is asking for an edit to *this* one. + +### 12.1 Preconditions and Status + +Chat refuses only a `sent` artifact and one with a run or live turn already in +flight. It never changes the artifact status — `awaiting` and `failed` rows +can be talked about and keep their status. The turn claims the same in-flight +registry as reviews, so a turn and a review cannot race. + +### 12.2 Source and Session Restoration + +- A review that never had a checkout must not gain one for chat. +- Otherwise the checkout is re-prepared — re-cloned if the LRU evicted it — + under **the same trust flag the review ran under**: re-preparing with the + wrong flag would either un-quarantine the PR's agent config or quarantine + what a trusted review could legitimately read. Preparation touches the + directory, so an active conversation keeps its checkout alive in the LRU. +- The review's agent session is resumed **only when the checkout exists**: + sessions are keyed by working directory, and resuming into a deleted one + yields a model that believes it can open files and describes them from + memory. With no source, the turn runs cold, is told it is picking the + review up cold, and is told every tool is off — "I would have to look to be + sure" is the honest answer it is instructed to give. +- If the checkout's SHA differs from the review's, the prompt carries a drift + note: code read now may not be the code the review is about. + +### 12.3 Detachment and Progress + +The turn is written to `pendingChat` *before* it starts and runs detached +(§16.3). Progress lines are coalesced (2 s), deduplicated when consecutive, +bounded (last 30), and written onto `pendingChat.progress` through a +serialized write chain; the writer MUST be stopped-and-drained before the +result is saved, or a late flush would overwrite the answer with "still +thinking". A failed turn keeps its last progress lines and carries its error +on `pendingChat.error`, against the question that caused it. + +### 12.4 Revisions and Refusals + +The assistant's revisions are applied directly to the draft — **no accept +step**. Send is where a human vouches for what reaches GitHub; a per-turn +undo would be propose/accept machinery wearing a different name. The escape +hatch is one pre-chat snapshot (§12.6) plus the transcript. + +The artifact refuses, recording a Refusal rather than applying: + +- any edit or drop of a comment that is the user's own writing + (`origin === "user"` or `editedByUser`) unless the caller passed + `allowUserComments` (set only when the user said so in conversation); +- any revision naming an unknown comment or chapter id. + +Drops mark (`status: "dropped"`), never delete. The chat prompt additionally +instructs: revise only what the user raised, never revise merely to seem +agreeable, and keep the review's shape (§11.6). + +### 12.5 Merging a Landed Turn + +The turn ran for minutes while the cockpit stayed live, so its result is +folded onto the *current* artifact, three-way (`before` = at turn start, +`after` = the turn's result, `current` = on disk now): + +- A comment the user touched mid-turn (added, or `body`/`status`/`severity` + changed between `before` and `current`) keeps the user's version; an + untouched one takes the turn's. +- Comments the turn added are appended. A comment the user *deleted* mid-turn + (present in `before` and `after`, absent from `current`) is not + resurrected — deletes remove rows, drops only mark them, so drops survive + 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. + +### 12.6 Snapshot and Reset + +The first turn — and only the first — snapshots the draft (`preChat`). Reset +restores exactly the snapshot's four fields (summary, chapters, comments, +verdict) and keeps the conversation: you lose the edits, not the reasoning. +Reset refuses on a sent artifact, mid-turn, or when there is nothing to reset +to. + +## 13. Refresh and Re-Anchoring + +### 13.1 The Problem + +A comment stores a line *number* but means a line of *code*, and GitHub +rejects an entire review if any inline comment names a line not in the diff. +When the PR head moves, cerber re-anchors rather than re-reviews. + +### 13.2 Refresh + +Refresh runs automatically whenever the user opens a review (and on demand). +It costs a PR fetch plus, when the head moved, a diff fetch — never an AI +run. When stale (stored head ≠ current head, empty stored head never stale), +it replaces `pr` wholesale and `diff`, re-anchors every comment, and records +`refresh {fromSha, toSha, moved, drifted}`. It MUST NOT touch `summary`, +`chapters` or `verdict` — they still describe the reviewed commit, and the +cockpit says so. It MUST no-op on `sent` (a record) and `running` (the +runner's) artifacts, reporting "not changed" rather than an error. + +### 13.3 Re-Anchoring Algorithm + +Matching is exact and deterministic — no fuzzy distance, no AI. Per comment, +with `before`/`after` = per-file maps of new-side line number → line text +(added and context lines inside hunks only): + +1. `line == null` → unanchored; unchanged. +2. File or original line text absent from the relevant side → **drifted**. +3. Same text at the same number in the new diff → unchanged. +4. Else collect every new-side line whose text equals the original exactly: + one candidate → **moved**; several → disambiguate by up to 3 lines of + context either side (neighbours outside the old hunk are neutral, any + mismatch disqualifies); exactly one survivor → moved, otherwise drifted. + +A moved comment gets the new `line`, with `originalLine` sticky at where the +human first wrote it. A drifted comment keeps its stale `line` (the only +trace of what it pointed at) and is flagged `drifted`; the send path folds it +into the review body instead of posting inline (§14.4). A refresh that +re-finds a previously drifted line un-drifts it. + +## 14. GitHub Integration + +### 14.1 Client Contract + +All GitHub access shells out to `gh` with an **argument array, never a shell +string** — a shell interpolation carrying PR-sourced text is a +specification-level defect. Auth is entirely `gh`'s. Reads used: PR view, PR +diff, review-request search (`--review-requested=@me`, open, non-archived, +limit 50), issue-comment pagination, PR review listing, review-request +listing, the review-request timeline (GraphQL, one call, for the reopen check +of §9.6), org/team membership probes, current login (cached per process, +cache dropped on failure). Membership probes MUST rethrow anything that is not a +clean 404 — a missing scope or an outage must never read as membership (and a +`pending` team invitation is not membership). + +### 14.2 Trust + +Trust decides one thing: whether the review run may execute commands (§11.3). + +- Rules name **people**: `@login` (globs allowed within a path segment), + `@org/team`, `@org/*`. A leading `!` denies. **Denials win regardless of + order**; first matching grant otherwise; no match — including an empty rule + list — is untrusted. Default posture: trust nobody. +- Cerber refuses repo-shaped trust outright rather than qualifying it — a + repo takes PRs from anyone, so `private`/`public`/bare-repo patterns are + rejected at parse time with an explanation, not stored behind a warning. +- Membership is resolved against GitHub at run time; a failed lookup counts + as "not a member". +- Nothing but the user grants trust: not the PR, not its author's + association, not a heuristic. Explicit CLI `--trust`/`--no-trust` overrides + the rules for that invocation; `--no-trust` on `serve` caps every daemon + run. + +### 14.3 The Run Environment Holds No Credentials + +Bash plus a stored credential would turn "nothing reaches GitHub without a +Send" from a fact into a request. Every agent run's environment MUST: point +`GH_CONFIG_DIR` at an empty per-run directory; blank `GH_TOKEN`, +`GITHUB_TOKEN` and their enterprise variants; disable git's global *and* +system config (`GIT_CONFIG_NOSYSTEM=1` — the system-config path alone is +insufficient on macOS, where the CLT ships an osxkeychain credential helper); +disable git terminal prompts and askpass; and close ssh completely (no agent, +no default identities, no user config, batch mode). This is credential +hygiene, **not a sandbox**, and implementations MUST say so rather than imply +one. + +### 14.4 Send — the Only Write + +One GitHub write exists in the product: submitting a review (`POST +…/pulls/N/reviews`), one shot — body, event and all inline comments in a +single request, no retry, no partial send. Reachable from exactly three +places: the cockpit send (requires `confirm: true` in the request), `cerber +send` (interactive `[y/N]` unless `--yes`), and daemon auto-send (§15). + +Payload construction (pure, previewable without side effects): + +- Active comments (non-dropped) split into **inline** — `!drifted`, + `line != null`, and the line is an anchorable new-side line of the stored + diff — and **folded**, which land in the review body under + `## Additional notes` as `path:line` bullets (`:~line` marks a drifted + anchor). Inline comments always post on the `RIGHT` side. +- 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." +- 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. + +Send preconditions, all paths: not already sent; no run in flight — where +"in flight" is the OR of the persisted `running` status (cross-process +signal) and the in-process claim (a run that has not written yet). On +success the artifact becomes `sent` with `SentInfo` and a **Calibration** +snapshot: the AI's recommendation and confidence versus the event actually +sent, plus AI-comment totals kept/edited/dropped and user comments added — +the raw material for `cerber stats`. + +Idempotency is guarded only by the persisted `sent` field checked before the +call; a submit that succeeds on GitHub but fails to persist could double-send +on retry. Implementations SHOULD keep this window minimal; the reference +accepts it. + +### 14.5 Confirmation Is Asked Exactly Once + +The cockpit's Send button, once clicked with the preview available, is the +confirmation; implementations MUST NOT stack a second "are you sure" on top. +(A second confirmation on the action the user just chose is friction wearing +safety's coat.) The `confirm: true` body field exists to keep a stray HTTP +request from sending, not to double-ask a human. + +## 15. Auto-Send + +### 15.1 Activation + +Auto-send is CLI-only and per-process: `cerber serve --auto-send` turns it +on; absent, the daemon runs in **shadow mode**, evaluating and logging what +it *would* have sent. The threshold (`--auto-send-threshold`, default 90) is +clamped to [50, 100]. Enabling prints a warning naming exactly what will +happen. There is deliberately no config-file switch (§6.1). + +### 15.2 Eligibility + +Evaluated **per fresh run** — only where the poll's auto-review actually +produced a review, so a draft the freshness guard skipped is not re-judged or +re-logged. All of, in order, first failure terminal: + +1. never sent before (no re-sends); +2. status `ready`; +3. a verdict exists; +4. recommendation is `approve` — `comment` and `request_changes` always wait + for a human, at any confidence; +5. zero non-dropped blocker findings standing (an approve over a live blocker + is a contradiction a human resolves); +6. confidence ≥ threshold (equality is eligible). + +An eligible artifact in `on` mode is submitted as `APPROVE` with the standard +payload; the artifact becomes `sent` with `auto: true` plus calibration. A +failed submit is logged and counted, never retried and never marks the +artifact. + +### 15.3 The Decision Log + +Every evaluation — eligible or not, shadow or on, success or failure — +appends one JSON line to `/autosend.ndjson`: +`{at, id, recommendation, confidence, mode, decision, sent}`. The log is +append-only, read defensively (unparseable lines skipped), and is the +evidence `cerber stats` presents for deciding whether to enable `on` mode. + +## 16. HTTP API + +### 16.1 Server and Auth + +A local HTTP server (default `127.0.0.1:4820`) serves the JSON API and the +static cockpit. With no token configured there is no auth — acceptable only +on loopback, which the CLI enforces: binding a non-loopback host without a +token MUST be refused at startup ("anyone reaching this port could send +reviews as you") unless `--insecure` is passed. With a token (flag or +`CERBER_TOKEN`), a global middleware accepts `?token=`, a `cerber_token` +cookie, or `Authorization: Bearer`, sets the cookie on a successful +query-token visit (HttpOnly, SameSite=Strict), and answers 401 otherwise — +static assets included. No CORS: same-origin only. + +### 16.2 Routes + +| Method & path | Purpose | Notable answers | +|---|---|---| +| `GET /api/daemon` | daemon status | `{enabled: false}` when none | +| `GET /api/config` · `POST /api/config/daemon` · `POST /api/config/trust` | settings | full config view; 400 with the trust parser's message verbatim | +| `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` | +| `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 | +| `POST …/chat` · `DELETE …/chat/pending` · `POST …/chat/reset` | §12 | **202**; 409 sent/in-flight; dismiss clears only *failed* turns | +| `GET …/export` | markdown export | standalone document; verdict basis = blockers + confidence | +| `GET …/send-preview?event=` | payload preview | pure, no side effects | +| `POST …/send` | §14.4 | requires `{confirm: true}`; 409/502 as specified | +| `GET /*` | cockpit SPA | plain-text pointer when the web build is absent | + +### 16.3 The 202-Detached Pattern + +No HTTP request is ever held open for an AI run — a review and a chat turn +both take minutes, and a held request dies at a reverse proxy's read timeout, +telling the user it broke while the run quietly succeeds. The pattern, for +create, re-run and chat alike: + +1. Validate everything synchronously validatable (this is the only moment a + failure can reach the caller as a response). +2. **Persist the intent before responding**: `status: "running"` with a full + `run` block for reviews (so the next poll cannot read a stale `ready` and + conclude the run finished — and so the created artifact is never a + reapable pure stub); `pendingChat` for chat. +3. Answer `202` with the just-persisted artifact; run the work detached. +4. Failures land **on the artifact** — `failed` + `run.error` (guarded so a + late failure cannot stomp a run that has since taken ownership), or + `pendingChat.error` — for the cockpit to poll. + +## 17. Cockpit Requirements + +The cockpit's implementation is largely presentation and *implementation- +defined*; the following behaviors are normative. + +### 17.1 Queue Visibility + +One bucketing function MUST produce both tab contents and counts, so they can +never disagree. With `archived = pr.state !== "OPEN"`, `live` = open and not +settled: **inbox** = live; **awaiting** = live with status +`awaiting`/`running`; **drafted** = the other live rows (`ready` and +`failed`); **settled** = open `reviewed`/`skipped`; **sent** = open `sent`; +**archived** = everything closed/merged. Filed tabs appear only while +non-empty; a tab that empties under the user falls back to the inbox. + +### 17.2 Open Requests + +The daemon's published awaiting list (§9.1) drives one more tab: rows GitHub +still requests that the local queue is hiding — settled locally, archived, or +sent-then-re-requested. It deliberately overlaps every other tab; without it +the cockpit would say "nothing awaits you" over a poll that just counted two. +The whose-move label (§9.7) renders here and never filters. + +### 17.3 One Walkable Set + +A single definition — non-archived, non-settled, status-ordered +(`ready, awaiting, running, failed, reviewed, skipped, sent`, newest first +within a band) — MUST back the prev/next walk, the arrival bell and the +favicon dot. These are one invariant, not three coincidences: the dot on the +tab, the popup, and the arrows all answer "what still wants me?". + +### 17.4 The Arrival Bell and Favicon + +The browser bell polls the queue from every screen and notifies once per new +key this *browser* has seen — the seen-set and the on/off switch live in +localStorage beside the permission they depend on, because the permission is +the browser's. Normative behaviors: a fresh browser MUST NOT announce the +whole backlog (first poll only records); keys are recorded even while quiet, +so enabling later announces only what arrives next; a visible, focused queue +suppresses the popup (the row appearing is the notice); one poll is at most +one notification. The bell MUST stand down when the daemon announces +arrivals on this same machine (daemon says notify works AND the cockpit is +served from loopback) — one PR is one popup — and a non-loopback cockpit +keeps ringing, since that machine's tap lands where nobody is looking. The +favicon shows a dot exactly while the walkable set is non-empty, re-derived +on every queue fetch so no screen can leave it stale. + +### 17.5 Truth-Telling Surfaces + +- The verdict cell shows recommendation + confidence; the blocker count is + shown wherever there is room (detail chip, queue strip) as the checkable + basis (§7.2). +- When verdict and standing findings disagree (approve over live blockers, + 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. +- A sent review renders read-only. Rows filed by cerber are labeled with the + filing reason ("reviewed on GitHub"), never with a bare "reviewed" that + would read as a click the user never made. +- Opening a review triggers refresh (§13.2); a refresh failure is reported + softly and the draft still reads. +- 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 + absent history MUST read as "predates cerber keeping one", never as an + empty log (§5.4). + +## 18. CLI + +`cerber` (version derived from the package — see Appendix B.1): + +- **`serve`** — the default command: reconcile (§5.3), start the daemon + (unless `--no-poll`), start the server. Flags: `-p/--port` (4820), + `-H/--host` (127.0.0.1), `-t/--token` (or `CERBER_TOKEN`), `--insecure`, + `-i/--interval`, `-R/--repo` (repeatable), `-P/--parallel`, `-m/--model`, + `--no-poll`, `--no-auto-review`, `--no-notify`, `--no-source`, + `--no-trust`, `--auto-send`, `--auto-send-threshold`. Warns at startup + when unattended trusted runs are possible and when auto-send is on. +- **`review [pr...]`** — run reviews from the terminal; `-a/--awaiting-me` + discovers; `-f/--force` bypasses the freshness guard; `--no-source`, + `-t/--trust`/`--no-trust`, `-P`, `-m`, `-R` as above. Exit code 1 if any + PR failed. +- **`send `** — the only writing command: preview, `[y/N]` confirmation + (skippable with `-y`), event from the verdict or `-e`. Refuses already-sent + and `running` artifacts. +- **`history `** — prints the review's history (§5.4): one line per + entry with local timestamp, actor, what happened, and the cause; says + plainly when a review predates the history being kept. +- **`list`**, **`export `** (renders the markdown document; never touches + GitHub), **`trust [pattern] [-d]`** (canonicalizes rules; explains + refusals), **`prune [--all]`** (checkout cache), **`stats`** (calibration + buckets + auto-send log summary). + +## 19. Failure Model + +| Failure | Behavior | +|---|---| +| Config invalid | loud failure everywhere it is read; never silent defaults (§6.2) | +| GitHub read fails (poll) | poll errs, last good awaiting list stands, error published (§9.1) | +| GitHub read fails (membership) | "not a member" — fail-closed for trust (§14.1) | +| GitHub read fails (whose-move / filing evidence) | `unknown` / no filing — claim nothing (§9.7, §9.5) | +| GitHub read fails (reopen timeline) | row untouched; retried after the leash expires (§9.6) | +| Checkout fails | diff-only review, logged, never fatal (§10.4) | +| Agent output invalid | one retry with the error echoed; then the run fails (§11.2) | +| Agent hangs | 30-min timeout, TERM→KILL, run fails (§11.5) | +| Run fails after start | `failed` + `run.error`, folded — user-owned statuses stand (§8.5) | +| Chat turn fails | error on `pendingChat`, transcript untouched (§12.3) | +| Process dies mid-run | startup reconciliation stamps it (§5.3) | +| Artifact corrupt | direct load throws; listing skips it (§5.2) | +| Send fails at GitHub | error to the caller (502 / exit 1 / log); artifact untouched (§14.4) | +| Notifier missing/hung | bounded, swallowed, bell handed back to the browser (§9.8) | +| Auto-send failure | logged + counted, poll continues (§15.2) | + +Recovery is stateless by design: on restart, reconcile, then re-poll — the +tracker (GitHub) and the artifact files *are* the durable state. In-memory +rate-limit bookkeeping and the in-flight registry are deliberately lost. + +## 20. Security Invariants (summary) + +1. **One write.** `submitReview` is the only GitHub write, reachable only via + explicit confirmed Send or opt-in auto-send. Everything else is read-only. +2. **Runs hold no credentials.** §14.3's environment is mandatory for every + agent invocation. Not a sandbox; never claim one. +3. **Trust is people, granted only by the user.** Repo-shaped trust is + refused outright; denials win; failed lookups fail closed. §14.2. +4. **Least tools.** Read-only by default; Bash only for trusted PRs; never + Edit/Write; nothing at all without a checkout. §11.3. +5. **Untrusted checkout content is evidence, not instructions.** Quarantine + + workspace isolation + prompt framing. §10.1, §11.3. +6. **PR-sourced strings are hostile.** Argv arrays for every subprocess; + escaping for notification text. §14.1, §9.8. +7. **Human decisions are never machine-undone.** `SETTLED_BY_YOU` survives + pushes; sends and settles survive concurrent runs; user comments survive + chat turns; `sent` is immutable. §8.5, §12.4–12.5. The one reopening rule + (§9.6) respects the decision's scope rather than undoing it: a settle + answered the request open at the time, and only a *newer* request naming + the user brings the row back. +8. **Secure defaults, no insecure offering.** Non-loopback without auth is + refused, not warned about. §16.1. + +## 21. Reference Algorithms + +### 21.1 Poll Tick + +``` +tick(): + if polling: return # never overlap + config = load_config() # throws loudly if invalid + if not config.daemon.poll and cli_poll: + publish awaiting=[]; return + refs = dedupe(search_awaiting(each repo filter)) + publish awaiting = classify_whose_move(refs) # unknown on failure + discovered = sync_queue(refs) # stubs, reopen, archive, reap, file + if notify_on and discovered: announce(discovered) + if auto_review: + for ref in refs, parallelism P: + r = review(ref, trigger=daemon) # freshness guard inside + if fresh(r): evaluate_auto_send(r) # §15 + finally: schedule(tick, interval) # end-to-start +``` + +### 21.2 Review Run + +``` +review(ref, opts): + claim_inflight(id) or raise InProgress + try: + pr = fetch_pr(ref) + if not opts.force and guard_skips(existing, pr): return skipped + diff = fetch_diff(ref) + trusted = resolve_trust(opts, config.trust, pr.author) # fail closed + source = try prepare_checkout(ref, trusted) else null # never fatal + persist running_artifact(pr, diff, source, trusted, opts.trigger, + chat=existing.chat) # before the agent + out = run_agent(prompt(pr, diff, source, trusted), tools(source, trusted)) + ai = validate(out) or retry_once_fresh() + result = fold_ai(ai, run={reviewedSha: pr.headSha, + sessionId: source ? ai.session : null}) + save(merge_run_result(result, load_current())) # §8.5 + except e: + save(fold_failure(load_current(), e)) # user-owned stands + raise + finally: release_inflight(id); cleanup_run_dir() +``` + +### 21.3 Filing Check (per ready, unsent draft, on the 30-min leash) + +``` +file_if_settled_elsewhere(a): + me = current_login() or return + if r := latest_own_review(a.pr, me): + if filed_by_your_act(a, r.at): file(a, own_review(r)) + return # terminal either way + conv = fetch_conversation(a.pr) + if mine := last_word_of_yours(conv, me): + if filed_by_your_act(a, mine.at): file(a, own_reply(mine)) + return + if classify(conv, me) != none: return # they answered you + if not filed_by_withdrawn_request(a): return # daemon drafts only + if still_requested(fetch_review_requests(a.pr), me): return # confirm + file(a, request_withdrawn) + +file(a, filed): # atomic, predicate re-checked against disk + update(a.key, cur => still_filable(cur) + ? {...cur, status: reviewed, filed, + settledAt: filed.at} : cur) +``` + +### 21.4 Reopen Check (per settled row in the awaiting search, on its own leash) + +``` +reopen_if_asked_again(a): + settled = a.settledAt or a.filed.at or a.run.finishedAt or return + if a.sent or cap_spent or checked_recently(a.id): return + stamp_checked(a.id) # before the call — outage ≠ retry storm + me = current_login() or return + asked = try last_review_request_naming(a.pr, me) else return # no evidence + if asked is null or parse(asked) <= parse(settled): return # team asks never count + update(a.key, cur => asked_again(cur, asked) # re-check on disk + ? {...cur, status: cur.has_clean_draft ? ready : awaiting, + settledAt: null, filed: null} + : cur) +``` + +### 21.5 Chat Turn + +``` +chat(a, message, refs): + claim_inflight(a.id) + try: + src = ensure_chat_source(a) # same trust flag; null if never had one + resume = src and a.run.sessionId + out = run_agent(chat_prompt(a, message, refs, src, resume), + tools(src, a.run.trusted), resume) + turn = validate(out) or retry_by_resuming(out.session) + pre = a.preChat or snapshot(a) # first turn only + revised, refused = apply_revisions(a, turn.revisions) # refuse user-owned + result = {...revised, preChat: pre, chat: a.chat + [user, assistant]} + finally: release_inflight(a.id) + # server, after progress.stop(): + update(a.key, cur => {...merge_concurrent_edits(a, result, cur), + pendingChat: null}) +``` + +## 22. Conformance Checklist + +An implementation conforms when all of the following hold: + +**State** +- [ ] Artifacts parse under the §4 schema; unknown grades and statuses are + rejected; absent defaulted fields materialize correctly; absent + `history` stays absent (never an empty log). +- [ ] Writes are atomic (tmp + rename); direct loads throw on corruption; + listings skip corrupt entries; startup reconciliation runs before + polling. +- [ ] History is appended by the store alone from what is on disk (a + caller-supplied log is ignored; the pre-save re-read is unconditional); + an unreadable prior file restarts the log saying so; notes never touch + `updatedAt`, dedupe against the last entry's text (never the length), + and skip the write entirely when repeated; the cap leaves a + dropped-entries marker; writes carry ambient actor + cause, with the + runner's context beginning only after the guards. + +**Lifecycle** +- [ ] Only `reviewed`/`skipped` are user-settable; sent artifacts are + immutable; the freshness guard compares `run.reviewedSha`; + `SETTLED_BY_YOU` survives pushes; run results and failures fold onto + the current artifact. +- [ ] Pure stubs (and only pure stubs) are reaped; filing follows §9.5's + order, guards, and withdrawal confirmation; bots never count as + replies. +- [ ] Every settling path stamps `settledAt` (the status route also clears + `filed`); reopening follows §9.6 — only user-named requests newer than + the settle (parsed timestamps), legacy fallback to `filed.at` then + `run.finishedAt`, unsettled rows cost no GitHub call, failed reads + change nothing, the leash stamp is spent before the call, and the + reopen clears `settledAt` and `filed`; a push alone never reopens. + +**Runner** +- [ ] The tool matrix of §11.3 is exact; the run environment matches §14.3; + untrusted checkouts quarantine agent config; checkout failure degrades + to diff-only; bad model output retries exactly once. +- [ ] No AI run starts without the in-flight claim; no HTTP request is held + open for one. + +**Chat** +- [ ] Turns never change status; user-owned comments are refused without + explicit permission; concurrent edits merge per §12.5; reset restores + the snapshot and keeps the transcript; sessions resume only with a + checkout. + +**GitHub** +- [ ] `submitReview` is the only write, gated by explicit confirmation or + §15's full auto-send predicate; the payload folds unanchorable + comments and pins `commit_id`; every subprocess uses argv arrays. +- [ ] Trust refuses repo-shaped rules; denials win; lookups fail closed. + +**Operator surface** +- [ ] Missing config works; invalid config fails loudly; poll-tick reload for + the boolean knobs; CLI flags cap, never raise. +- [ ] Non-loopback binding without auth is refused; every auto-send decision + is logged; announcements escape PR-controlled text and fire once per + arrival. + +--- + +## Appendix A. Reference Implementation Map (non-normative) + +| Spec section | Reference source | +|---|---| +| §4 domain model | `src/core/artifact.ts` | +| §5 state store, history | `src/core/state.ts`, `src/core/history.ts` | +| §6 configuration | `src/core/config.ts` | +| §7 severity/verdict | `src/core/severity.ts`, `src/runner/prompt.ts` | +| §8 lifecycle | `src/runner/review.ts`, `src/core/refresh.ts`, `docs/lifecycle.md` | +| §9 daemon | `src/server/daemon.ts`, `src/core/notify.ts` | +| §10 checkouts | `src/core/checkout.ts` | +| §11 runner | `src/runner/review.ts`, `claude.ts`, `prompt.ts`, `progress.ts`, `inflight.ts` | +| §12 chat | `src/runner/chat.ts`, `src/core/revise.ts`, `src/server/progress.ts` | +| §13 re-anchoring | `src/core/anchor.ts`, `src/core/refresh.ts` | +| §14 GitHub | `src/core/gh.ts`, `src/core/trust.ts`, `src/core/send.ts` | +| §15 auto-send | `src/core/autosend.ts` | +| §16 HTTP API | `src/server/index.ts` | +| §17 cockpit | `web/src/inbox.ts`, `notify.ts`, `favicon.ts`, `review.ts` | +| §18 CLI | `src/cli/index.ts` | + +## Appendix B. Known Divergences in the Reference Implementation (non-normative) + +Found while writing this specification; the spec text above follows the code +and tests. + +1. **CLI version string** — `src/cli/index.ts` pins `.version("0.5.0")` while + the package is well past that. The version SHOULD be derived from + `package.json`. +2. **Re-review comment carryover** — README ("When the PR moves under you") + and the cockpit's re-review tooltip say user comments survive a + re-review; the code, its tests and `docs/lifecycle.md` implement and + document wholesale replacement (§8.5), which CLAUDE.md records as a + deliberate decision. The prose should be corrected to match. +3. **Severity vocabulary** — the artifact model has three grades + (`blocker/minor/nit`, §7.1) while `CODE_REVIEW.md`'s process ladder for + reviewing *cerber's own PRs* uses four (`blocker/major/minor/nit`). These + are different domains, but the collision invites confusion. +4. **`allowUserComments`** — plumbed end-to-end (API → runner → revision + application) but no cockpit control sets it; today it is reachable only by + direct API call or by the model choosing to honor an in-conversation + grant.