@fullstackhouse/cerber 0.39.1 → 0.40.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
@@ -429,8 +429,31 @@ cockpit (or `cerber review <pr> --force`).
429
429
  > starts and they do not come back, on success or on failure. This is a
430
430
  > decision, not a bug ([`docs/lifecycle.md`](docs/lifecycle.md)): the run owns
431
431
  > the draft. If you have written comments you want to keep, send the review or
432
- > copy them out first. Your *decisions* do survive — a send, a `reviewed` or
433
- > `skipped` you set, and the chat transcript are all kept.
432
+ > copy them out first. Your *decisions* do survive — a `reviewed` or `skipped`
433
+ > you set and the chat transcript are kept, and a send is kept as a record of
434
+ > what went to GitHub (see the next section).
435
+
436
+ ### Re-reviewing one you already sent
437
+
438
+ Sending freezes the draft — it is the record of what GitHub has, so nothing
439
+ can edit it afterwards. What you can do is replace it. The same **Re-review at
440
+ head** button works on a sent review, and on a PR whose author has pushed
441
+ since it is the only thing that does: the review you sent answers a commit
442
+ that is gone, and cerber will not rewrite a review that is already public.
443
+
444
+ The run drafts against the current head and hands you an ordinary unsent
445
+ review again — editable, arguable, sendable. What you sent is kept beside it:
446
+ a note above the draft says what you posted and when, with a link, and the
447
+ line over the Send button says the next click would be review 2. **Nothing on
448
+ GitHub changes** — a second review sits beside the first; cerber never edits
449
+ or withdraws what it posted.
450
+
451
+ The poll does this on its own, too, for a sent review whose PR moved on and
452
+ whose author has asked you for another look. The two things that can reach
453
+ GitHub without you both stand down on a row like that: auto-send will not
454
+ approve a PR you have already reviewed yourself, and a handoff stops offering
455
+ to carry the draft — it is a new draft, but sending it would still be your
456
+ second review on that PR.
434
457
 
435
458
  ## Arguing with the review before you send it
436
459
 
@@ -488,7 +511,8 @@ the exchange went anywhere near GitHub.
488
511
  Nothing in the conversation reaches GitHub. Send still builds its payload from
489
512
  the summary, comments and verdict alone, and is still one deliberate click. A
490
513
  review that has already been sent can't be argued with: that artifact is the
491
- record of what GitHub has.
514
+ record of what GitHub has — re-review it first, and the new draft is open to
515
+ the conversation like any other.
492
516
 
493
517
  ## Every row remembers what happened to it
494
518
 
package/dist/cli/index.js CHANGED
@@ -231,6 +231,18 @@ program
231
231
  }
232
232
  const payload = buildReviewPayload(artifact, event);
233
233
  console.log(`About to send to ${artifact.pr.url}:`);
234
+ // Said before anything else about the payload: on a PR you have already
235
+ // reviewed, "what is in this review" is the second question. The cockpit's
236
+ // send panel says the same thing in the same place (SPEC §8.3).
237
+ const posted = artifact.priorSends;
238
+ if (posted.length > 0) {
239
+ const last = posted[posted.length - 1];
240
+ const when = new Date(last.at).toLocaleDateString();
241
+ console.log(` ⚠ you already sent ${posted.length === 1 ? "a review" : `${posted.length} reviews`}` +
242
+ ` on this PR${posted.length === 1 ? "" : ", the last"} on ${when}` +
243
+ ` as ${last.event.toLowerCase().replace("_", " ")}${last.url ? ` (${last.url})` : ""}.` +
244
+ ` This would be review ${posted.length + 1}; it sits beside them, replacing nothing.`);
245
+ }
234
246
  console.log(` event: ${event}`);
235
247
  console.log(` inline comments: ${payload.comments.length}`);
236
248
  if (payload.folded.length > 0) {
@@ -403,6 +403,22 @@ export const ArtifactSchema = z.object({
403
403
  run: RunInfoSchema.nullable().default(null),
404
404
  /** Set once the review was sent to GitHub (explicitly, or via opt-in auto-send). */
405
405
  sent: SentInfoSchema.nullable().default(null),
406
+ /**
407
+ * Reviews posted from this row that a later re-review has since replaced,
408
+ * oldest first.
409
+ *
410
+ * `sent` means "the draft on this page is the one that went to GitHub", and
411
+ * every guard in cerber reads it that way — the send path's "already sent"
412
+ * refusal most of all. So when a re-review replaces that draft the send
413
+ * cannot stay in the field (the new draft was never sent) and must not simply
414
+ * go (it is on the PR, under the user's name, for good). It moves here.
415
+ *
416
+ * What it buys is a cockpit that can say "you reviewed this on the 14th" over
417
+ * a draft of the 16th, and a send panel that says the next click posts a
418
+ * second review rather than a first. The GitHub URL rides along, because the
419
+ * record is only useful if the thing it names can be opened.
420
+ */
421
+ priorSends: z.array(SentInfoSchema).default([]),
406
422
  /** Set when cerber filed this draft away itself, and why. Never on a sent one. */
407
423
  filed: FiledInfoSchema.nullable().default(null),
408
424
  /** Set when you gave this review to somebody else. Mutually exclusive with `filed`. */
@@ -13,6 +13,14 @@ import { sendTreatment } from "./raised.js";
13
13
  export function evaluateAutoSend(artifact, threshold) {
14
14
  if (artifact.sent)
15
15
  return { eligible: false, reason: "already sent" };
16
+ // The same rule, for a send a re-review has since superseded. `sent` only
17
+ // ever describes the draft on the row, so once one moves to `priorSends`
18
+ // that guard stops seeing it — and "never a re-send" would quietly come to
19
+ // mean "never twice in a row". A PR this user has already reviewed is the
20
+ // last place to post unattended: the second review is theirs to send.
21
+ if (artifact.priorSends.length > 0) {
22
+ return { eligible: false, reason: "a review was already sent on this PR — the next one is yours to send" };
23
+ }
16
24
  if (artifact.status !== "ready")
17
25
  return { eligible: false, reason: `status is ${artifact.status}` };
18
26
  if (!artifact.verdict)
@@ -174,6 +174,11 @@ export function describeChange(before, after) {
174
174
  lines.push(`sent to GitHub as ${after.sent.event.toLowerCase().replace("_", " ")}` +
175
175
  `${after.sent.auto ? ", by auto-send" : ""}`);
176
176
  }
177
+ // The one state change nothing else on this list would record: the row went
178
+ // from "this draft is on GitHub" back to "this draft has never been sent".
179
+ if (after.priorSends.length > (before?.priorSends.length ?? 0)) {
180
+ lines.push("a new draft took over from the review that was sent — what was sent stays on GitHub");
181
+ }
177
182
  if (after.filed && after.filed.at !== before?.filed?.at) {
178
183
  lines.push(`filed under settled — ${FILED_PHRASE[after.filed.reason] ?? after.filed.reason}`);
179
184
  }
@@ -44,6 +44,19 @@ export function refreshArtifact(artifact, pr, diff) {
44
44
  export function userOwnsStatus(a) {
45
45
  return a.sent !== null || a.status === "reviewed" || a.status === "skipped";
46
46
  }
47
+ /**
48
+ * This row's sends, with the one a starting run is about to supersede folded in.
49
+ *
50
+ * The only way back into a review that was sent is a new draft, and `sent`
51
+ * describes a draft that is about to stop existing — so it cannot stay where
52
+ * it is. It also cannot simply go: that review is on the PR under the user's
53
+ * name, and a cockpit that forgets it would offer a second one as if it were
54
+ * the first. Called by the claim at the top of a run, which is the one moment
55
+ * a send stops covering what is on the page.
56
+ */
57
+ export function supersede(a) {
58
+ return a.sent ? [...a.priorSends, a.sent] : a.priorSends;
59
+ }
47
60
  /**
48
61
  * Fold a finished review run onto whatever the artifact says now.
49
62
  *
@@ -76,6 +89,9 @@ export function mergeRunResult(fresh, current) {
76
89
  // the stamp away with the status.
77
90
  settledAt: userOwnsStatus(current) ? current.settledAt : fresh.settledAt,
78
91
  sent: current.sent,
92
+ // Written by this run's own claim (`supersede`), or by an earlier one —
93
+ // either way the disk is the only copy; `fresh` was built before the claim.
94
+ priorSends: current.priorSends,
79
95
  // Disk wins: the ledger is the poll's, and the run knows nothing about it.
80
96
  notified: current.notified,
81
97
  calibration: current.calibration,
@@ -197,11 +197,18 @@ export async function reconcileRunning(opts = {}) {
197
197
  const stuckCheck = artifact.raisedCheck?.checkingSince != null;
198
198
  if (!stuckRun && !stuckChat && !stuckCheck)
199
199
  continue;
200
+ // A row still carrying its send was interrupted before the run claimed it,
201
+ // so the draft on it is untouched — and it is the one that went to GitHub.
202
+ // "failed" would hide a sent review behind a broken re-review, and the
203
+ // interruption belongs to the re-review rather than to the run block,
204
+ // which still describes the review that was posted. It goes to the history
205
+ // instead, which is where a thing that happened *to* a row belongs.
206
+ const interruptedSend = stuckRun && artifact.sent != null;
200
207
  await saveArtifact({
201
208
  ...artifact,
202
- status: stuckRun ? "failed" : artifact.status,
209
+ status: stuckRun ? (artifact.sent ? "sent" : "failed") : artifact.status,
203
210
  updatedAt: new Date().toISOString(),
204
- run: stuckRun && artifact.run
211
+ run: stuckRun && artifact.run && !interruptedSend
205
212
  ? { ...artifact.run, error: "interrupted — cerber restarted while this review was running" }
206
213
  : artifact.run,
207
214
  pendingChat: stuckChat
@@ -211,6 +218,9 @@ export async function reconcileRunning(opts = {}) {
211
218
  // report, and the next open starts another.
212
219
  raisedCheck: artifact.raisedCheck && { ...artifact.raisedCheck, checkingSince: null },
213
220
  });
221
+ if (interruptedSend) {
222
+ await noteHistory(artifact.id, "re-review interrupted — cerber restarted; the sent review is untouched");
223
+ }
214
224
  cleared++;
215
225
  }
216
226
  return cleared;
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { AiReviewSchema, SCHEMA_VERSION, artifactId, artifactKey, } from "../core/artifact.js";
3
3
  import { createRunDir, evictOldCheckouts, prepareCheckout, removeRunDir } from "../core/checkout.js";
4
4
  import { fetchPrDiff, fetchPrInfo, isOrgMember, isTeamMember } from "../core/gh.js";
5
- import { mergeRunResult, userOwnsStatus } from "../core/refresh.js";
5
+ import { mergeRunResult, supersede, userOwnsStatus } from "../core/refresh.js";
6
6
  import { loadArtifact, noteHistory, saveArtifact, updateArtifactByKey } from "../core/state.js";
7
7
  import { withWriter } from "../core/history.js";
8
8
  import { loadConfig } from "../core/config.js";
@@ -191,13 +191,19 @@ async function performReview(ref, pr, existing, opts, log) {
191
191
  trigger: opts.trigger ?? "user",
192
192
  reviewedSha: null,
193
193
  },
194
+ // Both filled in at the claim below, from the row as it stands then: a
195
+ // send this run supersedes moves to `priorSends` rather than vanishing.
194
196
  sent: null,
197
+ priorSends: [],
195
198
  refresh: null,
196
199
  // A new draft has new findings, compared afresh once the run has written them.
197
200
  raisedCheck: null,
198
201
  filed: null,
199
202
  handoff: null,
200
203
  settledAt: null,
204
+ // Carried at the claim, not reset: it describes a review that really was
205
+ // sent, and `cerber stats` is the only reader. Dropping it here
206
+ // would quietly delete one data point every time a sent PR is re-reviewed.
201
207
  calibration: null,
202
208
  // The conversation is the user's writing, so a re-review keeps it — the
203
209
  // chat prompt replays the transcript, which is what makes it survive the
@@ -222,10 +228,23 @@ async function performReview(ref, pr, existing, opts, log) {
222
228
  // (the guard above only lets a forced re-review that far), and claiming it is
223
229
  // how the reopen happens at all. A settle that appeared since is the one this
224
230
  // must not touch.
231
+ //
232
+ // A send is the one decision the claim takes *over* rather than leaves
233
+ // alone, because the only way to reopen a sent row is to replace the draft
234
+ // that was sent. It is not discarded: `supersede` files it under
235
+ // `priorSends`, so the cockpit can still say what went to GitHub and when,
236
+ // and the send panel knows the next click would post a second review.
237
+ // Read from `current` rather than `existing`, which is minutes old.
225
238
  const decided = existing ? userOwnsStatus(existing) : false;
226
239
  const claimed = await updateArtifactByKey(artifactKey(artifact.id), (current) => !decided && userOwnsStatus(current)
227
240
  ? current
228
- : { ...artifact, notified: current.notified, viewed: current.viewed });
241
+ : {
242
+ ...artifact,
243
+ notified: current.notified,
244
+ viewed: current.viewed,
245
+ priorSends: supersede(current),
246
+ calibration: current.calibration,
247
+ });
229
248
  const held = claimed !== null && !decided && userOwnsStatus(claimed);
230
249
  if (!held) {
231
250
  if (claimed)
@@ -46,6 +46,7 @@ export function stubArtifact(ref) {
46
46
  run: null,
47
47
  sent: null,
48
48
  refresh: null,
49
+ priorSends: [],
49
50
  filed: null,
50
51
  handoff: null,
51
52
  raisedCheck: null,
@@ -18,7 +18,7 @@ import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../co
18
18
  import { buildReviewPayload, computeCalibration, describeReplyFailures, recordReplies, } from "../core/send.js";
19
19
  import { raisedChanged, rewriteFinding, startRaisedCheck } from "../core/raised.js";
20
20
  import { checkAlreadyRaised, claimRaisedCheck, finishRaisedCheck, isRaisedCheckRunning, prepareRaisedCheck, releaseRaisedCheck, } from "../runner/raised.js";
21
- import { listArtifacts, loadArtifact, loadArtifactByKey, saveArtifact, updateArtifactByKey, } from "../core/state.js";
21
+ import { listArtifacts, loadArtifact, loadArtifactByKey, noteHistory, saveArtifact, updateArtifactByKey, } from "../core/state.js";
22
22
  import { isReviewRunning } from "../runner/inflight.js";
23
23
  import { reviewPr } from "../runner/review.js";
24
24
  import { runChatTurn } from "../runner/chat.js";
@@ -342,6 +342,7 @@ export async function buildApp(opts) {
342
342
  },
343
343
  sent: null,
344
344
  refresh: null,
345
+ priorSends: [],
345
346
  filed: null,
346
347
  handoff: null,
347
348
  raisedCheck: null,
@@ -568,8 +569,14 @@ export async function buildApp(opts) {
568
569
  const stale = artifact.pr.headSha !== "" && artifact.pr.headSha !== pr.headSha;
569
570
  // A sent review is a record of what was posted — never rewrite it. A running
570
571
  // one is mid-run and would be overwritten from under the runner.
572
+ //
573
+ // `headSha` rides on the answer either way, and it is the only thing a
574
+ // declined refresh can tell the cockpit: on a sent row nothing is written,
575
+ // so the artifact handed back still names the commit that was reviewed,
576
+ // and the page would have nothing to say about a PR that has moved past a
577
+ // review the user cannot edit but can replace.
571
578
  if (!stale || artifact.sent || artifact.status === "running") {
572
- return c.json({ stale, changed: false, prState: pr.state, artifact });
579
+ return c.json({ stale, changed: false, prState: pr.state, headSha: pr.headSha, artifact });
573
580
  }
574
581
  try {
575
582
  const diff = await fetchPrDiff(ref);
@@ -598,6 +605,7 @@ export async function buildApp(opts) {
598
605
  stale: true,
599
606
  changed: applied && result.changed,
600
607
  prState: pr.state,
608
+ headSha: pr.headSha,
601
609
  moved: applied ? result.moved : 0,
602
610
  drifted: applied ? result.drifted : 0,
603
611
  artifact: saved,
@@ -613,8 +621,13 @@ export async function buildApp(opts) {
613
621
  const artifact = await loadArtifactByKey(key);
614
622
  if (!artifact)
615
623
  return c.json({ error: "not found" }, 404);
616
- if (artifact.sent)
617
- return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
624
+ // A sent row may be re-reviewed, and this is the only way back into one.
625
+ // The author pushed again, somebody asked for another look, the review
626
+ // went out at a commit that no longer exists — none of that is answered by
627
+ // the review already on the PR, and refusing here left the cockpit with no
628
+ // move at all while `cerber review --force` did it from a terminal. What
629
+ // was sent is kept (`supersede`), the new draft is sendable in its own
630
+ // right, and the send panel says it would be a second review.
618
631
  if (isReviewRunning(artifact.id)) {
619
632
  return c.json({ error: busy(artifact, "a review of this PR is already running") }, 409);
620
633
  }
@@ -625,21 +638,29 @@ export async function buildApp(opts) {
625
638
  const withSource = sourceParam == null ? opts.withSource : sourceParam !== "0";
626
639
  // Mark it running before responding, so the cockpit's next poll can't catch
627
640
  // the old "ready" status and conclude the run already finished.
641
+ //
642
+ // The run block is left alone while the row still carries its send: that
643
+ // block describes the review on GitHub — its model, its cost, the commit
644
+ // it read — and a stub written here would erase all of it minutes before
645
+ // the runner's claim writes a real one, with nothing able to bring it back
646
+ // if the run never gets that far. The claim replaces it either way.
628
647
  const running = await updateArtifactByKey(key, (a) => ({
629
648
  ...a,
630
649
  status: "running",
631
- run: {
632
- model: null,
633
- startedAt: new Date().toISOString(),
634
- finishedAt: null,
635
- costUsd: null,
636
- error: null,
637
- withSource: withSource !== false,
638
- trusted: false,
639
- sessionId: null,
640
- trigger: "user",
641
- reviewedSha: null,
642
- },
650
+ run: a.sent
651
+ ? a.run
652
+ : {
653
+ model: null,
654
+ startedAt: new Date().toISOString(),
655
+ finishedAt: null,
656
+ costUsd: null,
657
+ error: null,
658
+ withSource: withSource !== false,
659
+ trusted: false,
660
+ sessionId: null,
661
+ trigger: "user",
662
+ reviewedSha: null,
663
+ },
643
664
  }));
644
665
  // Minutes-long: run it detached and let the cockpit poll the artifact.
645
666
  void reviewPr(ref, {
@@ -654,9 +675,20 @@ export async function buildApp(opts) {
654
675
  // The runner marks the artifact failed once it owns it; a failure before
655
676
  // that (fetching the PR, or a run already in flight) would otherwise
656
677
  // leave it stuck at "running" with nothing to clear it.
657
- await updateArtifactByKey(key, (a) => a.status === "running"
658
- ? { ...a, status: "failed", run: a.run ? { ...a.run, error: message } : null }
659
- : a).catch(() => { });
678
+ //
679
+ // A row that still carries its send never got as far as the claim, so
680
+ // the draft on it is the one that went to GitHub and nothing about it
681
+ // has changed: it goes back to `sent`, untouched. The failure is the
682
+ // re-review's, not that review's, so it goes in the history rather
683
+ // than onto a run block that describes something else entirely.
684
+ const back = await updateArtifactByKey(key, (a) => a.status === "running"
685
+ ? a.sent
686
+ ? { ...a, status: "sent" }
687
+ : { ...a, status: "failed", run: a.run ? { ...a.run, error: message } : null }
688
+ : a).catch(() => null);
689
+ if (back?.sent) {
690
+ await noteHistory(back.id, `re-review did not start: ${message}`).catch(() => { });
691
+ }
660
692
  });
661
693
  return c.json(running, 202);
662
694
  });
@@ -1017,12 +1049,36 @@ export async function buildApp(opts) {
1017
1049
  let sendError = null;
1018
1050
  let sent = null;
1019
1051
  let payload = null;
1020
- const alreadySent = postReview ? ((await loadArtifactByKey(key))?.sent ?? artifact.sent) : null;
1052
+ //
1053
+ // A send a re-review has since superseded counts here too. It is not on
1054
+ // this draft, but it is on the PR under the user's name, and this guard
1055
+ // exists to stop a handoff being the second review — which it would be.
1056
+ const onDisk = postReview ? await loadArtifactByKey(key) : null;
1057
+ const alreadySent = postReview
1058
+ ? (onDisk?.sent ??
1059
+ artifact.sent ??
1060
+ (onDisk ?? artifact).priorSends.at(-1) ??
1061
+ null)
1062
+ : null;
1021
1063
  // And there has to be a review to post. The dialog only offers the box on a
1022
1064
  // row that has one, but the dialog is not the guard: a stale tab or a direct
1023
1065
  // call would otherwise submit a body that is nothing but cerber's footer,
1024
1066
  // under the user's name, on a PR nobody has drafted a word about.
1025
1067
  const somethingToSay = artifact.summary !== "" || artifact.comments.some((cm) => cm.status !== "dropped");
1068
+ // A decline here has to say so. The dialog's own line promises "this row
1069
+ // goes under sent, with the review on the PR"; letting the guard refuse in
1070
+ // silence walks the user to the next review believing it posted.
1071
+ if (postReview && alreadySent) {
1072
+ // Phrased to read after the cockpit's own "the review did not post:",
1073
+ // which is what wraps every `sendError` on the way to the user.
1074
+ sendError =
1075
+ `a review was already sent on this PR on ${alreadySent.at.slice(0, 10)}` +
1076
+ `${alreadySent.url ? ` (${alreadySent.url})` : ""}, and a second one is yours to send.` +
1077
+ ` The draft is untouched.`;
1078
+ }
1079
+ else if (postReview && !somethingToSay) {
1080
+ sendError = "there is no draft on this row — only cerber's footer would have gone up.";
1081
+ }
1026
1082
  if (postReview && !alreadySent && somethingToSay) {
1027
1083
  payload = buildReviewPayload(artifact, "COMMENT");
1028
1084
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.39.1",
3
+ "version": "0.40.0",
4
4
  "description": "AI code-review cockpit: Claude reviews your PRs into local artifacts; nothing reaches GitHub until you say so.",
5
5
  "license": "MIT",
6
6
  "keywords": [