@fullstackhouse/cerber 0.29.1 โ†’ 0.31.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
@@ -1,18 +1,38 @@
1
1
  # cerber ๐Ÿ•
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/%40fullstackhouse%2Fcerber)](https://www.npmjs.com/package/@fullstackhouse/cerber)
4
+ [![CI](https://github.com/fullstackhouse/cerber/actions/workflows/ci.yml/badge.svg)](https://github.com/fullstackhouse/cerber/actions/workflows/ci.yml)
4
5
  [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
5
6
 
6
- **AI code-review cockpit.** Claude reviews your pull requests into local artifacts โ€”
7
- a summary, a chaptered walkthrough of the changes, draft inline comments, and a
8
- verdict with a confidence score. You go through it in a local web cockpit.
7
+ **AI code-review cockpit.** Claude reviews your pull requests into local
8
+ artifacts โ€” a summary, a chaptered walkthrough of the changes, draft inline
9
+ comments, and a verdict with a confidence score. You read it in a local web
10
+ cockpit, keep or rewrite or drop each comment, then press Send when you agree
11
+ with it.
9
12
 
10
13
  **Nothing reaches GitHub until you explicitly say so.** Cerber's only GitHub
11
14
  write is the Send button (plus opt-in daemon auto-send you turn on yourself) โ€”
12
- reviewing is 100% local and read-only.
15
+ reviewing is 100% local and read-only. Reviews go out under your own account,
16
+ as your review, because you decided each line of it should.
17
+
18
+ That gate is the whole point, and the reason this is not another review bot:
19
+
20
+ > Reviewing pull requests with an AI model creates a specific danger: a system
21
+ > that can both draft opinions and hold GitHub credentials will eventually post
22
+ > one without a human deciding it should.
13
23
 
14
24
  Named after Cerberus, the gatekeeper: cerber guards what gets merged.
15
25
 
26
+ **A drafted review, as you read it** โ€” verdict and confidence beside the PR
27
+ title, the walkthrough rail on the left, a summary written for someone who has
28
+ not read the diff:
29
+
30
+ ![A review opened in the cockpit: the verdict pill by the PR title, a walkthrough rail listing chapters and their comments on the left, and a summary written in sections โ€” problem first, then the fix, then details.](docs/walkthrough.png)
31
+
32
+ **And the queue they arrive in** โ€” `cerber serve` polls GitHub for PRs awaiting
33
+ your review and drafts one for each, so they are written by the time you get
34
+ there:
35
+
16
36
  ![The queue: three drafted reviews, each with a verdict and confidence in its row; under the cursor, a strip quotes the selected review's own reasoning so it can be judged without opening it.](docs/queue.png)
17
37
 
18
38
  *Every screenshot in this README is cerber reviewing cerber's own pull
@@ -20,11 +40,25 @@ requests.*
20
40
 
21
41
  ## Quick start
22
42
 
23
- Requires Node 20+, an authenticated [`gh`](https://cli.github.com/) and a
24
- logged-in [`claude`](https://claude.com/claude-code) CLI. No API keys, no config, no database.
43
+ Requires Node 20+, an authenticated [`gh`](https://cli.github.com/), `git`, and
44
+ a logged-in [`claude`](https://claude.com/claude-code) CLI. No API keys, no
45
+ config, no database.
46
+
47
+ **Claude Code is a hard requirement, and a paid one.** Cerber drafts reviews by
48
+ running `claude` as you: it rides your existing Claude Code login and draws on
49
+ that plan's usage limits, so you need a Claude subscription that includes Claude
50
+ Code. There is no `ANTHROPIC_API_KEY` path โ€” not as a fallback, not at all.
51
+ GitHub only; no GitLab or Bitbucket. Notifications are macOS and Linux.
52
+
53
+ Run `cerber doctor` first โ€” it checks all three tools and tells you which one to
54
+ fix, instead of letting a missing login turn into an empty cockpit later.
25
55
 
26
56
  ```bash
27
- # Review a PR (writes a local artifact to ~/.cerber, nothing else)
57
+ # Open the cockpit โ€” this is the whole product
58
+ npx @fullstackhouse/cerber serve # โ†’ http://127.0.0.1:4820
59
+
60
+ # Or review one PR from the terminal (writes a local artifact to ~/.cerber,
61
+ # nothing else)
28
62
  npx @fullstackhouse/cerber review https://github.com/owner/repo/pull/123
29
63
 
30
64
  # Review several
@@ -33,22 +67,79 @@ npx @fullstackhouse/cerber review 123 124 --repo owner/repo
33
67
 
34
68
  # See what's in the queue
35
69
  npx @fullstackhouse/cerber list
36
-
37
- # Open the cockpit โ€” it's an inbox: it polls GitHub for PRs awaiting your
38
- # review and drafts a review for each, so they're ready when you arrive
39
- npx @fullstackhouse/cerber serve # โ†’ http://127.0.0.1:4820
40
70
  ```
41
71
 
42
72
  `serve` does the whole loop by default: discover โ†’ draft โ†’ wait for you.
43
73
  Sending stays a human click. Tame it with `--no-auto-review` (list awaiting
44
74
  PRs, review on click) or `--no-poll` (no GitHub polling at all) โ€” or flip those
45
75
  switches in the cockpit's Settings, which persist in `~/.cerber/config.json`
46
- and apply on the next poll. The queue only lists what
47
- still wants you: anything you have settled moves to the filed tabs right of
48
- the thin rule in the filter row โ€” reviews you sent, ones you marked reviewed
49
- or skipped, merged and closed PRs โ€” each tab appearing only while it holds
50
- something. PRs in archived repos are never picked up at all, since the repo
51
- is read-only and a review could never be sent.
76
+ and apply on the next poll.
77
+
78
+ ## Contents
79
+
80
+ - [What a review looks like](#what-a-review-looks-like) โ€” what Claude writes and how you walk it
81
+ - [The inbox](#the-inbox) โ€” what the queue shows, what settles a row, the notifications
82
+ - [It reviews the code, not just the diff](#it-reviews-the-code-not-just-the-diff)
83
+ - [Trusted PRs: reviews that can run things](#trusted-prs-reviews-that-can-run-things)
84
+ - [When the PR moves under you](#when-the-pr-moves-under-you)
85
+ - [Arguing with the review before you send it](#arguing-with-the-review-before-you-send-it)
86
+ - [Every row remembers what happened to it](#every-row-remembers-what-happened-to-it)
87
+ - [Status](#status) ยท [Running on a VPS](#running-on-a-vps) ยท [How it works](#how-it-works) ยท [Development](#development)
88
+
89
+ ## What a review looks like
90
+
91
+ Each review is a plain JSON artifact in `~/.cerber/reviews/` (override with
92
+ `CERBER_HOME`) containing:
93
+
94
+ - **Summary** โ€” what the PR actually does and why, written top-down
95
+ - **Chapters** โ€” the changed files grouped into a logical walkthrough, each
96
+ with a title, an explanation, and its slice of the diff
97
+ - **Draft comments** โ€” inline, anchored to file/line, only things worth a
98
+ human's time. Findings are graded **blocker / minor / nit** โ€” the words mean
99
+ what they mean everywhere, and only a blocker stands between the PR and
100
+ approval; a question or note carries no grade at all
101
+ - **Verdict** โ€” approve / comment / request changes, with a 0โ€“100 confidence
102
+ score and reasoning that names the worst finding still standing
103
+ - **Run metadata** โ€” model, whether it read the source or only the diff, and
104
+ the token spend as an API-rate equivalent
105
+
106
+ The cockpit (`cerber serve`) renders the queue and the per-PR walkthrough with
107
+ diffs. Draft comments sit inline in the diff, anchored to the line they're
108
+ about, each waiting for you to keep, rewrite, or drop it:
109
+
110
+ ![A chapter of the walkthrough: the diff with a draft comment card anchored under the line it discusses, explaining the finding in plain words.](docs/comment.png)
111
+
112
+ Any line of the diff is one click away from being talked about: hover it and a
113
+ `+` appears in the gutter, the way it does on GitHub. What opens is one box
114
+ with two exits โ€” write the comment yourself, or ask the reviewer about that
115
+ line and get the answer in the conversation below. A line the PR removes can be
116
+ asked about too; a comment on one posts on the file, since GitHub only takes
117
+ inline comments on the new side of a diff.
118
+
119
+ A markdown file the PR **adds** opens as the document it is, not as a thousand
120
+ rows of `+ ## Heading` โ€” because reviewing a new spec means reading it, and
121
+ every line being an addition means the diff's markers say nothing. It is still
122
+ the review: the draft comments sit in the document under the paragraph they
123
+ point at, and any paragraph takes a comment or a question where it stands. A
124
+ markdown file the PR only *edits* stays a diff โ€” there the change is the point
125
+ โ€” with "read as a document" in its header when you want the prose instead.
126
+
127
+ Artifacts are plain JSON you can `cat`, edit, or pipe into anything.
128
+
129
+ The queue is meant to be walked, not clicked through: `j`/`k` move the cursor
130
+ and the strip under the table explains whatever it lands on โ€” the verdict's own
131
+ reasoning, what the run read, how it sits against the auto-send bar โ€” so most
132
+ rows can be judged without opening them. `โ†ต` opens one, `r` drafts (or
133
+ re-drafts) it. Inside a review, `[` and `]` walk to the previous/next PR still
134
+ awaiting you, `n` steps through the chapters, and `s` sends.
135
+
136
+ ## The inbox
137
+
138
+ The queue only lists what still wants you: anything you have settled moves to
139
+ the filed tabs right of the thin rule in the filter row โ€” reviews you sent,
140
+ ones you marked reviewed or skipped, merged and closed PRs โ€” each tab appearing
141
+ only while it holds something. PRs in archived repos are never picked up at
142
+ all, since the repo is read-only and a review could never be sent.
52
143
 
53
144
  ### Settled means you settled it
54
145
 
@@ -88,13 +179,20 @@ who did is the one mistake worth designing against.
88
179
 
89
180
  ### It taps you on the shoulder
90
181
 
91
- When a PR lands in the queue you get a desktop notification naming it, folded
92
- into one popup when several arrive at once. The tap comes from the poll that
93
- found the PR, so it reaches you with the cockpit closed, in another Space, or
94
- after a browser restart โ€” `cerber serve` is the only thing that has to be
95
- running. It goes to whatever this machine already has (Notification Centre on
96
- macOS, `notify-send` on Linux) and stays quiet where there is neither. Off with
97
- `daemon.notify` in `~/.cerber/config.json`, or the checkbox in Settings.
182
+ When a PR is ready for you, you get a desktop notification naming it, folded
183
+ into one popup when several land at once. Ready means *there is something to
184
+ read*: cerber drafts the review itself, so the tap waits for the draft and says
185
+ what it found โ€” "widgets#7 draft ready ยท requests changes ยท 2 blockers" โ€” rather
186
+ than walking you back to a row that says "no run yet". A PR nobody is going to
187
+ draft (auto-review off, or a run that failed) is announced as it arrives, since
188
+ that is all the news there will be โ€” and if a retry drafts it after all, that is
189
+ new news and you are told. Nothing else is: a PR whose draft you have been told
190
+ about stays quiet however its next re-review ends. The tap comes from the poll, so it reaches
191
+ you with the cockpit closed, in another Space, or after a browser restart โ€”
192
+ `cerber serve` is the only thing that has to be running. It goes to whatever
193
+ this machine already has (Notification Centre on macOS, `notify-send` on Linux)
194
+ and stays quiet where there is neither. Off with `daemon.notify` in
195
+ `~/.cerber/config.json`, or the checkbox in Settings.
98
196
 
99
197
  **On macOS, clicking it opens that review** in the cockpit. It takes a small app
100
198
  cerber builds once under `~/.cerber` โ€” the first tap asks you to allow
@@ -122,56 +220,7 @@ anything, the favicon wears a red dot, and it goes away when the last review is
122
220
  dealt with. No permission, no switch โ€” just the paw with a mark on it, so a
123
221
  glance at the tab strip answers "is there anything for me?".
124
222
 
125
- ## What a review looks like
126
-
127
- Each review is a plain JSON artifact in `~/.cerber/reviews/` (override with
128
- `CERBER_HOME`) containing:
129
-
130
- - **Summary** โ€” what the PR actually does and why, written top-down
131
- - **Chapters** โ€” the changed files grouped into a logical walkthrough, each
132
- with a title, an explanation, and its slice of the diff
133
- - **Draft comments** โ€” inline, anchored to file/line, only things worth a
134
- human's time. Findings are graded **blocker / minor / nit** โ€” the words mean
135
- what they mean everywhere, and only a blocker stands between the PR and
136
- approval; a question or note carries no grade at all
137
- - **Verdict** โ€” approve / comment / request changes, with a 0โ€“100 confidence
138
- score and reasoning that names the worst finding still standing
139
- - **Run metadata** โ€” model, whether it read the source or only the diff, and
140
- the token spend as an API-rate equivalent
141
-
142
- ![A review opened in the cockpit: the verdict pill by the PR title, a walkthrough rail listing chapters and their comments on the left, and a summary written in sections โ€” problem first, then the fix, then details.](docs/walkthrough.png)
143
-
144
- The cockpit (`cerber serve`) renders the queue and the per-PR walkthrough with
145
- diffs. Draft comments sit inline in the diff, anchored to the line they're
146
- about, each waiting for you to keep, rewrite, or drop it:
147
-
148
- ![A chapter of the walkthrough: the diff with a draft comment card anchored under the line it discusses, explaining the finding in plain words.](docs/comment.png)
149
-
150
- Any line of the diff is one click away from being talked about: hover it and a
151
- `+` appears in the gutter, the way it does on GitHub. What opens is one box
152
- with two exits โ€” write the comment yourself, or ask the reviewer about that
153
- line and get the answer in the conversation below. A line the PR removes can be
154
- asked about too; a comment on one posts on the file, since GitHub only takes
155
- inline comments on the new side of a diff.
156
-
157
- A markdown file the PR **adds** opens as the document it is, not as a thousand
158
- rows of `+ ## Heading` โ€” because reviewing a new spec means reading it, and
159
- every line being an addition means the diff's markers say nothing. It is still
160
- the review: the draft comments sit in the document under the paragraph they
161
- point at, and any paragraph takes a comment or a question where it stands. A
162
- markdown file the PR only *edits* stays a diff โ€” there the change is the point
163
- โ€” with "read as a document" in its header when you want the prose instead.
164
-
165
- Artifacts are plain JSON you can `cat`, edit, or pipe into anything.
166
-
167
- The queue is meant to be walked, not clicked through: `j`/`k` move the cursor
168
- and the strip under the table explains whatever it lands on โ€” the verdict's own
169
- reasoning, what the run read, how it sits against the auto-send bar โ€” so most
170
- rows can be judged without opening them. `โ†ต` opens one, `r` drafts (or
171
- re-drafts) it. Inside a review, `[` and `]` walk to the previous/next PR still
172
- awaiting you, `n` steps through the chapters, and `s` sends.
173
-
174
- ### It reviews the code, not just the diff
223
+ ## It reviews the code, not just the diff
175
224
 
176
225
  Cerber checks the PR's head out locally and lets the review read it. That
177
226
  matters because a reviewer holding only a diff hedges over things it could have
@@ -228,7 +277,7 @@ Details worth knowing:
228
277
  review is currently reading. `cerber prune` reclaims the space now;
229
278
  `cerber prune --all` takes the ones for reviews still awaiting you too.
230
279
 
231
- ### Trusted PRs: reviews that can run things
280
+ ## Trusted PRs: reviews that can run things
232
281
 
233
282
  Reading beats guessing, but running beats reading. A review that ran the test
234
283
  covering the change reports what happened; one that only read it guesses. So
@@ -297,7 +346,7 @@ unattended by default: with trust rules set, it will run matching PRs' code
297
346
  with nobody watching. It warns at startup, the cockpit shows a โšก badge while
298
347
  it's the case, and `--no-trust` (or `--no-auto-review`) turns it off.
299
348
 
300
- ### When the PR moves under you
349
+ ## When the PR moves under you
301
350
 
302
351
  A review is written against one commit, but authors keep pushing. Opening a
303
352
  review checks the PR's head and pulls the review forward: comments follow their
@@ -308,10 +357,17 @@ commit's SHA, so inline comments land where they were written.
308
357
 
309
358
  The AI's summary and verdict still describe the commit that was reviewed. To
310
359
  get its opinion of the new code, hit **Re-review at the new head** in the
311
- cockpit (or `cerber review <pr> --force`) โ€” comments you wrote or rewrote are
312
- carried into the fresh review.
360
+ cockpit (or `cerber review <pr> --force`).
361
+
362
+ > โš ๏ธ **A re-review replaces the whole draft โ€” including comments you wrote or
363
+ > rewrote, and a send body you wrote yourself.** They are dropped when the run
364
+ > starts and they do not come back, on success or on failure. This is a
365
+ > decision, not a bug ([`docs/lifecycle.md`](docs/lifecycle.md)): the run owns
366
+ > the draft. If you have written comments you want to keep, send the review or
367
+ > copy them out first. Your *decisions* do survive โ€” a send, a `reviewed` or
368
+ > `skipped` you set, and the chat transcript are all kept.
313
369
 
314
- ### Arguing with the review before you send it
370
+ ## Arguing with the review before you send it
315
371
 
316
372
  A draft you disagree with used to leave two options: rewrite it by hand, or
317
373
  re-review and hope. Neither lets you say *what was wrong*. So every review has
@@ -369,7 +425,7 @@ the summary, comments and verdict alone, and is still one deliberate click. A
369
425
  review that has already been sent can't be argued with: that artifact is the
370
426
  record of what GitHub has.
371
427
 
372
- ### Every row remembers what happened to it
428
+ ## Every row remembers what happened to it
373
429
 
374
430
  A review keeps one "last updated" time, which means every write erases the
375
431
  answer to *when did I skip this, and did they ask again afterwards?* So each
@@ -448,6 +504,15 @@ pnpm build # dist/ (CLI+server) + web/dist (cockpit)
448
504
  Web cockpit dev with hot reload: `pnpm dev serve` in one terminal,
449
505
  `npx vite --config web/vite.config.ts` in another (proxies `/api`).
450
506
 
507
+ ## Contributing and security
508
+
509
+ Patches welcome โ€” [`CONTRIBUTING.md`](./CONTRIBUTING.md) has the gate
510
+ (`pnpm typecheck && pnpm test`) and the one rule that is not negotiable.
511
+
512
+ Cerber holds your GitHub credentials and, on a trusted PR, runs that PR's code.
513
+ [`SECURITY.md`](./SECURITY.md) says exactly what that means, what is stripped
514
+ from a trusted run, and where to report a vulnerability privately.
515
+
451
516
  ## License
452
517
 
453
518
  MIT ยฉ [Full Stack House](https://fullstack.house)
package/dist/cli/index.js CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
+ import { createRequire } from "node:module";
3
4
  import readline from "node:readline/promises";
4
5
  import { artifactId, artifactKey } from "../core/artifact.js";
5
6
  import { listCheckouts, removeCheckout } from "../core/checkout.js";
6
7
  import { toMarkdown } from "../core/export.js";
7
8
  import { configPath, loadConfig, saveConfig } from "../core/config.js";
9
+ import { formatBlockers, isBlocking, preflight } from "../core/preflight.js";
8
10
  import { withWriter } from "../core/history.js";
9
11
  import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
10
12
  import { parsePrRef, searchAwaitingMe, submitReview } from "../core/gh.js";
@@ -15,10 +17,34 @@ import { isReviewRunning } from "../runner/inflight.js";
15
17
  import { startDaemon } from "../server/daemon.js";
16
18
  import { cockpitUrl, startServer } from "../server/index.js";
17
19
  const program = new Command();
20
+ /**
21
+ * The published version, read from the manifest rather than typed here โ€” a
22
+ * hand-maintained copy drifted to 0.5.0 while 0.30.0 shipped, and `--version`
23
+ * is the first thing anyone runs to decide whether a project is alive.
24
+ */
25
+ const { version } = createRequire(import.meta.url)("../../package.json");
26
+ /**
27
+ * Stop before the work starts when a tool cerber shells out to is missing or
28
+ * logged out, rather than letting it surface as an empty queue or a retrying
29
+ * poll. `allowDegraded` is for `serve --no-poll`, which is still useful for
30
+ * reading artifacts already on disk.
31
+ */
32
+ async function requireTools(allowDegraded = false) {
33
+ const checks = await preflight();
34
+ if (!checks.some(isBlocking))
35
+ return;
36
+ const message = formatBlockers(checks);
37
+ if (allowDegraded) {
38
+ console.warn(`\n${message}\n`);
39
+ return;
40
+ }
41
+ console.error(`\n${message}\n`);
42
+ process.exit(1);
43
+ }
18
44
  program
19
45
  .name("cerber")
20
46
  .description("AI code-review cockpit. Claude reviews PRs into local artifacts; nothing reaches GitHub until you explicitly send it.")
21
- .version("0.5.0");
47
+ .version(version);
22
48
  program
23
49
  .command("review")
24
50
  .description("Run an AI review of one or more PRs (local artifact only โ€” no GitHub writes)")
@@ -32,6 +58,7 @@ program
32
58
  .option("-t, --trust", "let the review run commands in the checkout (tests, typecheck, git log) whatever the trust config says")
33
59
  .option("--no-trust", "keep the review read-only even if a trust rule matches")
34
60
  .action(async (prs, opts) => {
61
+ await requireTools();
35
62
  let refs;
36
63
  if (opts.awaitingMe) {
37
64
  const found = await searchAwaitingMe(opts.repo);
@@ -349,6 +376,31 @@ program
349
376
  `${wouldSend} shadow candidate(s), ${autoSent} auto-sent.`);
350
377
  }
351
378
  });
379
+ program
380
+ .command("doctor")
381
+ .description("Check that everything cerber needs is installed and logged in")
382
+ .action(async () => {
383
+ const checks = await preflight();
384
+ for (const c of checks) {
385
+ if (c.status === "ok") {
386
+ console.log(` โœ” ${c.name.padEnd(22)} ${c.detail ?? ""}`.trimEnd());
387
+ }
388
+ else {
389
+ console.log(` โœ— ${c.name.padEnd(22)} ${c.status === "missing" ? "not installed" : "not logged in"}`);
390
+ if (c.fix)
391
+ console.log(` ${c.fix}`);
392
+ }
393
+ }
394
+ const bad = checks.filter(isBlocking);
395
+ console.log("");
396
+ if (bad.length === 0) {
397
+ console.log("Ready. `cerber serve` opens the cockpit.");
398
+ }
399
+ else {
400
+ console.log(`${bad.length} thing(s) to fix before cerber can review anything.`);
401
+ process.exitCode = 1;
402
+ }
403
+ });
352
404
  program
353
405
  .command("serve", { isDefault: true })
354
406
  .description("Start the review cockpit โ€” an inbox: it polls GitHub for PRs awaiting your review and drafts " +
@@ -358,7 +410,7 @@ program
358
410
  .option("-t, --token <token>", "require this token on every request (env: CERBER_TOKEN)")
359
411
  .option("--no-poll", "don't poll GitHub for PRs awaiting your review (also disables auto-review)")
360
412
  .option("--no-auto-review", "list awaiting PRs but don't review them until you click review")
361
- .option("--no-notify", "don't notify this machine when a PR lands in the queue")
413
+ .option("--no-notify", "don't notify this machine when a review is ready for you")
362
414
  .option("-d, --daemon", "(deprecated no-op โ€” polling and auto-review are now the default)")
363
415
  .option("-i, --interval <minutes>", "poll interval (default: config daemon.intervalMinutes, 5)")
364
416
  .option("-R, --repo <owner/repo>", "repo(s) to watch (repeatable; default: config daemon.repos, all)", collect, [])
@@ -377,6 +429,9 @@ program
377
429
  `Pass --token <secret> (or CERBER_TOKEN), or --insecure if the network is trusted.`);
378
430
  process.exit(1);
379
431
  }
432
+ // Without `gh` the cockpit binds, polls into an error loop and shows an
433
+ // empty queue; `--no-poll` is the one shape that still works offline.
434
+ await requireTools(!opts.poll);
380
435
  const threshold = Math.min(100, Math.max(50, Number(opts.autoSendThreshold) || 90));
381
436
  if (opts.autoSend) {
382
437
  console.log(`โš  auto-send is ON: APPROVE verdicts with confidence โ‰ฅ ${threshold}% will be sent to GitHub as you, without a per-review confirmation.\n` +
@@ -304,6 +304,32 @@ export const ArtifactSchema = z.object({
304
304
  * field forward long after the decision it is meant to date.
305
305
  */
306
306
  settledAt: z.string().nullable().default(null),
307
+ /**
308
+ * The announcement ledger: what this machine has already told you about this
309
+ * row โ€” see `pendingNews` in `src/core/notify.ts` for what is worth telling.
310
+ *
311
+ * Three states, not two. `null` is "the poll found this and owes you a tap";
312
+ * a record is "already told". **Absent** is neither: nobody ever meant to
313
+ * announce this row โ€” a review you pulled in by hand, or one written before
314
+ * the ledger existed โ€” so it is never news, and an upgrade doesn't announce
315
+ * a queue the user has been looking at for weeks. Only `stubArtifact` writes
316
+ * the null.
317
+ *
318
+ * It records *what* was said, not just that something was, because the two
319
+ * things cerber can say about a row are different news: "nobody is drafting
320
+ * this" and "here is the draft". A run that fails is announced as the first,
321
+ * is retried on the next poll, and may then succeed โ€” and a ledger that only
322
+ * remembered "told" would swallow the draft-ready tap, leaving the user with
323
+ * the one notification that had nothing to read behind it.
324
+ */
325
+ notified: z
326
+ .object({
327
+ at: z.string(),
328
+ /** True when what was announced was a finished draft. */
329
+ drafted: z.boolean(),
330
+ })
331
+ .nullable()
332
+ .optional(),
307
333
  /** Last time this review was pulled forward onto a newer head commit. */
308
334
  refresh: RefreshInfoSchema.nullable().default(null),
309
335
  calibration: CalibrationSchema.nullable().default(null),
@@ -23,9 +23,10 @@ export const DaemonConfigSchema = z.object({
23
23
  /** Draft an AI review for whatever lands in the queue. Never sends. */
24
24
  autoReview: z.boolean().default(true),
25
25
  /**
26
- * Tap this machine's notification centre when a PR lands in the queue. The
27
- * cockpit's bell needs a tab open and permitted; this one only needs `serve`
28
- * running, which is the point of it.
26
+ * Tap this machine's notification centre when a PR is worth coming back for
27
+ * โ€” the draft landing, or the arrival itself when nothing is going to be
28
+ * drafted (`isNews` in `core/notify.ts`). The cockpit's bell needs a tab open
29
+ * and permitted; this one only needs `serve` running, which is the point.
29
30
  */
30
31
  notify: z.boolean().default(true),
31
32
  intervalMinutes: z.number().int().positive().default(5),
package/dist/core/gh.js CHANGED
@@ -68,8 +68,13 @@ export async function fetchPrInfo(ref) {
68
68
  }
69
69
  /**
70
70
  * Org and team membership, for `@org/*` and `@org/team` trust rules. A 404 is
71
- * GitHub's "no" and the answer we want; anything else (no `read:org` scope, an
72
- * outage) throws, so a broken check can never read as "trusted".
71
+ * GitHub's "no"; anything else (an outage, a rate limit) throws. Either way the
72
+ * answer is never "trusted", which is the property that matters.
73
+ *
74
+ * Note that a token without `read:org` also gets a 404 rather than a 403 โ€” so a
75
+ * missing scope reads as "not a member" and silently narrows trust instead of
76
+ * announcing itself. Fail-closed, but quiet: if a rule you expect to match
77
+ * doesn't, check the scope before the rule.
73
78
  */
74
79
  export async function isOrgMember(org, login) {
75
80
  return membershipCheck(["api", `orgs/${org}/members/${login}`, "--silent"]);
@@ -4,8 +4,16 @@
4
4
  // one when it can ring: it names the PR and opens that review when you click
5
5
  // it. But it is a page notification, so it needs a tab that is open, alive and
6
6
  // permitted โ€” which is exactly what you don't have while you're in an editor
7
- // all afternoon. This half rides the poll that discovers the PR instead, so the
8
- // tap survives a closed cockpit, a denied permission and a browser restart.
7
+ // all afternoon. This half rides the poll instead, so the tap survives a closed
8
+ // cockpit, a denied permission and a browser restart.
9
+ //
10
+ // It taps you once per piece of news, at the moment that news is worth walking
11
+ // back for. With auto-review on โ€” the default โ€” that is when the draft is
12
+ // written, not when the PR lands: a tap that leads to "no run yet, press r" is
13
+ // a tap that costs you the walk back and gives you nothing. `isNews` decides
14
+ // whether there is news at all, `pendingNews` whether it is news you have
15
+ // already been told. A PR has at most two: "nobody is drafting this", and
16
+ // "here is the draft" โ€” and it only gets the second if it got the first.
9
17
  //
10
18
  // A click has to land in the same place either way, which on macOS is the whole
11
19
  // difference between the two paths below. `osascript -e 'display notification'`
@@ -40,6 +48,22 @@ const execFileAsync = promisify(execFile);
40
48
  */
41
49
  export const NOTIFY_TIMEOUT_MS = 5_000;
42
50
  const slug = (a) => `${a.repo}#${a.number}`;
51
+ const VERDICT_WORDS = {
52
+ approve: "approves",
53
+ comment: "comments",
54
+ request_changes: "requests changes",
55
+ };
56
+ /**
57
+ * What a finished draft says, in the few words a popup gets: the verdict, and
58
+ * the count the verdict rests on. A draft that graded nothing and left no
59
+ * verdict still gets a line โ€” "drafted" is the news there.
60
+ */
61
+ function draftLine(d) {
62
+ const verdict = d.recommendation ? VERDICT_WORDS[d.recommendation] : "drafted";
63
+ if (d.blockers === 0)
64
+ return verdict;
65
+ return `${verdict} ยท ${d.blockers} blocker${d.blockers === 1 ? "" : "s"}`;
66
+ }
43
67
  /**
44
68
  * Text a notification body or an AppleScript literal can actually hold. A PR
45
69
  * title is somebody else's text and can carry anything; control characters
@@ -48,7 +72,7 @@ const slug = (a) => `${a.repo}#${a.number}`;
48
72
  */
49
73
  const flatten = (value) => value.replace(/[\u0000-\u001f\u007f]+/g, " ");
50
74
  /**
51
- * One popup for one poll's arrivals โ€” a batch is one interruption, not five.
75
+ * One popup for one poll's news โ€” a batch is one interruption, not five.
52
76
  * Deliberately the same shape and wording the cockpit's bell uses, so the two
53
77
  * channels never read as two different pieces of news about one PR.
54
78
  *
@@ -57,27 +81,92 @@ const flatten = (value) => value.replace(/[\u0000-\u001f\u007f]+/g, " ");
57
81
  * origin with no fragment of its own โ€” the deep link is a fragment, and the
58
82
  * cockpit is a hash-routed page.
59
83
  */
60
- export function notice(arrived, cockpit) {
61
- if (arrived.length === 0)
84
+ export function notice(news, cockpit) {
85
+ if (news.length === 0)
62
86
  return null;
63
87
  const base = cockpit || null;
64
- if (arrived.length === 1) {
65
- const a = arrived[0];
88
+ if (news.length === 1) {
89
+ const a = news[0];
66
90
  const key = artifactKey(artifactId(a));
67
- return {
68
- title: `${slug(a)} awaits your review`,
69
- body: `${a.title} โ€” ${a.author}`,
70
- url: base && `${base}#/r/${encodeURIComponent(key)}`,
71
- };
91
+ const url = base && `${base}#/r/${encodeURIComponent(key)}`;
92
+ // The draft is the news when there is one, so the popup leads with what it
93
+ // found rather than with the PR having arrived โ€” which, by then, it did
94
+ // minutes ago.
95
+ if (a.draft)
96
+ return { title: `${slug(a)} draft ready`, body: `${draftLine(a.draft)} โ€” ${a.title}`, url };
97
+ return { title: `${slug(a)} awaits your review`, body: `${a.title} โ€” ${a.author}`, url };
72
98
  }
73
- const named = arrived.slice(0, 3).map(slug);
74
- const rest = arrived.length - named.length;
99
+ const named = news.slice(0, 3).map(slug);
100
+ const rest = news.length - named.length;
75
101
  return {
76
- title: `${arrived.length} PRs await your review`,
102
+ // A drafted PR awaits you too, so the general wording is true of any batch;
103
+ // the better headline is only claimed when every one of them is drafted.
104
+ title: news.every((n) => n.draft) ? `${news.length} drafts ready` : `${news.length} PRs await your review`,
77
105
  body: rest > 0 ? `${named.join(", ")} and ${rest} more` : named.join(", "),
78
106
  url: base,
79
107
  };
80
108
  }
109
+ /**
110
+ * Whether this row is news *yet*.
111
+ *
112
+ * The one rule behind the tap's timing, and the cockpit's bell mirrors it
113
+ * (`web/src/notify.ts`). A row cerber is about to draft, or is drafting, is not
114
+ * news: the tap would arrive minutes before there is anything to read, and the
115
+ * one after it would never come. So it waits โ€” unless nothing is coming, which
116
+ * is the case when auto-review is off (nobody will draft this until you say so)
117
+ * or the run failed (nobody will draft it at all).
118
+ *
119
+ * A row you have settled is not news either, whatever else happened to it: you
120
+ * already answered it.
121
+ */
122
+ export function isNews(a, autoReview) {
123
+ if (a.pr.state !== "OPEN")
124
+ return false;
125
+ if (a.status === "running")
126
+ return false;
127
+ if (a.status === "awaiting")
128
+ return !autoReview;
129
+ return a.status === "ready" || a.status === "failed";
130
+ }
131
+ /**
132
+ * What this row has to say that it has not said already, or null for silence.
133
+ *
134
+ * `isNews` answers "is there news at all"; this one also weighs it against the
135
+ * ledger, which is where the two things cerber can say about a row stop being
136
+ * interchangeable. A run that failed is announced as "nobody is drafting this",
137
+ * is retried on the next poll, and may then succeed โ€” so the draft is news
138
+ * *again*, to someone who was told the opposite five minutes ago. The reverse
139
+ * is not: a row that had a draft and now has a broken re-review is a PR the
140
+ * user already knows about, and a second tap there is noise.
141
+ */
142
+ export function pendingNews(a, autoReview) {
143
+ // Absent, not null: nobody ever meant to announce this row.
144
+ if (a.notified === undefined)
145
+ return null;
146
+ if (!isNews(a, autoReview))
147
+ return null;
148
+ const news = newsOf(a);
149
+ if (a.notified === null)
150
+ return news;
151
+ return news.draft && !a.notified.drafted ? news : null;
152
+ }
153
+ /** What that row would say, for the one notice a poll sends. */
154
+ export function newsOf(a) {
155
+ return {
156
+ // The click target is built from the ref, so the owner travels with it.
157
+ owner: a.pr.owner,
158
+ repo: a.pr.repo,
159
+ number: a.pr.number,
160
+ title: a.pr.title,
161
+ author: a.pr.author,
162
+ draft: a.status === "ready"
163
+ ? {
164
+ recommendation: a.verdict?.recommendation ?? null,
165
+ blockers: a.comments.filter((c) => c.status !== "dropped" && c.severity === "blocker").length,
166
+ }
167
+ : null,
168
+ };
169
+ }
81
170
  /**
82
171
  * A PR title, as an AppleScript string literal.
83
172
  *