@fullstackhouse/cerber 0.25.0 → 0.27.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
@@ -391,6 +415,12 @@ above: the seven statuses and what sets each, which tab a PR lands in and why,
391
415
  when the poll files or archives a row on its own, when a re-review happens and
392
416
  what it will not overwrite, and what gets written to disk at each step.
393
417
 
418
+ [**SPEC.md**](SPEC.md) is the full service specification — the artifact
419
+ schema, every lifecycle and filing rule, the runner's tool policy and
420
+ credential hygiene, the send path — written precisely enough to reimplement
421
+ cerber from. lifecycle.md is the operator's view of the product; SPEC.md is
422
+ the engineer's.
423
+
394
424
  ## Development
395
425
 
396
426
  ```bash
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
@@ -299,6 +300,16 @@ export const ArtifactSchema = z.object({
299
300
  pendingChat: PendingChatSchema.nullable().default(null),
300
301
  /** The review as it stood before the first chat turn — "reset" restores this. */
301
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(),
302
313
  });
303
314
  /** What the AI must return. Subset of the artifact; cerber owns the rest. */
304
315
  export const AiReviewSchema = z.object({
package/dist/core/gh.js CHANGED
@@ -132,7 +132,7 @@ export async function fetchConversation(ref) {
132
132
  "--paginate",
133
133
  `repos/${ref.owner}/${ref.repo}/issues/${ref.number}/comments`,
134
134
  "--jq",
135
- '.[] | {author: .user.login, at: .created_at, bot: (.user.type == "Bot"), url: .html_url}',
135
+ '.[] | {author: .user.login, at: .created_at, body: (.body // ""), bot: (.user.type == "Bot"), url: .html_url}',
136
136
  ]);
137
137
  // --jq streams one object per line rather than a JSON array.
138
138
  return out
@@ -173,6 +173,39 @@ export function lastWordOfYours(comments, login) {
173
173
  const mine = comments.filter((c) => !isBot(c) && c.author === login);
174
174
  return mine[mine.length - 1] ?? null;
175
175
  }
176
+ /**
177
+ * Whether a comment speaks to you by name.
178
+ *
179
+ * GitHub's own rule, near enough: an `@login` that isn't part of a longer word.
180
+ * The boundary is "not a login character" rather than `\b`, because logins may
181
+ * contain hyphens — `@me-bot` is a different account from `@me`, and
182
+ * `you@me.dev` is an email address, not an ask.
183
+ *
184
+ * Deliberately not team mentions. `@org/team` is a room being addressed, and
185
+ * this is used to undo a decision of yours, which wants somebody naming *you*.
186
+ */
187
+ export function mentionsYou(body, login) {
188
+ const name = login.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
189
+ return new RegExp(`(^|[^\\w-])@${name}(?![\\w-])`, "i").test(body);
190
+ }
191
+ /**
192
+ * The last comment somebody addressed to you by name, or null if nobody has.
193
+ *
194
+ * The other half of what the conversation can say. `classifyReply` reads it for
195
+ * whose move it is; this reads the same comments for the ask GitHub's own API
196
+ * has no event for — "@you this is ready for another look", typed instead of
197
+ * clicking the re-request button. Your own comments never count, and bots never
198
+ * do: a bot that @-mentions the assignee on every push would otherwise be an
199
+ * ask on every push.
200
+ */
201
+ export function lastMentionOfYou(comments, login) {
202
+ const theirs = comments.filter((c) => !isBot(c) && c.author !== login && mentionsYou(c.body, login));
203
+ if (theirs.length === 0)
204
+ return null;
205
+ // By date, not by position: this undoes a settle, so it does not lean on the
206
+ // API happening to return the conversation in order.
207
+ return theirs.reduce((a, b) => (b.at > a.at ? b : a));
208
+ }
176
209
  /**
177
210
  * The last review of your own that still stands, or null.
178
211
  *
@@ -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
+ }
@@ -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";
@@ -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