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
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ any of them — it names the files each rule lives in.
(`checkout.ts` — shallow `refs/pull/N/head` clone per PR under `~/.cerber/src`;
an LRU cache of 8, evicted as reviews run, reclaimable with `cerber prune`),
trust rules (`trust.ts` — `@login`, `@org/team`, `@org/*`; people only, no
way to trust a repo; denials win) and settings
way to trust a repo; denials win), the per-review history
(`history.ts` — appended by `saveArtifact` itself, never by its callers, so
no write path can forget it; a watchlist of what changed, who wrote it from
an ambient `withWriter`, and `noteHistory` for the decisions that changed
nothing) and settings
(`config.ts` — `~/.cerber/config.json`, zod-validated, written by the CLI and
the cockpit's settings screen)
- `src/runner/` — review prompt + headless `claude -p --output-format
Expand Down
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,12 +355,36 @@ the summary, comments and verdict alone, and is still one deliberate click. A
review that has already been sent can't be argued with: that artifact is the
record of what GitHub has.

### Every row remembers what happened to it

A review keeps one "last updated" time, which means every write erases the
answer to *when did I skip this, and did they ask again afterwards?* So each
one also keeps a history: the status changes with their timestamps, every push
it saw, each run and what it cost, sends, refreshes — and which part of cerber
did it, whether that was you in the cockpit, the CLI, an AI run or the poll.

The poll's silences are in there too. When it looks at a row and deliberately
leaves it alone — you settled it, so a new push does not reopen it; or GitHub
still lists you as a requested reviewer even though its own search has stopped
saying so — it writes that down instead of passing without a trace. That is
usually the answer when a PR is not where you expected it to be.

It's at the foot of every review in the cockpit, and:

```bash
cerber history owner/repo#123
```

Nothing about GitHub's own timeline is copied here — GitHub keeps that, and
`gh` can be asked for it again. This is cerber's side of the story.

## Status

Early, but whole: everything described above has shipped — reviewing,
editing and the gated Send, inbox discovery with parallel runs, confidence
calibration (`cerber stats`), shadow-mode and opt-in auto-send, re-anchoring
onto new commits, source-backed and trusted runs, and the reviewer chat.
onto new commits, source-backed and trusted runs, the reviewer chat, and a
per-review history of everything that touched it (`cerber history`).
`cerber export` writes a review out as markdown if you want it elsewhere.

## Running on a VPS
Expand Down
64 changes: 62 additions & 2 deletions docs/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ Four things write the artifact, and most questions in this document are really
| **startup** | `reconcileRunning`, `src/core/state.ts` | on boot, turn a leftover `running` into `failed` and error a pending chat turn |
| **the runner** | `src/runner/review.ts`, `chat.ts` | fill in summary / chapters / comments / verdict |
| **you** | the cockpit → `src/server/index.ts` | edit, mark reviewed/skipped, send, re-review, chat — and, just by opening a review, the automatic refresh that rewrites `pr`, `diff`, the comment anchors and `refresh` |
| **you** | the CLI → `src/cli/index.ts` | `review` (`--force` re-reviews) and `send`. That is all it writes — `export` only renders, `prune` only clears checkouts, and there is no edit, mark or chat |
| **you** | the CLI → `src/cli/index.ts` | `review` (`--force` re-reviews) and `send`. That is all it writes — `export` only renders, `history` only reads, `prune` only clears checkouts, and there is no edit, mark or chat |

There is no database and no migration step. The file is hand-editable; readers
are defensive and writers are atomic (tmp+rename, `src/core/state.ts`).
are defensive and writers are atomic (tmp+rename, `src/core/state.ts`). Which
of the four moved a given row, and when, is on the artifact itself: every write
appends to its `history` (§6).

---

Expand Down Expand Up @@ -171,6 +173,10 @@ a token it checks the artifact already on disk:
and why it is not `pr.headSha`, is the paragraph below.)
4. Otherwise → run.

Steps 2 and 3 write a note to the review's history (§6) saying so — a poll that
looks at a row and deliberately does nothing is otherwise indistinguishable
from one that never looked, which is the hardest thing about it to debug.

So a `ready` **or `sent`** artifact on a PR that gets a new commit is meant to
be re-drafted by the next poll: `HEAD_SENSITIVE` only skips while the head is
*unchanged*. For a sent one that is the point — submitting cleared GitHub's
Expand Down Expand Up @@ -299,6 +305,12 @@ Someone *answering* your comment files nothing — that reply is addressed to
you. State checks are leashed to one per artifact per 30 minutes and capped
per poll.

The two cases that leave a row alone — someone has answered you, and GitHub
still lists you as a requested reviewer despite the search — write a note to
the review's history (§6) rather than passing in silence. The second is the one
fact nobody can reconstruct afterwards: what the awaiting search said at that
minute, and that the PR itself disagreed with it.

### Asked again: the way back out of settled

Filing's mirror image, and the only thing that reopens a settled row on its own
Expand Down Expand Up @@ -386,6 +398,48 @@ Written by other paths:
both answer `202` and put their state on the artifact for the cockpit to poll —
failures included, since there is no response left to hand them to.

### The history: the record one `updatedAt` cannot keep

An artifact carries a single `updatedAt`, so every write erases the answer to
"when did this become `skipped`, and did anything ask for it again afterwards?".
`history` is the answer that survives — an append-only list on the artifact,
oldest first, read in the cockpit's **history** card and with `cerber history
<pr>`.

It is written by `saveArtifact` itself (`src/core/state.ts`), never by its
callers: several write paths hand over an artifact built minutes earlier, and a
log any of them had to remember to carry would be lost by the first that
didn't. So a history handed in is ignored — what is on disk is the only copy —
and a new write path is recorded without knowing history exists.

Which is why every save re-reads the file first, even when the caller has just
read it. Two writers share these files (§1), so a caller's copy can be out of
date by the time it writes, and appending to *that* would drop whatever the
other one recorded in between. The rest of the artifact is lost in that race
either way; the history need not be.

Three things go in, and two deliberately don't (`src/core/history.ts`):

- **What changed**, from a watchlist: status, head sha, PR state and draftness,
a run starting/finishing/failing and what it could read, the verdict,
comment churn, send, filing, refresh. A watchlist rather than a deep diff,
or a running turn's narration — rewritten every couple of seconds — would
bury everything else.
- **Who did it**: `daemon`, `cockpit`, `cli`, `runner`, with the request, poll
or run that caused it. Set once at each entry point (`withWriter`), ambient
from there down.
- **What the poll decided *not* to do** — the notes in §4 and §5 below, written
with `noteHistory`. A decision re-taken every poll is recorded once, and a
note does not touch `updatedAt`: it is not a change to the review and must
not reorder the queue.
- **Not** GitHub's timeline. Pushes, requests and reviews are GitHub's own
record and `gh` can be asked for them again; the exception is what the
awaiting *search* said at a given minute, which cannot be asked for later.
- **Not** the chat, which already carries its own turns, timestamps and edits.

The most recent 500 entries are kept, with a marker where older ones were
dropped. Deleting a stub deletes its history with it; nothing else removes one.

---

## 7. Settings that change any of this
Expand Down Expand Up @@ -454,6 +508,12 @@ forces too, but refuses a `sent` artifact outright — that record is not
rewritten from the UI, though the poll will still re-draft it once the head
moves.

**"When did I skip this — and did they ask again after?"** — `cerber history
<pr>`, or the **history** card at the foot of the review. It carries the status
change with its timestamp and who made it, every push it saw, and the poll's
own notes for the times it looked at the row and deliberately left it alone.
Empty on reviews that predate it being kept.

**"Why does it say reviewed when I never touched it?"** — The poll filed it;
`filed.reason` says which of the three cases. The draft is untouched and still
sendable.
Expand Down
47 changes: 46 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { artifactId, artifactKey } from "../core/artifact.js";
import { listCheckouts, removeCheckout } from "../core/checkout.js";
import { toMarkdown } from "../core/export.js";
import { configPath, loadConfig, saveConfig } from "../core/config.js";
import { withWriter } from "../core/history.js";
import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
import { PrRef, parsePrRef, searchAwaitingMe, submitReview } from "../core/gh.js";
import { ReviewEvent, buildReviewPayload, computeCalibration, eventForRecommendation } from "../core/send.js";
Expand Down Expand Up @@ -139,6 +140,47 @@ program
}
});

program
.command("history")
.description(
"Everything that has happened to a review: what changed, when, which part of cerber did it — and the decisions the poll took to leave it alone",
)
.argument("<pr>", "PR URL, owner/repo#number, or number (with --repo)")
.option("-R, --repo <owner/repo>", "repository for bare PR numbers")
.action(async (input: string, opts: { repo?: string }) => {
const ref = parsePrRef(input, opts.repo);
const artifact = await loadArtifact(artifactId(ref));
if (!artifact) {
console.error(`No review found for ${artifactId(ref)}. Run: cerber review ${input}`);
process.exit(1);
}
console.log(`${artifact.id} — ${artifact.pr.title}\n`);
const history = artifact.history ?? [];
if (history.length === 0) {
console.log(
"Nothing recorded. This review predates cerber keeping a history — it starts at the next thing that happens to it.",
);
return;
}
const stamp = (at: string) => {
const d = new Date(at);
return Number.isNaN(d.getTime())
? at
: `${d.toLocaleDateString(undefined, { month: "short", day: "2-digit" })} ${d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}`;
};
// The stamp's width is the locale's business, not ours — measure it.
const stampWidth = Math.max(...history.map((e) => stamp(e.at).length)) + 2;
for (const entry of history) {
// The cause names the path that made the change — which endpoint, which
// command — and is the whole point on a row two paths could have written.
console.log(
`${stamp(entry.at).padEnd(stampWidth)}${entry.by.padEnd(9)}${entry.what}` +
`${entry.cause ? ` · ${entry.cause}` : ""}`,
);
}
console.log(`\n(times are local · ${history.length} entr${history.length === 1 ? "y" : "ies"})`);
});

program
.command("export")
.description("Print a review as markdown (never touches GitHub)")
Expand Down Expand Up @@ -513,7 +555,10 @@ function collect(value: string, previous: string[]): string[] {
return [...previous, value];
}

program.parseAsync().catch((err) => {
// Every artifact write under this command is stamped with the command that
// made it. Nested contexts win, so `serve` labels its requests, its poll and
// its runs for themselves rather than all of them "cli".
withWriter({ by: "cli", cause: process.argv[2] ?? null }, () => program.parseAsync()).catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});
11 changes: 11 additions & 0 deletions src/core/artifact.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import { HistoryEntrySchema } from "./history.js";

export const SCHEMA_VERSION = 1 as const;

Expand Down Expand Up @@ -337,6 +338,16 @@ export const ArtifactSchema = z.object({
pendingChat: PendingChatSchema.nullable().default(null),
/** The review as it stood before the first chat turn — "reset" restores this. */
preChat: ReviewSnapshotSchema.nullable().default(null),
/**
* Everything that has happened to this review, oldest first.
*
* Optional, and with no default: nothing outside `saveArtifact` writes this,
* so absent means absent — an artifact from before it was kept, which the
* cockpit and the CLI say so about rather than showing as an empty history.
* Hand one in and it is ignored; the log on disk is the only current copy.
* See `history.ts` for what is recorded and what is deliberately left out.
*/
history: z.array(HistoryEntrySchema).optional(),
});
export type Artifact = z.infer<typeof ArtifactSchema>;

Expand Down
Loading
Loading