@fullstackhouse/cerber 0.24.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -355,12 +355,36 @@ the summary, comments and verdict alone, and is still one deliberate click. A
355
355
  review that has already been sent can't be argued with: that artifact is the
356
356
  record of what GitHub has.
357
357
 
358
+ ### Every row remembers what happened to it
359
+
360
+ A review keeps one "last updated" time, which means every write erases the
361
+ answer to *when did I skip this, and did they ask again afterwards?* So each
362
+ one also keeps a history: the status changes with their timestamps, every push
363
+ it saw, each run and what it cost, sends, refreshes — and which part of cerber
364
+ did it, whether that was you in the cockpit, the CLI, an AI run or the poll.
365
+
366
+ The poll's silences are in there too. When it looks at a row and deliberately
367
+ leaves it alone — you settled it, so a new push does not reopen it; or GitHub
368
+ still lists you as a requested reviewer even though its own search has stopped
369
+ saying so — it writes that down instead of passing without a trace. That is
370
+ usually the answer when a PR is not where you expected it to be.
371
+
372
+ It's at the foot of every review in the cockpit, and:
373
+
374
+ ```bash
375
+ cerber history owner/repo#123
376
+ ```
377
+
378
+ Nothing about GitHub's own timeline is copied here — GitHub keeps that, and
379
+ `gh` can be asked for it again. This is cerber's side of the story.
380
+
358
381
  ## Status
359
382
 
360
383
  Early, but whole: everything described above has shipped — reviewing,
361
384
  editing and the gated Send, inbox discovery with parallel runs, confidence
362
385
  calibration (`cerber stats`), shadow-mode and opt-in auto-send, re-anchoring
363
- onto new commits, source-backed and trusted runs, and the reviewer chat.
386
+ onto new commits, source-backed and trusted runs, the reviewer chat, and a
387
+ per-review history of everything that touched it (`cerber history`).
364
388
  `cerber export` writes a review out as markdown if you want it elsewhere.
365
389
 
366
390
  ## Running on a VPS
package/dist/cli/index.js CHANGED
@@ -5,6 +5,7 @@ import { artifactId, artifactKey } from "../core/artifact.js";
5
5
  import { listCheckouts, removeCheckout } from "../core/checkout.js";
6
6
  import { toMarkdown } from "../core/export.js";
7
7
  import { configPath, loadConfig, saveConfig } from "../core/config.js";
8
+ import { withWriter } from "../core/history.js";
8
9
  import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
9
10
  import { parsePrRef, searchAwaitingMe, submitReview } from "../core/gh.js";
10
11
  import { buildReviewPayload, computeCalibration, eventForRecommendation } from "../core/send.js";
@@ -103,6 +104,40 @@ program
103
104
  console.log(`${a.status.padEnd(8)} ${a.id.padEnd(40)} ${v.padEnd(20)} ${a.pr.title}`);
104
105
  }
105
106
  });
107
+ program
108
+ .command("history")
109
+ .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")
110
+ .argument("<pr>", "PR URL, owner/repo#number, or number (with --repo)")
111
+ .option("-R, --repo <owner/repo>", "repository for bare PR numbers")
112
+ .action(async (input, opts) => {
113
+ const ref = parsePrRef(input, opts.repo);
114
+ const artifact = await loadArtifact(artifactId(ref));
115
+ if (!artifact) {
116
+ console.error(`No review found for ${artifactId(ref)}. Run: cerber review ${input}`);
117
+ process.exit(1);
118
+ }
119
+ console.log(`${artifact.id} — ${artifact.pr.title}\n`);
120
+ const history = artifact.history ?? [];
121
+ if (history.length === 0) {
122
+ console.log("Nothing recorded. This review predates cerber keeping a history — it starts at the next thing that happens to it.");
123
+ return;
124
+ }
125
+ const stamp = (at) => {
126
+ const d = new Date(at);
127
+ return Number.isNaN(d.getTime())
128
+ ? at
129
+ : `${d.toLocaleDateString(undefined, { month: "short", day: "2-digit" })} ${d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}`;
130
+ };
131
+ // The stamp's width is the locale's business, not ours — measure it.
132
+ const stampWidth = Math.max(...history.map((e) => stamp(e.at).length)) + 2;
133
+ for (const entry of history) {
134
+ // The cause names the path that made the change — which endpoint, which
135
+ // command — and is the whole point on a row two paths could have written.
136
+ console.log(`${stamp(entry.at).padEnd(stampWidth)}${entry.by.padEnd(9)}${entry.what}` +
137
+ `${entry.cause ? ` · ${entry.cause}` : ""}`);
138
+ }
139
+ console.log(`\n(times are local · ${history.length} entr${history.length === 1 ? "y" : "ies"})`);
140
+ });
106
141
  program
107
142
  .command("export")
108
143
  .description("Print a review as markdown (never touches GitHub)")
@@ -399,7 +434,10 @@ program
399
434
  function collect(value, previous) {
400
435
  return [...previous, value];
401
436
  }
402
- program.parseAsync().catch((err) => {
437
+ // Every artifact write under this command is stamped with the command that
438
+ // made it. Nested contexts win, so `serve` labels its requests, its poll and
439
+ // its runs for themselves rather than all of them "cli".
440
+ withWriter({ by: "cli", cause: process.argv[2] ?? null }, () => program.parseAsync()).catch((err) => {
403
441
  console.error(err instanceof Error ? err.message : err);
404
442
  process.exit(1);
405
443
  });
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { HistoryEntrySchema } from "./history.js";
2
3
  export const SCHEMA_VERSION = 1;
3
4
  /**
4
5
  * How hard a finding presses on the merge. The words carry their own
@@ -278,6 +279,18 @@ export const ArtifactSchema = z.object({
278
279
  sent: SentInfoSchema.nullable().default(null),
279
280
  /** Set when cerber filed this draft away itself, and why. Never on a sent one. */
280
281
  filed: FiledInfoSchema.nullable().default(null),
282
+ /**
283
+ * When this review was settled — marked `reviewed` or `skipped`, by you or by
284
+ * cerber filing it. Null on anything not settled.
285
+ *
286
+ * A settle answers the request that was open at the time. It cannot answer
287
+ * one that came afterwards, so the poll needs to know which side of it a
288
+ * review request falls on before deciding a settled row stays settled
289
+ * (`askedAgainAfterSettling` in `src/server/daemon.ts`). `updatedAt` cannot
290
+ * stand in for this: opening a settled review refreshes it, which moves that
291
+ * field forward long after the decision it is meant to date.
292
+ */
293
+ settledAt: z.string().nullable().default(null),
281
294
  /** Last time this review was pulled forward onto a newer head commit. */
282
295
  refresh: RefreshInfoSchema.nullable().default(null),
283
296
  calibration: CalibrationSchema.nullable().default(null),
@@ -287,6 +300,16 @@ export const ArtifactSchema = z.object({
287
300
  pendingChat: PendingChatSchema.nullable().default(null),
288
301
  /** The review as it stood before the first chat turn — "reset" restores this. */
289
302
  preChat: ReviewSnapshotSchema.nullable().default(null),
303
+ /**
304
+ * Everything that has happened to this review, oldest first.
305
+ *
306
+ * Optional, and with no default: nothing outside `saveArtifact` writes this,
307
+ * so absent means absent — an artifact from before it was kept, which the
308
+ * cockpit and the CLI say so about rather than showing as an empty history.
309
+ * Hand one in and it is ignored; the log on disk is the only current copy.
310
+ * See `history.ts` for what is recorded and what is deliberately left out.
311
+ */
312
+ history: z.array(HistoryEntrySchema).optional(),
290
313
  });
291
314
  /** What the AI must return. Subset of the artifact; cerber owns the rest. */
292
315
  export const AiReviewSchema = z.object({
package/dist/core/gh.js CHANGED
@@ -254,6 +254,58 @@ export async function fetchReviewRequests(ref) {
254
254
  export function stillRequested(requests, login) {
255
255
  return requests.teams.length > 0 || requests.users.includes(login);
256
256
  }
257
+ export function lastRequestOf(events, login) {
258
+ const mine = events.filter((e) => e.requestedReviewer?.__typename === "User" && e.requestedReviewer.login === login);
259
+ if (mine.length === 0)
260
+ return null;
261
+ return mine.reduce((a, b) => (b.createdAt > a.createdAt ? b : a)).createdAt;
262
+ }
263
+ // 100 is the page maximum, and it costs exactly what a smaller window would:
264
+ // one call. It wants to be big because the recent events are not the useful
265
+ // ones — a PR that cycled through a dozen reviewers can push the request that
266
+ // named *you* past a short window, and the answer would come back "nobody
267
+ // asked", silently restoring the bug this exists to fix.
268
+ //
269
+ // It is still a window, not a guarantee: a PR carrying more than 100 review
270
+ // requests would lose the oldest, and that is accepted rather than paginated.
271
+ // Paginating would cost one call per extra page on every settled row on every
272
+ // poll, to cover a PR that does not realistically exist — and the failure mode
273
+ // is the conservative one, a row left settled rather than one wrongly reopened.
274
+ const LAST_REQUEST_QUERY = `query($owner:String!,$repo:String!,$number:Int!){
275
+ repository(owner:$owner,name:$repo){
276
+ pullRequest(number:$number){
277
+ timelineItems(last:100,itemTypes:[REVIEW_REQUESTED_EVENT]){
278
+ nodes{... on ReviewRequestedEvent{createdAt requestedReviewer{__typename ... on User{login}}}}
279
+ }
280
+ }
281
+ }
282
+ }`;
283
+ /**
284
+ * Ask GitHub when your review was last requested on a PR.
285
+ *
286
+ * GraphQL rather than the REST timeline on purpose: this runs on rows the queue
287
+ * already holds, poll after poll, and the REST timeline of a busy PR is hundreds
288
+ * of events across several pages — every one of them fetched to find the handful
289
+ * that are review requests. Filtering server-side to the one event type turns
290
+ * that into a single call; the window it reads is bounded, as the query above
291
+ * explains.
292
+ */
293
+ export async function fetchLastReviewRequest(ref, login) {
294
+ const out = await gh([
295
+ "api",
296
+ "graphql",
297
+ "-f",
298
+ `query=${LAST_REQUEST_QUERY}`,
299
+ "-F",
300
+ `owner=${ref.owner}`,
301
+ "-F",
302
+ `repo=${ref.repo}`,
303
+ "-F",
304
+ `number=${ref.number}`,
305
+ ]);
306
+ const raw = JSON.parse(out);
307
+ return lastRequestOf(raw.data?.repository?.pullRequest?.timelineItems?.nodes ?? [], login);
308
+ }
257
309
  export function searchAwaitingArgs(repoFilter, limit = 50) {
258
310
  const args = [
259
311
  "search",
@@ -0,0 +1,226 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { z } from "zod";
3
+ /**
4
+ * What happened to a review, and when — cerber's side of the story.
5
+ *
6
+ * An artifact keeps one `updatedAt`, so every write erases the answer to "when
7
+ * did this become skipped?". This is the record that survives: one line per
8
+ * thing that changed, appended by `saveArtifact` itself rather than by its
9
+ * callers, so no write path can forget to keep it.
10
+ *
11
+ * It deliberately records only what cerber did and saw. GitHub keeps its own
12
+ * timeline of pushes, requests and reviews, and `gh` can always be asked for it
13
+ * again — mirroring it here would be a database wearing a different hat. The
14
+ * one thing worth writing down is what the poll *saw* at a given minute, since
15
+ * a search index cannot be asked what it said an hour ago.
16
+ *
17
+ * The chat is the other deliberate omission: a conversation already carries its
18
+ * own turns, timestamps and revisions.
19
+ */
20
+ export const HistoryActorSchema = z.enum(["daemon", "cockpit", "cli", "runner", "unknown"]);
21
+ export const HistoryEntrySchema = z.object({
22
+ at: z.string(),
23
+ /** Which part of cerber wrote it. "unknown" when nothing claimed the write. */
24
+ by: HistoryActorSchema.default("unknown"),
25
+ /** What happened, in plain words: "status ready → skipped". */
26
+ what: z.string(),
27
+ /** What was being done at the time: "PATCH /api/reviews/…", "poll", "review". */
28
+ cause: z.string().nullable().default(null),
29
+ });
30
+ /**
31
+ * How many entries one review keeps.
32
+ *
33
+ * Entries are ~100 bytes next to a diff that is routinely a hundred times
34
+ * that, and a watchlist plus note de-duplication keeps a busy PR to a few
35
+ * dozen — so this is a backstop against a pathological row, not a budget.
36
+ */
37
+ export const MAX_ENTRIES = 500;
38
+ /**
39
+ * Who is writing right now.
40
+ *
41
+ * Ambient rather than a parameter on every write: there are twenty-odd call
42
+ * sites and the useful answer is the same for all the writes one request, poll
43
+ * or run makes. Set it once at each entry point — the HTTP middleware, the
44
+ * poll, the CLI, an AI run — and every artifact write underneath is labelled,
45
+ * including ones added later that never think about history at all. Nesting
46
+ * works the obvious way: a run started by a request labels its own writes.
47
+ */
48
+ const writer = new AsyncLocalStorage();
49
+ export function withWriter(w, fn) {
50
+ return writer.run({ by: w.by, cause: w.cause ?? null }, fn);
51
+ }
52
+ export function currentWriter() {
53
+ return writer.getStore() ?? { by: "unknown", cause: null };
54
+ }
55
+ const short = (sha) => (sha.length > 7 ? sha.slice(0, 7) : sha);
56
+ const FILED_PHRASE = {
57
+ "own-review": "you had already reviewed it on GitHub",
58
+ "own-reply": "you answered on the PR and nobody has answered back",
59
+ "request-withdrawn": "nobody is asking for this review any more",
60
+ };
61
+ /** The shape of a run, said once at the top of it: what it could read, who asked. */
62
+ function runShape(run) {
63
+ const parts = [run.model ?? "default model", run.withSource ? "reading the source" : "diff only"];
64
+ if (run.trusted)
65
+ parts.push("trusted — may run commands");
66
+ if (run.trigger)
67
+ parts.push(run.trigger === "daemon" ? "asked for by the poll" : "asked for by you");
68
+ return parts.join(", ");
69
+ }
70
+ /** Comment churn as one line: what a re-review, a chat turn or your own edits did. */
71
+ function describeComments(before, after) {
72
+ const was = new Map(before.map((c) => [c.id, c]));
73
+ const is = new Map(after.map((c) => [c.id, c]));
74
+ let fromReview = 0;
75
+ let yours = 0;
76
+ let edited = 0;
77
+ let regraded = 0;
78
+ let dropped = 0;
79
+ let restored = 0;
80
+ for (const c of after) {
81
+ const old = was.get(c.id);
82
+ if (!old) {
83
+ if (c.origin === "user")
84
+ yours++;
85
+ else
86
+ fromReview++;
87
+ continue;
88
+ }
89
+ if (old.body !== c.body)
90
+ edited++;
91
+ if (old.severity !== c.severity)
92
+ regraded++;
93
+ if (old.status !== "dropped" && c.status === "dropped")
94
+ dropped++;
95
+ if (old.status === "dropped" && c.status !== "dropped")
96
+ restored++;
97
+ }
98
+ const gone = before.filter((c) => !is.has(c.id)).length;
99
+ const parts = [];
100
+ if (fromReview)
101
+ parts.push(`+${fromReview} from the review`);
102
+ if (yours)
103
+ parts.push(`+${yours} you wrote`);
104
+ if (edited)
105
+ parts.push(`${edited} edited`);
106
+ if (regraded)
107
+ parts.push(`${regraded} re-graded`);
108
+ if (dropped)
109
+ parts.push(`${dropped} dropped`);
110
+ if (restored)
111
+ parts.push(`${restored} restored`);
112
+ if (gone)
113
+ parts.push(`${gone} gone`);
114
+ return parts.length > 0 ? `comments: ${parts.join(", ")}` : null;
115
+ }
116
+ /**
117
+ * What changed between two versions of a review, in plain words.
118
+ *
119
+ * A watchlist, not a deep diff. A generic comparison would bury the timeline
120
+ * under a running turn's narration, which is rewritten to the artifact every
121
+ * couple of seconds and says nothing about where the review got to.
122
+ */
123
+ export function describeChange(before, after) {
124
+ const lines = [];
125
+ if (!before) {
126
+ lines.push(after.status === "awaiting"
127
+ ? "appeared in the inbox — GitHub is asking you for a review"
128
+ : `first written here (${after.status})`);
129
+ }
130
+ else {
131
+ if (before.status !== after.status)
132
+ lines.push(`status ${before.status} → ${after.status}`);
133
+ if (before.pr.headSha !== after.pr.headSha && after.pr.headSha) {
134
+ lines.push(before.pr.headSha
135
+ ? `head moved ${short(before.pr.headSha)} → ${short(after.pr.headSha)}`
136
+ : `head is ${short(after.pr.headSha)}`);
137
+ }
138
+ if (before.pr.state !== after.pr.state) {
139
+ lines.push(after.pr.state === "OPEN" ? "PR reopened" : `PR ${after.pr.state.toLowerCase()}`);
140
+ }
141
+ if (before.pr.isDraft !== after.pr.isDraft) {
142
+ lines.push(after.pr.isDraft ? "turned back into a draft" : "marked ready for review");
143
+ }
144
+ }
145
+ const wasRun = before?.run ?? null;
146
+ const run = after.run;
147
+ if (run && run.startedAt !== wasRun?.startedAt)
148
+ lines.push(`review started (${runShape(run)})`);
149
+ if (run?.finishedAt && run.finishedAt !== wasRun?.finishedAt && !run.error) {
150
+ lines.push(`review finished${run.reviewedSha ? ` at ${short(run.reviewedSha)}` : ""}` +
151
+ `${run.costUsd != null ? ` (≈$${run.costUsd.toFixed(2)} at API rates)` : ""}`);
152
+ }
153
+ if (run?.error && run.error !== wasRun?.error)
154
+ lines.push(`review failed: ${run.error}`);
155
+ const wasVerdict = before?.verdict ?? null;
156
+ const verdict = after.verdict;
157
+ if (verdict &&
158
+ (!wasVerdict ||
159
+ wasVerdict.recommendation !== verdict.recommendation ||
160
+ wasVerdict.confidence !== verdict.confidence)) {
161
+ lines.push(`verdict ${wasVerdict ? "changed to" : "set to"} ${verdict.recommendation.replace("_", " ")}` +
162
+ ` (${verdict.confidence}% sure of the findings)`);
163
+ }
164
+ const comments = describeComments(before?.comments ?? [], after.comments);
165
+ if (comments)
166
+ lines.push(comments);
167
+ if (after.sent && after.sent.at !== before?.sent?.at) {
168
+ lines.push(`sent to GitHub as ${after.sent.event.toLowerCase().replace("_", " ")}` +
169
+ `${after.sent.auto ? ", by auto-send" : ""}`);
170
+ }
171
+ if (after.filed && after.filed.at !== before?.filed?.at) {
172
+ lines.push(`filed under settled — ${FILED_PHRASE[after.filed.reason] ?? after.filed.reason}`);
173
+ }
174
+ if (after.refresh && after.refresh.at !== before?.refresh?.at) {
175
+ const r = after.refresh;
176
+ lines.push(`pulled forward onto ${short(r.toSha)} — ${r.moved} comment(s) followed the code` +
177
+ `${r.drifted > 0 ? `, ${r.drifted} drifted` : ""}`);
178
+ }
179
+ return lines;
180
+ }
181
+ /**
182
+ * Would this note be dropped as a repeat of the last thing said?
183
+ *
184
+ * The rule belongs to `appendHistory`, which drops such a note; this is the
185
+ * same question asked ahead of time, so a caller can skip a write that would
186
+ * change nothing. Exported rather than inferred from the result: at the cap an
187
+ * appended entry trims an older one, so the log's *length* is not evidence of
188
+ * anything having been added, and a caller comparing lengths would silently
189
+ * stop recording notes the moment a review reached `MAX_ENTRIES`.
190
+ */
191
+ export function noteIsRepeat(prior, note) {
192
+ return (prior?.history ?? []).at(-1)?.what === note;
193
+ }
194
+ /**
195
+ * The history to write, given what is on disk and what is about to replace it.
196
+ *
197
+ * Whatever history the caller is holding is ignored: several write paths
198
+ * legitimately hand over an artifact built minutes ago, and their copy of the
199
+ * log is stale by definition. Disk is the only source.
200
+ */
201
+ export function appendHistory(prior, next, opts = {}) {
202
+ const kept = prior?.history ?? [];
203
+ const { by, cause } = currentWriter();
204
+ const at = new Date().toISOString();
205
+ const what = opts.unreadable
206
+ ? ["history restarts here — the previous file could not be read"]
207
+ : opts.note
208
+ ? // A note explains a decision the poll re-takes every few minutes. Said
209
+ // once: repeating it until something else happens is noise, and the
210
+ // entry it would duplicate already says the same thing.
211
+ noteIsRepeat(prior, opts.note)
212
+ ? []
213
+ : [opts.note]
214
+ : describeChange(prior, next);
215
+ const all = [...kept, ...what.map((w) => ({ at, by, cause, what: w }))];
216
+ if (all.length <= MAX_ENTRIES)
217
+ return all;
218
+ const [oldest, ...rest] = all.slice(-(MAX_ENTRIES - 1));
219
+ if (!oldest)
220
+ return all;
221
+ return [
222
+ { at: oldest.at, by: "unknown", cause: null, what: "… earlier entries dropped" },
223
+ oldest,
224
+ ...rest,
225
+ ];
226
+ }
@@ -71,6 +71,10 @@ export function mergeRunResult(fresh, current) {
71
71
  return {
72
72
  ...fresh,
73
73
  status: userOwnsStatus(current) ? current.status : fresh.status,
74
+ // Travels with the status it dates. A settle that landed while the run
75
+ // worked keeps both halves of itself, and a run that reopens the row takes
76
+ // the stamp away with the status.
77
+ settledAt: userOwnsStatus(current) ? current.settledAt : fresh.settledAt,
74
78
  sent: current.sent,
75
79
  calibration: current.calibration,
76
80
  filed: current.filed,
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { ArtifactSchema, artifactKey } from "./artifact.js";
5
+ import { appendHistory, noteIsRepeat } from "./history.js";
5
6
  export function cerberHome() {
6
7
  return process.env.CERBER_HOME ?? path.join(os.homedir(), ".cerber");
7
8
  }
@@ -11,14 +12,91 @@ function reviewsDir() {
11
12
  function artifactPath(id) {
12
13
  return path.join(reviewsDir(), `${artifactKey(id)}.json`);
13
14
  }
14
- export async function saveArtifact(artifact) {
15
+ /**
16
+ * Read whatever is on disk, tolerating anything.
17
+ *
18
+ * These files are the user's to edit, so a broken one has to be survivable:
19
+ * before, a save simply overwrote it. It still does — but the timeline says so
20
+ * rather than quietly claiming the review began at that moment.
21
+ */
22
+ async function readPrior(file) {
23
+ let raw;
24
+ try {
25
+ raw = await fs.readFile(file, "utf8");
26
+ }
27
+ catch (err) {
28
+ if (err.code === "ENOENT")
29
+ return { artifact: null, unreadable: false };
30
+ return { artifact: null, unreadable: true };
31
+ }
32
+ try {
33
+ return { artifact: ArtifactSchema.parse(JSON.parse(raw)), unreadable: false };
34
+ }
35
+ catch {
36
+ return { artifact: null, unreadable: true };
37
+ }
38
+ }
39
+ /**
40
+ * Write an artifact, and record what changed about it.
41
+ *
42
+ * The history is appended here rather than by the caller on purpose. Around
43
+ * twenty places write artifacts and several of them overwrite one wholesale
44
+ * from a copy built minutes earlier — a log any of them had to remember to
45
+ * carry would be lost by the first one that didn't. Appending at the one place
46
+ * every write goes through makes the record a property of writing.
47
+ *
48
+ * The read here is not skippable, even for a caller that has just done one of
49
+ * its own. Two writers share these files — the poll's timer and the cockpit's
50
+ * button — so a caller's copy can be out of date by the time it writes, and
51
+ * appending to *that* would drop whatever the other one recorded in between.
52
+ * The rest of the artifact is lost in that race either way; the history need
53
+ * not be, and the extra read is one file next to a write of the same file.
54
+ *
55
+ * `note` records a decision that changed nothing, which is the only kind of
56
+ * history a diff cannot see. It does not touch `updatedAt` — that belongs to
57
+ * `updateArtifactByKey`.
58
+ */
59
+ export async function saveArtifact(artifact, opts = {}) {
15
60
  await fs.mkdir(reviewsDir(), { recursive: true });
16
61
  const file = artifactPath(artifact.id);
62
+ const prior = await readPrior(file);
63
+ const next = {
64
+ ...artifact,
65
+ history: appendHistory(prior.artifact, artifact, {
66
+ note: opts.note,
67
+ unreadable: prior.unreadable,
68
+ }),
69
+ };
17
70
  const tmp = `${file}.tmp`;
18
- await fs.writeFile(tmp, JSON.stringify(artifact, null, 2));
71
+ await fs.writeFile(tmp, JSON.stringify(next, null, 2));
19
72
  await fs.rename(tmp, file);
20
73
  return file;
21
74
  }
75
+ /**
76
+ * Write down a decision that changed nothing.
77
+ *
78
+ * The poll's silences are the hardest thing to debug about it — it looks at a
79
+ * settled row, decides deliberately to leave it alone, and leaves no trace of
80
+ * having looked. This is that trace. It is not a change to the review, so
81
+ * `updatedAt` stays put: a note must not reorder the queue.
82
+ *
83
+ * Missing artifacts are ignored, and a note identical to the last entry is
84
+ * dropped, so a decision re-taken every poll is recorded once.
85
+ */
86
+ export async function noteHistory(id, what) {
87
+ const prior = await loadArtifact(id).catch(() => null);
88
+ if (!prior)
89
+ return;
90
+ // Nothing to add means nothing to write: the poll re-takes these decisions
91
+ // every few minutes, and rewriting a whole artifact, diff and all, to change
92
+ // nothing is the expensive half of saying it again. The rule for "nothing to
93
+ // add" is `appendHistory`'s, so ask it rather than knowing it twice — and ask
94
+ // it directly, because at the cap an appended note trims an older entry and
95
+ // leaves the length exactly as it was.
96
+ if (noteIsRepeat(prior, what))
97
+ return;
98
+ await saveArtifact(prior, { note: what });
99
+ }
22
100
  export async function loadArtifact(id) {
23
101
  try {
24
102
  const raw = await fs.readFile(artifactPath(id), "utf8");
@@ -3,7 +3,8 @@ import { AiReviewSchema, SCHEMA_VERSION, artifactId, artifactKey, } from "../cor
3
3
  import { createRunDir, evictOldCheckouts, prepareCheckout, removeRunDir } from "../core/checkout.js";
4
4
  import { fetchPrDiff, fetchPrInfo, isOrgMember, isTeamMember } from "../core/gh.js";
5
5
  import { mergeRunResult, userOwnsStatus } from "../core/refresh.js";
6
- import { loadArtifact, saveArtifact, updateArtifactByKey } from "../core/state.js";
6
+ import { loadArtifact, noteHistory, saveArtifact, updateArtifactByKey } from "../core/state.js";
7
+ import { withWriter } from "../core/history.js";
7
8
  import { loadConfig } from "../core/config.js";
8
9
  import { decideTrust, membershipQueries, parseTrustRules } from "../core/trust.js";
9
10
  import { extractJson, runClaude, unauthenticatedEnv } from "./claude.js";
@@ -32,7 +33,7 @@ const HEAD_SENSITIVE = new Set(["ready", "sent"]);
32
33
  * as cerber overruling you — and on a busy PR it happens every few minutes.
33
34
  * The way back in is the way you got out: press re-review, which forces.
34
35
  */
35
- const SETTLED_BY_YOU = new Set(["reviewed", "skipped"]);
36
+ export const SETTLED_BY_YOU = new Set(["reviewed", "skipped"]);
36
37
  /**
37
38
  * Fetch a PR, run the AI review, persist the artifact at each stage.
38
39
  * Everything stays local — nothing is ever written to GitHub.
@@ -80,15 +81,28 @@ async function resolveTrust(pr, opts, log) {
80
81
  log(`Trusted (${decision.reason}): the review may run commands in the checkout.`);
81
82
  return decision.trusted;
82
83
  }
84
+ /**
85
+ * Decide whether to run, then run.
86
+ *
87
+ * The two halves are deliberately not in the same writer context. Everything
88
+ * down to the guards belongs to whoever asked — the poll's timer, you at a
89
+ * terminal, the cockpit's button — and that is the whole value of the notes
90
+ * they write: a review that did not happen has no runner to blame, and "who
91
+ * wanted one" is the fact worth keeping. Only the run itself is the runner's.
92
+ */
83
93
  async function runReview(ref, opts) {
84
94
  const log = opts.onProgress ?? (() => { });
85
- const now = () => new Date().toISOString();
86
95
  log(`Fetching ${ref.owner}/${ref.repo}#${ref.number}…`);
87
96
  const pr = await fetchPrInfo(ref);
88
97
  const existing = await loadArtifact(artifactId(pr));
89
98
  if (existing && !opts.force) {
90
99
  if (SETTLED_BY_YOU.has(existing.status)) {
91
100
  log(`You marked this ${existing.status} — leaving it alone. Use --force to re-review.`);
101
+ // Written down because it is the poll's most confusing silence: a row
102
+ // the author keeps pushing to, that never comes back into the inbox.
103
+ // Only a push, now — somebody asking again does reopen it, and says so
104
+ // for itself (`reopenIfAskedAgain`).
105
+ await noteHistory(existing.id, `left alone: you marked it ${existing.status}, so a new push does not reopen it`);
92
106
  return { artifact: existing, skipped: true };
93
107
  }
94
108
  // The sha the AI *read*, not the one the artifact happens to mention.
@@ -100,9 +114,18 @@ async function runReview(ref, opts) {
100
114
  const reviewedSha = existing.run?.reviewedSha ?? existing.pr.headSha;
101
115
  if (HEAD_SENSITIVE.has(existing.status) && reviewedSha !== "" && reviewedSha === pr.headSha) {
102
116
  log(`Up to date (reviewed at ${reviewedSha.slice(0, 7)}, status ${existing.status}) — skipping. Use --force to re-review.`);
117
+ // Carries the sha, so it says itself again the next time the head moves
118
+ // and this guard stops being the reason nothing happened.
119
+ await noteHistory(existing.id, `already reviewed at ${reviewedSha.slice(0, 7)} — not re-reviewed`);
103
120
  return { artifact: existing, skipped: true };
104
121
  }
105
122
  }
123
+ // The run owns its writes from here, whoever asked for it: a re-review
124
+ // started from a cockpit click is still the runner rewriting the draft.
125
+ return await withWriter({ by: "runner", cause: "review" }, () => performReview(ref, pr, existing, opts, log));
126
+ }
127
+ async function performReview(ref, pr, existing, opts, log) {
128
+ const now = () => new Date().toISOString();
106
129
  const diff = await fetchPrDiff(ref);
107
130
  const trusted = await resolveTrust(pr, opts, log);
108
131
  // A checkout is an optimisation, never a precondition: if git or gh can't
@@ -160,6 +183,7 @@ async function runReview(ref, opts) {
160
183
  sent: null,
161
184
  refresh: null,
162
185
  filed: null,
186
+ settledAt: null,
163
187
  calibration: null,
164
188
  // The conversation is the user's writing, so a re-review keeps it — the
165
189
  // chat prompt replays the transcript, which is what makes it survive the