@fullstackhouse/cerber 0.8.0 → 0.9.1

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
@@ -28,10 +28,19 @@ npx @fullstackhouse/cerber review 123 124 --repo owner/repo
28
28
  # See what's in the queue
29
29
  npx @fullstackhouse/cerber list
30
30
 
31
- # Open the cockpit
31
+ # Open the cockpit — it's an inbox: it polls GitHub for PRs awaiting your
32
+ # review and drafts a review for each, so they're ready when you arrive
32
33
  npx @fullstackhouse/cerber serve # → http://127.0.0.1:4820
33
34
  ```
34
35
 
36
+ `serve` does the whole loop by default: discover → draft → wait for you.
37
+ Sending stays a human click. Tame it with `--no-auto-review` (list awaiting
38
+ PRs, review on click) or `--no-poll` (no GitHub polling at all) — or flip the
39
+ same two switches in the cockpit's Settings, which persist in
40
+ `~/.cerber/config.json` and apply on the next poll. Merged and closed PRs are
41
+ archived out of the queue automatically, and PRs in archived repos are never
42
+ picked up at all — the repo is read-only, so a review could never be sent.
43
+
35
44
  ## What a review looks like
36
45
 
37
46
  Each review is a plain JSON artifact in `~/.cerber/reviews/` (override with
@@ -75,7 +84,7 @@ If you want the old behaviour — faster, lighter, blind past the changed lines:
75
84
 
76
85
  ```bash
77
86
  cerber review owner/repo#123 --no-source
78
- cerber serve --daemon --no-source
87
+ cerber serve --no-source
79
88
  ```
80
89
 
81
90
  The cockpit labels every review "read the full source" or "read the diff only",
@@ -171,9 +180,10 @@ the default is a review that cannot run anything at all.
171
180
  machine — the branch's scripts, its dependencies, its test suite. That is the
172
181
  same exposure as checking the branch out and running the tests yourself, which
173
182
  is what you would otherwise do; it is not the same as reading a diff. Trust
174
- orgs and people, not the whole of GitHub. And note the daemon reviews
175
- unattended: with trust rules set, `cerber serve --daemon` will run matching PRs'
176
- code with nobody watching. It warns at startup, and `--no-trust` turns it off.
183
+ orgs and people, not the whole of GitHub. And note that `cerber serve` reviews
184
+ unattended by default: with trust rules set, it will run matching PRs' code
185
+ with nobody watching. It warns at startup, the cockpit shows a ⚡ badge while
186
+ it's the case, and `--no-trust` (or `--no-auto-review`) turns it off.
177
187
 
178
188
  ### When the PR moves under you
179
189
 
@@ -199,9 +209,8 @@ Early, but all six planned phases have shipped.
199
209
  only GitHub write in the codebase) + `cerber export` to markdown
200
210
  3. ✅ `--awaiting-me` discovery, parallel runs (`--parallel`, default 3),
201
211
  freshness skip (same head SHA → no re-run; `--force` overrides), queue stats
202
- 4. ✅ Daemon/VPS mode: `cerber serve --daemon` polls for PRs awaiting your
203
- review and reviews them automatically (read-only); token auth for
204
- non-localhost binds
212
+ 4. ✅ Inbox mode (now the default): `cerber serve` polls for PRs awaiting your
213
+ review and reviews them automatically; token auth for non-localhost binds
205
214
  5. ✅ Confidence calibration (`cerber stats`), shadow-mode auto-send (default
206
215
  in daemon: logs what would be sent), opt-in `--auto-send` (approve-only,
207
216
  `--auto-send-threshold`, default 90%)
@@ -212,11 +221,11 @@ Early, but all six planned phases have shipped.
212
221
  ## Running on a VPS
213
222
 
214
223
  ```bash
215
- cerber serve --daemon --host 0.0.0.0 --token "$(openssl rand -hex 16)" \
224
+ cerber serve --host 0.0.0.0 --token "$(openssl rand -hex 16)" \
216
225
  --repo you/repo-a --repo you/repo-b --interval 10
217
226
  ```
218
227
 
219
- The daemon polls GitHub, reviews anything new (skipping PRs whose artifact
228
+ The server polls GitHub, reviews anything new (skipping PRs whose artifact
220
229
  already matches the head SHA), and the cockpit is always warm — open it any
221
230
  minute and see pending/ready/sent reviews. Auth: `?token=…` once in the
222
231
  browser (sets a cookie) or `Authorization: Bearer …`. Binding a non-localhost
package/dist/cli/index.js CHANGED
@@ -305,16 +305,19 @@ program
305
305
  }
306
306
  });
307
307
  program
308
- .command("serve")
309
- .description("Start the review cockpit; --daemon keeps the queue warm by polling GitHub")
308
+ .command("serve", { isDefault: true })
309
+ .description("Start the review cockpit — an inbox: it polls GitHub for PRs awaiting your review and drafts " +
310
+ "a review for each, so they're ready when you open it. --no-poll / --no-auto-review to tame it.")
310
311
  .option("-p, --port <port>", "port", "4820")
311
312
  .option("-H, --host <host>", "host to bind (0.0.0.0 for VPS — requires --token)", "127.0.0.1")
312
313
  .option("-t, --token <token>", "require this token on every request (env: CERBER_TOKEN)")
313
- .option("-d, --daemon", "poll for PRs awaiting your review and auto-review them (read-only)")
314
- .option("-i, --interval <minutes>", "daemon poll interval", "5")
315
- .option("-R, --repo <owner/repo>", "repo(s) the daemon watches (repeatable; default: all)", collect, [])
316
- .option("-P, --parallel <n>", "daemon review concurrency", "3")
317
- .option("-m, --model <model>", "Claude model override for daemon reviews")
314
+ .option("--no-poll", "don't poll GitHub for PRs awaiting your review (also disables auto-review)")
315
+ .option("--no-auto-review", "list awaiting PRs but don't review them until you click review")
316
+ .option("-d, --daemon", "(deprecated no-op — polling and auto-review are now the default)")
317
+ .option("-i, --interval <minutes>", "poll interval (default: config daemon.intervalMinutes, 5)")
318
+ .option("-R, --repo <owner/repo>", "repo(s) to watch (repeatable; default: config daemon.repos, all)", collect, [])
319
+ .option("-P, --parallel <n>", "review concurrency (default: config daemon.parallel, 3)")
320
+ .option("-m, --model <model>", "Claude model override for unattended reviews")
318
321
  .option("--no-source", "review the diff alone, with no local checkout of the PR head (applies to daemon runs and cockpit re-reviews)")
319
322
  .option("--no-trust", "ignore the configured trust rules: every review here stays read-only, even for repos you trust")
320
323
  .option("--auto-send", "ACTUALLY auto-send APPROVE verdicts at/above --auto-send-threshold (default: shadow mode, which only logs what would be sent)")
@@ -333,27 +336,38 @@ program
333
336
  console.log(`⚠ auto-send is ON: APPROVE verdicts with confidence ≥ ${threshold}% will be sent to GitHub as you, without a per-review confirmation.\n` +
334
337
  ` Every decision is logged to ${cerberHome()}/autosend.ndjson. COMMENT/REQUEST_CHANGES always wait for a human.`);
335
338
  }
339
+ if (opts.daemon) {
340
+ console.log("Note: --daemon is deprecated — polling and auto-review are on by default now.");
341
+ }
342
+ // Config holds the defaults and is re-read by the daemon on every poll
343
+ // (so the cockpit's Settings toggles apply live); a --no-* flag caps
344
+ // this run only, and --no-poll skips the loop entirely.
345
+ const config = await loadConfig();
346
+ const d = config.daemon;
336
347
  // Trust means "run this PR's code". A human clicking review accepted that
337
- // per PR; a daemon does it unattended, on whatever lands in the queue.
338
- if (opts.daemon && opts.trust) {
339
- const rules = (await loadConfig()).trust;
340
- if (rules.length > 0) {
341
- console.log(`⚠ ${rules.length} trust rule(s) in ${configPath()}: this daemon will run commands from matching PRs ` +
342
- `automatically, with nobody watching. Pass --no-trust to keep every review read-only.`);
343
- }
348
+ // per PR; auto-review does it unattended, on whatever lands in the queue.
349
+ if (opts.poll && d.poll && opts.autoReview && d.autoReview && opts.trust && config.trust.length > 0) {
350
+ console.log(`⚠ ${config.trust.length} trust rule(s) in ${configPath()}: unattended reviews will run commands from ` +
351
+ `matching PRs, with nobody watching. Pass --no-trust to keep every review read-only, ` +
352
+ `or --no-auto-review to only review on click.`);
344
353
  }
345
- const daemon = opts.daemon
354
+ const daemon = opts.poll
346
355
  ? startDaemon({
347
- repos: opts.repo,
348
- intervalMs: Math.max(1, Number(opts.interval) || 5) * 60_000,
349
- parallel: Math.max(1, Number(opts.parallel) || 1),
356
+ repos: opts.repo.length > 0 ? opts.repo : d.repos,
357
+ intervalMs: Math.max(1, opts.interval !== undefined ? Number(opts.interval) || d.intervalMinutes : d.intervalMinutes) *
358
+ 60_000,
359
+ parallel: Math.max(1, opts.parallel !== undefined ? Number(opts.parallel) || d.parallel : d.parallel),
350
360
  model: opts.model,
351
361
  withSource: opts.source,
352
362
  trust: opts.trust ? undefined : false,
363
+ autoReview: opts.autoReview,
353
364
  autoSend: opts.autoSend ? "on" : "shadow",
354
365
  autoSendThreshold: threshold,
355
366
  })
356
367
  : undefined;
368
+ if (!opts.poll) {
369
+ console.log("Polling is off (--no-poll) — the queue only shows what you review by hand.");
370
+ }
357
371
  await startServer({
358
372
  port: Number(opts.port),
359
373
  host: opts.host,
@@ -11,6 +11,22 @@ import { TrustRuleError, parseTrustRule } from "./trust.js";
11
11
  * Rules stay one-line strings rather than objects: the same syntax you type at
12
12
  * `cerber trust`, readable in a diff, and short enough to explain in a tooltip.
13
13
  */
14
+ /**
15
+ * The background loop `cerber serve` runs. Both knobs default ON — the cockpit
16
+ * is an inbox: open it and the PRs awaiting your review are already there,
17
+ * drafts and all. Flip them here (or in the cockpit's Settings), or per-run
18
+ * with `--no-poll` / `--no-auto-review`.
19
+ */
20
+ export const DaemonConfigSchema = z.object({
21
+ /** Discover PRs awaiting your review and list them as `awaiting`. */
22
+ poll: z.boolean().default(true),
23
+ /** Draft an AI review for whatever lands in the queue. Never sends. */
24
+ autoReview: z.boolean().default(true),
25
+ intervalMinutes: z.number().int().positive().default(5),
26
+ parallel: z.number().int().positive().default(3),
27
+ /** Repos to watch (owner/repo). Empty = everything your gh account can see. */
28
+ repos: z.array(z.string()).default([]),
29
+ });
14
30
  export const ConfigSchema = z.object({
15
31
  /** Whose PRs may be reviewed by running code. See core/trust.ts for syntax. */
16
32
  trust: z
@@ -30,8 +46,9 @@ export const ConfigSchema = z.object({
30
46
  }
31
47
  });
32
48
  }),
49
+ daemon: DaemonConfigSchema.default({}),
33
50
  });
34
- export const DEFAULT_CONFIG = { trust: [] };
51
+ export const DEFAULT_CONFIG = ConfigSchema.parse({});
35
52
  export function configPath() {
36
53
  return path.join(cerberHome(), "config.json");
37
54
  }
@@ -42,7 +59,7 @@ export async function loadConfig() {
42
59
  throw err;
43
60
  });
44
61
  if (raw === null)
45
- return { ...DEFAULT_CONFIG };
62
+ return ConfigSchema.parse({});
46
63
  try {
47
64
  return ConfigSchema.parse(JSON.parse(raw));
48
65
  }
@@ -64,7 +81,8 @@ function describeConfigError(err) {
64
81
  return ` not JSON — ${err.message}`;
65
82
  return ` ${err instanceof Error ? err.message : String(err)}`;
66
83
  }
67
- /** Write atomically — the cockpit and a `cerber trust` can both be writing. */
84
+ /** Write atomically — the cockpit and a `cerber trust` can both be writing.
85
+ * Accepts schema input (defaults fill the gaps), so callers may omit blocks. */
68
86
  export async function saveConfig(config) {
69
87
  const file = configPath();
70
88
  await fs.mkdir(cerberHome(), { recursive: true });
package/dist/core/gh.js CHANGED
@@ -109,25 +109,32 @@ export function mapSearchResults(raw) {
109
109
  url: r.url,
110
110
  updatedAt: r.updatedAt,
111
111
  isDraft: r.isDraft,
112
+ author: r.author?.login ?? "",
112
113
  },
113
114
  ];
114
115
  });
115
116
  }
116
- /** Open, non-draft PRs where the current gh user's review is requested. */
117
- export async function searchAwaitingMe(repoFilter, limit = 50) {
117
+ export function searchAwaitingArgs(repoFilter, limit = 50) {
118
118
  const args = [
119
119
  "search",
120
120
  "prs",
121
+ // An archived repo is read-only: its PRs can never merge, close, or take a
122
+ // review. Without this they'd sit in the inbox forever — nothing retires them.
123
+ "archived:false",
121
124
  "--review-requested=@me",
122
125
  "--state=open",
123
126
  "--limit",
124
127
  String(limit),
125
128
  "--json",
126
- "number,title,repository,isDraft,url,updatedAt",
129
+ "number,title,repository,isDraft,url,updatedAt,author",
127
130
  ];
128
131
  if (repoFilter)
129
132
  args.push("--repo", repoFilter);
130
- const out = await gh(args);
133
+ return args;
134
+ }
135
+ /** Open, non-draft PRs where the current gh user's review is requested. */
136
+ export async function searchAwaitingMe(repoFilter, limit = 50) {
137
+ const out = await gh(searchAwaitingArgs(repoFilter, limit));
131
138
  return mapSearchResults(JSON.parse(out));
132
139
  }
133
140
  /**
@@ -67,6 +67,21 @@ export async function readAutoSendLog() {
67
67
  throw err;
68
68
  }
69
69
  }
70
+ /**
71
+ * Remove an artifact file. Only ever called on pure discovery stubs (status
72
+ * "awaiting", no comments) — anything a human or an AI run touched stays.
73
+ */
74
+ export async function deleteArtifact(id) {
75
+ try {
76
+ await fs.unlink(artifactPath(id));
77
+ return true;
78
+ }
79
+ catch (err) {
80
+ if (err.code === "ENOENT")
81
+ return false;
82
+ throw err;
83
+ }
84
+ }
70
85
  /** Load by key, apply a mutation, bump updatedAt, save. Returns the new artifact. */
71
86
  export async function updateArtifactByKey(key, mutate) {
72
87
  const artifact = await loadArtifactByKey(key);
@@ -1,13 +1,63 @@
1
- import { artifactKey } from "../core/artifact.js";
1
+ import { SCHEMA_VERSION, artifactId, artifactKey } from "../core/artifact.js";
2
2
  import { evaluateAutoSend } from "../core/autosend.js";
3
- import { searchAwaitingMe, submitReview } from "../core/gh.js";
3
+ import { loadConfig } from "../core/config.js";
4
+ import { fetchPrInfo, searchAwaitingMe, submitReview } from "../core/gh.js";
4
5
  import { buildReviewPayload, computeCalibration } from "../core/send.js";
5
- import { appendAutoSendLog, updateArtifactByKey } from "../core/state.js";
6
+ import { appendAutoSendLog, deleteArtifact, listArtifacts, loadArtifact, saveArtifact, updateArtifactByKey, } from "../core/state.js";
6
7
  import { ReviewInProgressError } from "../runner/inflight.js";
7
8
  import { pool, reviewPr } from "../runner/review.js";
8
9
  /**
9
- * Poll GitHub for PRs awaiting the user's review and keep their artifacts
10
- * warm. Reviews are read-only; the daemon NEVER sends anything.
10
+ * A pure discovery stub: enough artifact for the queue to show an awaiting row.
11
+ * The AI run that follows (automatic or clicked) replaces it wholesale.
12
+ */
13
+ export function stubArtifact(ref) {
14
+ const now = new Date().toISOString();
15
+ return {
16
+ schemaVersion: SCHEMA_VERSION,
17
+ id: artifactId(ref),
18
+ status: "awaiting",
19
+ createdAt: now,
20
+ updatedAt: now,
21
+ pr: {
22
+ owner: ref.owner,
23
+ repo: ref.repo,
24
+ number: ref.number,
25
+ title: ref.title,
26
+ url: ref.url,
27
+ author: ref.author,
28
+ body: "",
29
+ baseRefName: "",
30
+ headRefName: "",
31
+ headSha: "",
32
+ state: "OPEN",
33
+ additions: 0,
34
+ deletions: 0,
35
+ changedFiles: 0,
36
+ },
37
+ diff: "",
38
+ summary: "",
39
+ chapters: [],
40
+ comments: [],
41
+ verdict: null,
42
+ run: null,
43
+ sent: null,
44
+ refresh: null,
45
+ calibration: null,
46
+ };
47
+ }
48
+ /** An artifact nothing has touched yet — safe to delete when its PR leaves the queue. */
49
+ export function isPureStub(artifact) {
50
+ return artifact.status === "awaiting" && artifact.comments.length === 0 && artifact.run === null;
51
+ }
52
+ /** How many open-PR artifacts to re-check against GitHub per poll, and how often each. */
53
+ const STATE_REFRESH_CAP = 10;
54
+ const STATE_REFRESH_MIN_MS = 30 * 60_000;
55
+ /**
56
+ * The inbox loop behind `cerber serve`: poll GitHub for PRs awaiting the
57
+ * user's review, list them as `awaiting`, and (unless auto-review is off)
58
+ * draft a review for each. Merged/closed PRs are archived out of the queue.
59
+ * Reviews stay local; the daemon NEVER sends anything except via the
60
+ * explicitly opted-in auto-send path.
11
61
  */
12
62
  export function startDaemon(opts) {
13
63
  let timer = null;
@@ -17,70 +67,181 @@ export function startDaemon(opts) {
17
67
  polling: false,
18
68
  repos: opts.repos,
19
69
  intervalMs: opts.intervalMs,
70
+ pollEnabled: true,
71
+ autoReview: opts.autoReview,
72
+ trustedRuns: false,
20
73
  polls: 0,
21
74
  errors: 0,
22
75
  lastPollAt: null,
23
76
  nextPollAt: new Date(Date.now()).toISOString(),
24
77
  lastSummary: null,
78
+ lastPollError: null,
25
79
  autoSend: opts.autoSend,
26
80
  autoSendThreshold: opts.autoSendThreshold,
27
81
  autoSent: 0,
28
82
  autoSendCandidates: 0,
29
83
  };
30
84
  const log = (m) => console.log(`[daemon] ${m}`);
85
+ /** When GitHub last confirmed an artifact's PR state, by artifact id. In-memory:
86
+ * a restart just re-checks sooner, which is harmless. */
87
+ const stateCheckedAt = new Map();
88
+ async function discover() {
89
+ const filters = opts.repos.length > 0 ? opts.repos : [undefined];
90
+ const seen = new Set();
91
+ return (await Promise.all(filters.map((repo) => searchAwaitingMe(repo))))
92
+ .flat()
93
+ .filter((r) => {
94
+ const id = artifactId(r);
95
+ if (seen.has(id))
96
+ return false;
97
+ seen.add(id);
98
+ return true;
99
+ });
100
+ }
101
+ /**
102
+ * Make the local queue mirror what's actually awaiting: stub artifacts for
103
+ * newly discovered PRs, archive merged/closed ones, drop stubs whose review
104
+ * request went away. Freshness decisions always come from local artifacts —
105
+ * search results lag GitHub by minutes and never override them.
106
+ */
107
+ async function syncQueue(refs) {
108
+ let discovered = 0;
109
+ for (const ref of refs) {
110
+ if (await loadArtifact(artifactId(ref)))
111
+ continue;
112
+ await saveArtifact(stubArtifact(ref));
113
+ discovered++;
114
+ }
115
+ const awaitingIds = new Set(refs.map((r) => artifactId(r)));
116
+ let refreshed = 0;
117
+ for (const artifact of await listArtifacts()) {
118
+ if (artifact.status === "running")
119
+ continue;
120
+ if (awaitingIds.has(artifact.id))
121
+ continue;
122
+ if (artifact.pr.state !== "OPEN")
123
+ continue;
124
+ if (isPureStub(artifact)) {
125
+ // Discovered earlier, gone from the search now: merged/closed (archive
126
+ // it), or the request was withdrawn / handled elsewhere (drop it — it
127
+ // holds no work, and search lag at worst re-creates it next poll).
128
+ try {
129
+ const pr = await fetchPrInfo(artifact.pr);
130
+ if (pr.state !== "OPEN") {
131
+ await updateArtifactByKey(artifactKey(artifact.id), (a) => ({ ...a, pr }));
132
+ log(`[${artifact.id}] ${pr.state.toLowerCase()} — archived`);
133
+ }
134
+ else {
135
+ await deleteArtifact(artifact.id);
136
+ log(`[${artifact.id}] no longer awaiting your review — removed from the queue`);
137
+ }
138
+ }
139
+ catch {
140
+ // GitHub unreachable — leave the stub; next poll retries.
141
+ }
142
+ continue;
143
+ }
144
+ // Anything with real work in it: periodically confirm the PR is still
145
+ // open, so merged/closed ones stop cluttering the queue. Capped and
146
+ // spaced out — this is book-keeping, not a data feed.
147
+ if (refreshed >= STATE_REFRESH_CAP)
148
+ continue;
149
+ const last = stateCheckedAt.get(artifact.id) ?? 0;
150
+ if (Date.now() - last < STATE_REFRESH_MIN_MS)
151
+ continue;
152
+ refreshed++;
153
+ stateCheckedAt.set(artifact.id, Date.now());
154
+ try {
155
+ const pr = await fetchPrInfo(artifact.pr);
156
+ if (pr.state !== "OPEN") {
157
+ await updateArtifactByKey(artifactKey(artifact.id), (a) => ({
158
+ ...a,
159
+ pr: { ...a.pr, state: pr.state },
160
+ }));
161
+ log(`[${artifact.id}] ${pr.state.toLowerCase()} — archived`);
162
+ }
163
+ }
164
+ catch {
165
+ // Same: book-keeping can wait for the next poll.
166
+ }
167
+ }
168
+ return { discovered };
169
+ }
170
+ async function reviewAll(refs) {
171
+ let reviewed = 0;
172
+ let skipped = 0;
173
+ let failed = 0;
174
+ await pool(refs, opts.parallel, async (ref) => {
175
+ const label = artifactId(ref);
176
+ try {
177
+ const result = await reviewPr(ref, {
178
+ model: opts.model,
179
+ withSource: opts.withSource,
180
+ trust: opts.trust,
181
+ onProgress: (m) => log(`[${label}] ${m}`),
182
+ });
183
+ if (result.skipped)
184
+ skipped++;
185
+ else {
186
+ reviewed++;
187
+ await handleAutoSend(result.artifact);
188
+ }
189
+ }
190
+ catch (err) {
191
+ if (err instanceof ReviewInProgressError) {
192
+ // The cockpit (or an earlier, slower poll) is already on it.
193
+ skipped++;
194
+ return;
195
+ }
196
+ failed++;
197
+ status.errors++;
198
+ log(`[${label}] failed: ${err instanceof Error ? err.message : err}`);
199
+ }
200
+ });
201
+ return { reviewed, skipped, failed };
202
+ }
31
203
  async function poll() {
32
204
  if (status.polling)
33
205
  return;
34
206
  status.polling = true;
35
207
  status.lastPollAt = new Date().toISOString();
36
208
  try {
37
- const filters = opts.repos.length > 0 ? opts.repos : [undefined];
38
- const seen = new Set();
39
- const refs = (await Promise.all(filters.map((repo) => searchAwaitingMe(repo))))
40
- .flat()
41
- .filter((r) => {
42
- const id = `${r.owner}/${r.repo}#${r.number}`;
43
- if (seen.has(id))
44
- return false;
45
- seen.add(id);
46
- return true;
47
- });
48
- let reviewed = 0;
49
- let skipped = 0;
50
- let failed = 0;
51
- await pool(refs, opts.parallel, async (ref) => {
52
- const label = `${ref.owner}/${ref.repo}#${ref.number}`;
53
- try {
54
- const result = await reviewPr(ref, {
55
- model: opts.model,
56
- withSource: opts.withSource,
57
- trust: opts.trust,
58
- onProgress: (m) => log(`[${label}] ${m}`),
59
- });
60
- if (result.skipped)
61
- skipped++;
62
- else {
63
- reviewed++;
64
- await handleAutoSend(result.artifact);
65
- }
66
- }
67
- catch (err) {
68
- if (err instanceof ReviewInProgressError) {
69
- // The cockpit (or an earlier, slower poll) is already on it.
70
- skipped++;
71
- return;
72
- }
73
- failed++;
74
- status.errors++;
75
- log(`[${label}] failed: ${err instanceof Error ? err.message : err}`);
76
- }
77
- });
78
- status.lastSummary = `${refs.length} awaiting; ${reviewed} reviewed, ${skipped} up to date${failed > 0 ? `, ${failed} failed` : ""}`;
209
+ // The config is re-read every poll, so the cockpit's Settings toggles
210
+ // take effect on the next tick — no restart. CLI --no-* flags cap it.
211
+ // loadConfig is strict on purpose (a typo must not silently change trust
212
+ // or the knobs), so a broken file skips the tick loudly instead of
213
+ // proceeding on guessed defaults.
214
+ const config = await loadConfig();
215
+ const knobs = config.daemon;
216
+ const autoReview = opts.autoReview && knobs.autoReview;
217
+ status.pollEnabled = knobs.poll;
218
+ status.autoReview = autoReview;
219
+ // Recomputed too, so a trust rule added mid-run shows up in the strip.
220
+ status.trustedRuns = autoReview && opts.trust !== false && config.trust.length > 0;
221
+ if (!knobs.poll) {
222
+ // Deliberately off is not an outage — don't leave a stale error up.
223
+ status.lastPollError = null;
224
+ status.lastSummary = "polling off (settings) — the queue only shows what you review by hand";
225
+ return;
226
+ }
227
+ const refs = await discover();
228
+ status.lastPollError = null;
229
+ const { discovered } = await syncQueue(refs);
230
+ if (autoReview) {
231
+ const { reviewed, skipped, failed } = await reviewAll(refs);
232
+ status.lastSummary =
233
+ `${refs.length} awaiting; ${reviewed} reviewed, ${skipped} up to date` +
234
+ `${failed > 0 ? `, ${failed} failed` : ""}`;
235
+ }
236
+ else {
237
+ status.lastSummary = `${refs.length} awaiting${discovered > 0 ? `, ${discovered} new` : ""} (auto-review off)`;
238
+ }
79
239
  log(status.lastSummary);
80
240
  }
81
241
  catch (err) {
82
242
  status.errors++;
83
- status.lastSummary = `poll failed: ${err instanceof Error ? err.message : err}`;
243
+ status.lastPollError = err instanceof Error ? err.message : String(err);
244
+ status.lastSummary = `poll failed: ${status.lastPollError}`;
84
245
  log(status.lastSummary);
85
246
  }
86
247
  finally {
@@ -9,7 +9,8 @@ import { randomUUID } from "node:crypto";
9
9
  import { ArtifactStatusSchema, VerdictSchema, artifactKey } from "../core/artifact.js";
10
10
  import { toMarkdown } from "../core/export.js";
11
11
  import { fetchPrDiff, fetchPrInfo, submitReview } from "../core/gh.js";
12
- import { configPath, loadConfig, saveConfig } from "../core/config.js";
12
+ import { z } from "zod";
13
+ import { DaemonConfigSchema, configPath, loadConfig, saveConfig } from "../core/config.js";
13
14
  import { refreshArtifact } from "../core/refresh.js";
14
15
  import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
15
16
  import { buildReviewPayload, computeCalibration } from "../core/send.js";
@@ -51,12 +52,35 @@ export async function buildApp(opts) {
51
52
  app.get("/api/config", async (c) => {
52
53
  try {
53
54
  const config = await loadConfig();
54
- return c.json({ path: configPath(), trust: trustView(config.trust) });
55
+ return c.json({ path: configPath(), trust: trustView(config.trust), daemon: config.daemon });
55
56
  }
56
57
  catch (err) {
57
58
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
58
59
  }
59
60
  });
61
+ // The inbox knobs. The daemon re-reads the config every poll, so a toggle
62
+ // here applies on the next tick without restarting serve.
63
+ app.post("/api/config/daemon", async (c) => {
64
+ let body;
65
+ try {
66
+ body = await c.req.json();
67
+ }
68
+ catch {
69
+ return c.json({ error: "body must be JSON" }, 400);
70
+ }
71
+ try {
72
+ const config = await loadConfig();
73
+ const daemon = DaemonConfigSchema.parse({ ...config.daemon, ...body });
74
+ await saveConfig({ ...config, daemon });
75
+ return c.json({ path: configPath(), trust: trustView(config.trust), daemon });
76
+ }
77
+ catch (err) {
78
+ if (err instanceof z.ZodError) {
79
+ return c.json({ error: err.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") }, 400);
80
+ }
81
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
82
+ }
83
+ });
60
84
  app.post("/api/config/trust", async (c) => {
61
85
  let body;
62
86
  try {
@@ -90,7 +114,9 @@ export async function buildApp(opts) {
90
114
  });
91
115
  const trust = remove === true ? without : [...without, canonical];
92
116
  await saveConfig({ ...config, trust });
93
- return c.json({ path: configPath(), trust: trustView(trust) });
117
+ // Full ConfigView — the cockpit replaces its config state with this
118
+ // wholesale, so omitting daemon would crash the Settings toggles.
119
+ return c.json({ path: configPath(), trust: trustView(trust), daemon: config.daemon });
94
120
  }
95
121
  catch (err) {
96
122
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
@@ -110,6 +136,7 @@ export async function buildApp(opts) {
110
136
  owner: a.pr.owner,
111
137
  repo: a.pr.repo,
112
138
  number: a.pr.number,
139
+ state: a.pr.state,
113
140
  additions: a.pr.additions,
114
141
  deletions: a.pr.deletions,
115
142
  changedFiles: a.pr.changedFiles,
@@ -360,7 +387,10 @@ export async function startServer(opts) {
360
387
  serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
361
388
  const tokenHint = opts.token ? `/?token=${opts.token}` : "";
362
389
  console.log(`cerber cockpit: http://${opts.host}:${info.port}${tokenHint}`);
363
- if (opts.daemon)
364
- console.log("daemon: polling for PRs awaiting your review");
390
+ if (opts.daemon) {
391
+ const s = opts.daemon.status();
392
+ console.log(`inbox: polling every ${Math.round(s.intervalMs / 60_000)}m` +
393
+ `${s.autoReview ? ", drafting a review for each PR awaiting you" : " (auto-review off — click review in the queue)"}`);
394
+ }
365
395
  });
366
396
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
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
  "repository": "github:fullstackhouse/cerber",