@fullstackhouse/cerber 0.3.0 → 0.5.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
@@ -58,10 +58,33 @@ Early. Current phase: review + read-only cockpit.
58
58
  GitHub write in the codebase) + `cerber export` to markdown
59
59
  3. ✅ `--awaiting-me` discovery, parallel runs (`--parallel`, default 3),
60
60
  freshness skip (same head SHA → no re-run; `--force` overrides), queue stats
61
- 4. Daemon/VPS mode: poll for PRs awaiting your review, review them
62
- automatically, cockpit always warm
63
- 5. Confidence calibration → shadow-mode auto-send → opt-in auto-send above a
64
- threshold
61
+ 4. ✅ Daemon/VPS mode: `cerber serve --daemon` polls for PRs awaiting your
62
+ review and reviews them automatically (read-only); token auth for
63
+ non-localhost binds
64
+ 5. ✅ Confidence calibration (`cerber stats`), shadow-mode auto-send (default
65
+ in daemon: logs what would be sent), opt-in `--auto-send` (approve-only,
66
+ `--auto-send-threshold`, default 90%)
67
+
68
+ ## Running on a VPS
69
+
70
+ ```bash
71
+ cerber serve --daemon --host 0.0.0.0 --token "$(openssl rand -hex 16)" \
72
+ --repo you/repo-a --repo you/repo-b --interval 10
73
+ ```
74
+
75
+ The daemon polls GitHub, reviews anything new (skipping PRs whose artifact
76
+ already matches the head SHA), and the cockpit is always warm — open it any
77
+ minute and see pending/ready/sent reviews. Auth: `?token=…` once in the
78
+ browser (sets a cookie) or `Authorization: Bearer …`. Binding a non-localhost
79
+ host without a token is refused. By default the daemon never sends reviews — Send stays a human click.
80
+
81
+ **Auto-send** (opt-in): add `--auto-send --auto-send-threshold 90` and the
82
+ daemon will submit APPROVE verdicts at/above the threshold as you. It never
83
+ auto-sends COMMENT or REQUEST_CHANGES. Before enabling, run the default
84
+ shadow mode for a while: every would-send decision lands in
85
+ `~/.cerber/autosend.ndjson`, and `cerber stats` shows how often the AI's
86
+ verdicts and comments survive your review — enable auto-send when its 90%
87
+ actually means 90%.
65
88
 
66
89
  ## Development
67
90
 
package/dist/cli/index.js CHANGED
@@ -4,15 +4,16 @@ import readline from "node:readline/promises";
4
4
  import { artifactId, artifactKey } from "../core/artifact.js";
5
5
  import { toMarkdown } from "../core/export.js";
6
6
  import { parsePrRef, searchAwaitingMe, submitReview } from "../core/gh.js";
7
- import { buildReviewPayload, eventForRecommendation } from "../core/send.js";
8
- import { cerberHome, listArtifacts, loadArtifact, updateArtifactByKey } from "../core/state.js";
7
+ import { buildReviewPayload, computeCalibration, eventForRecommendation } from "../core/send.js";
8
+ import { cerberHome, listArtifacts, loadArtifact, readAutoSendLog, updateArtifactByKey, } from "../core/state.js";
9
9
  import { pool, reviewPr } from "../runner/review.js";
10
+ import { startDaemon } from "../server/daemon.js";
10
11
  import { startServer } from "../server/index.js";
11
12
  const program = new Command();
12
13
  program
13
14
  .name("cerber")
14
15
  .description("AI code-review cockpit. Claude reviews PRs into local artifacts; nothing reaches GitHub until you explicitly send it.")
15
- .version("0.3.0");
16
+ .version("0.5.0");
16
17
  program
17
18
  .command("review")
18
19
  .description("Run an AI review of one or more PRs (local artifact only — no GitHub writes)")
@@ -154,18 +155,97 @@ program
154
155
  await updateArtifactByKey(artifactKey(id), (a) => ({
155
156
  ...a,
156
157
  status: "sent",
157
- sent: { at: new Date().toISOString(), event, url },
158
+ sent: { at: new Date().toISOString(), event, url, auto: false },
159
+ calibration: computeCalibration(a, event),
158
160
  }));
159
161
  console.log(`✔ Review sent${url ? `: ${url}` : "."}`);
160
162
  });
163
+ program
164
+ .command("stats")
165
+ .description("How well-calibrated the AI reviews are: verdict agreement and comment survival by confidence")
166
+ .action(async () => {
167
+ const artifacts = await listArtifacts();
168
+ const sent = artifacts.filter((a) => a.calibration);
169
+ if (sent.length === 0) {
170
+ console.log("No sent reviews with calibration data yet. Stats appear after you send reviews.");
171
+ }
172
+ else {
173
+ const buckets = [
174
+ [" <60%", (c) => c < 60],
175
+ ["60-79%", (c) => c >= 60 && c < 80],
176
+ ["80-89%", (c) => c >= 80 && c < 90],
177
+ [" 90%+", (c) => c >= 90],
178
+ ];
179
+ console.log(`Calibration over ${sent.length} sent review(s):\n`);
180
+ console.log("confidence n verdict-agree ai-comments kept/edited/dropped");
181
+ for (const [label, match] of buckets) {
182
+ const group = sent.filter((a) => a.calibration.aiConfidence != null && match(a.calibration.aiConfidence));
183
+ if (group.length === 0)
184
+ continue;
185
+ const agree = group.filter((a) => a.calibration.aiRecommendation &&
186
+ eventForRecommendation(a.calibration.aiRecommendation) === a.calibration.sentEvent).length;
187
+ const totals = group.reduce((acc, a) => {
188
+ const c = a.calibration;
189
+ acc.total += c.aiCommentsTotal;
190
+ acc.dropped += c.aiCommentsDropped;
191
+ acc.edited += c.aiCommentsEdited;
192
+ return acc;
193
+ }, { total: 0, dropped: 0, edited: 0 });
194
+ const kept = totals.total - totals.dropped;
195
+ console.log(`${label} ${String(group.length).padEnd(3)} ${`${agree}/${group.length}`.padEnd(14)}` +
196
+ `${kept}/${totals.edited}/${totals.dropped} of ${totals.total}`);
197
+ }
198
+ }
199
+ const log = await readAutoSendLog();
200
+ if (log.length > 0) {
201
+ const wouldSend = log.filter((e) => e.decision.startsWith("would send")).length;
202
+ const autoSent = log.filter((e) => e.sent).length;
203
+ console.log(`\nAuto-send log (${cerberHome()}/autosend.ndjson): ${log.length} decision(s), ` +
204
+ `${wouldSend} shadow candidate(s), ${autoSent} auto-sent.`);
205
+ }
206
+ });
161
207
  program
162
208
  .command("serve")
163
- .description("Start the local review cockpit")
209
+ .description("Start the review cockpit; --daemon keeps the queue warm by polling GitHub")
164
210
  .option("-p, --port <port>", "port", "4820")
165
- .option("-H, --host <host>", "host to bind", "127.0.0.1")
211
+ .option("-H, --host <host>", "host to bind (0.0.0.0 for VPS — requires --token)", "127.0.0.1")
212
+ .option("-t, --token <token>", "require this token on every request (env: CERBER_TOKEN)")
213
+ .option("-d, --daemon", "poll for PRs awaiting your review and auto-review them (read-only)")
214
+ .option("-i, --interval <minutes>", "daemon poll interval", "5")
215
+ .option("-R, --repo <owner/repo>", "repo(s) the daemon watches (repeatable; default: all)", collect, [])
216
+ .option("-P, --parallel <n>", "daemon review concurrency", "3")
217
+ .option("-m, --model <model>", "Claude model override for daemon reviews")
218
+ .option("--auto-send", "ACTUALLY auto-send APPROVE verdicts at/above --auto-send-threshold (default: shadow mode, which only logs what would be sent)")
219
+ .option("--auto-send-threshold <pct>", "confidence needed to auto-send", "90")
220
+ .option("--insecure", "allow binding a non-localhost host without a token (NOT recommended)")
166
221
  .action(async (opts) => {
167
- await startServer({ port: Number(opts.port), host: opts.host });
222
+ const token = opts.token ?? process.env.CERBER_TOKEN;
223
+ const isLocal = ["127.0.0.1", "localhost", "::1"].includes(opts.host);
224
+ if (!isLocal && !token && !opts.insecure) {
225
+ console.error(`Refusing to bind ${opts.host} without auth: anyone reaching this port could send reviews as you.\n` +
226
+ `Pass --token <secret> (or CERBER_TOKEN), or --insecure if the network is trusted.`);
227
+ process.exit(1);
228
+ }
229
+ const threshold = Math.min(100, Math.max(50, Number(opts.autoSendThreshold) || 90));
230
+ if (opts.autoSend) {
231
+ console.log(`⚠ auto-send is ON: APPROVE verdicts with confidence ≥ ${threshold}% will be sent to GitHub as you, without a per-review confirmation.\n` +
232
+ ` Every decision is logged to ${cerberHome()}/autosend.ndjson. COMMENT/REQUEST_CHANGES always wait for a human.`);
233
+ }
234
+ const daemon = opts.daemon
235
+ ? startDaemon({
236
+ repos: opts.repo,
237
+ intervalMs: Math.max(1, Number(opts.interval) || 5) * 60_000,
238
+ parallel: Math.max(1, Number(opts.parallel) || 1),
239
+ model: opts.model,
240
+ autoSend: opts.autoSend ? "on" : "shadow",
241
+ autoSendThreshold: threshold,
242
+ })
243
+ : undefined;
244
+ await startServer({ port: Number(opts.port), host: opts.host, token, daemon });
168
245
  });
246
+ function collect(value, previous) {
247
+ return [...previous, value];
248
+ }
169
249
  program.parseAsync().catch((err) => {
170
250
  console.error(err instanceof Error ? err.message : err);
171
251
  process.exit(1);
@@ -10,6 +10,8 @@ export const CommentSchema = z.object({
10
10
  chapterId: z.string().nullable().default(null),
11
11
  origin: z.enum(["ai", "user"]),
12
12
  status: z.enum(["draft", "approved", "dropped"]).default("draft"),
13
+ /** Set when the user edits an AI comment's body — calibration signal. */
14
+ editedByUser: z.boolean().default(false),
13
15
  });
14
16
  /** A logical group of changes — the unit of the review walkthrough. */
15
17
  export const ChapterSchema = z.object({
@@ -59,6 +61,18 @@ export const SentInfoSchema = z.object({
59
61
  at: z.string(),
60
62
  event: z.enum(["APPROVE", "COMMENT", "REQUEST_CHANGES"]),
61
63
  url: z.string().nullable().default(null),
64
+ /** True when the daemon auto-sent this review (opt-in --auto-send). */
65
+ auto: z.boolean().default(false),
66
+ });
67
+ /** What actually happened vs what the AI proposed — recorded at send time. */
68
+ export const CalibrationSchema = z.object({
69
+ aiRecommendation: z.enum(["approve", "comment", "request_changes"]).nullable(),
70
+ aiConfidence: z.number().nullable(),
71
+ sentEvent: z.enum(["APPROVE", "COMMENT", "REQUEST_CHANGES"]),
72
+ aiCommentsTotal: z.number().int(),
73
+ aiCommentsDropped: z.number().int(),
74
+ aiCommentsEdited: z.number().int(),
75
+ userCommentsAdded: z.number().int(),
62
76
  });
63
77
  export const ArtifactSchema = z.object({
64
78
  schemaVersion: z.literal(SCHEMA_VERSION),
@@ -76,8 +90,9 @@ export const ArtifactSchema = z.object({
76
90
  comments: z.array(CommentSchema).default([]),
77
91
  verdict: VerdictSchema.nullable().default(null),
78
92
  run: RunInfoSchema.nullable().default(null),
79
- /** Set once the user explicitly sent the review to GitHub. */
93
+ /** Set once the review was sent to GitHub (explicitly, or via opt-in auto-send). */
80
94
  sent: SentInfoSchema.nullable().default(null),
95
+ calibration: CalibrationSchema.nullable().default(null),
81
96
  });
82
97
  /** What the AI must return. Subset of the artifact; cerber owns the rest. */
83
98
  export const AiReviewSchema = z.object({
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Auto-send policy — deliberately narrow:
3
+ * only APPROVE verdicts, at or above the confidence threshold, never a
4
+ * re-send. COMMENT and REQUEST_CHANGES always wait for a human.
5
+ */
6
+ export function evaluateAutoSend(artifact, threshold) {
7
+ if (artifact.sent)
8
+ return { eligible: false, reason: "already sent" };
9
+ if (artifact.status !== "ready")
10
+ return { eligible: false, reason: `status is ${artifact.status}` };
11
+ if (!artifact.verdict)
12
+ return { eligible: false, reason: "no verdict" };
13
+ if (artifact.verdict.recommendation !== "approve") {
14
+ return {
15
+ eligible: false,
16
+ reason: `recommendation is ${artifact.verdict.recommendation} — only approve auto-sends`,
17
+ };
18
+ }
19
+ if (artifact.verdict.confidence < threshold) {
20
+ return {
21
+ eligible: false,
22
+ reason: `confidence ${artifact.verdict.confidence}% < threshold ${threshold}%`,
23
+ };
24
+ }
25
+ return { eligible: true, reason: `approve at ${artifact.verdict.confidence}% ≥ ${threshold}%` };
26
+ }
package/dist/core/send.js CHANGED
@@ -39,3 +39,16 @@ export function buildReviewPayload(artifact, event) {
39
39
  parts.push("", "---", "_Reviewed with [cerber](https://github.com/fullstackhouse/cerber) 🐕 — drafted by AI, sent by a human._");
40
40
  return { event, body: parts.join("\n").trim(), comments: inline, folded };
41
41
  }
42
+ /** Snapshot of AI-proposal vs human-final, recorded at send time for `cerber stats`. */
43
+ export function computeCalibration(artifact, event) {
44
+ const ai = artifact.comments.filter((c) => c.origin === "ai");
45
+ return {
46
+ aiRecommendation: artifact.verdict?.recommendation ?? null,
47
+ aiConfidence: artifact.verdict?.confidence ?? null,
48
+ sentEvent: event,
49
+ aiCommentsTotal: ai.length,
50
+ aiCommentsDropped: ai.filter((c) => c.status === "dropped").length,
51
+ aiCommentsEdited: ai.filter((c) => c.editedByUser).length,
52
+ userCommentsAdded: artifact.comments.filter((c) => c.origin === "user").length,
53
+ };
54
+ }
@@ -41,6 +41,32 @@ export async function loadArtifactByKey(key) {
41
41
  throw err;
42
42
  }
43
43
  }
44
+ /** Append to ~/.cerber/autosend.ndjson — the shadow/auto-send audit log. */
45
+ export async function appendAutoSendLog(entry) {
46
+ await fs.mkdir(cerberHome(), { recursive: true });
47
+ await fs.appendFile(path.join(cerberHome(), "autosend.ndjson"), JSON.stringify(entry) + "\n");
48
+ }
49
+ export async function readAutoSendLog() {
50
+ try {
51
+ const raw = await fs.readFile(path.join(cerberHome(), "autosend.ndjson"), "utf8");
52
+ return raw
53
+ .split("\n")
54
+ .filter(Boolean)
55
+ .flatMap((line) => {
56
+ try {
57
+ return [JSON.parse(line)];
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ });
63
+ }
64
+ catch (err) {
65
+ if (err.code === "ENOENT")
66
+ return [];
67
+ throw err;
68
+ }
69
+ }
44
70
  /** Load by key, apply a mutation, bump updatedAt, save. Returns the new artifact. */
45
71
  export async function updateArtifactByKey(key, mutate) {
46
72
  const artifact = await loadArtifactByKey(key);
@@ -40,6 +40,7 @@ export async function reviewPr(ref, opts = {}) {
40
40
  verdict: null,
41
41
  run: { model: opts.model ?? null, startedAt: now(), finishedAt: null, costUsd: null, error: null },
42
42
  sent: null,
43
+ calibration: null,
43
44
  };
44
45
  await saveArtifact(artifact);
45
46
  const { prompt, truncated } = buildReviewPrompt(pr, diff);
@@ -70,6 +71,7 @@ export async function reviewPr(ref, opts = {}) {
70
71
  chapterId: c.chapterId ?? null,
71
72
  origin: "ai",
72
73
  status: "draft",
74
+ editedByUser: false,
73
75
  })),
74
76
  verdict: review.ai.verdict,
75
77
  run: {
@@ -0,0 +1,136 @@
1
+ import { artifactKey } from "../core/artifact.js";
2
+ import { evaluateAutoSend } from "../core/autosend.js";
3
+ import { searchAwaitingMe, submitReview } from "../core/gh.js";
4
+ import { buildReviewPayload, computeCalibration } from "../core/send.js";
5
+ import { appendAutoSendLog, updateArtifactByKey } from "../core/state.js";
6
+ import { pool, reviewPr } from "../runner/review.js";
7
+ /**
8
+ * Poll GitHub for PRs awaiting the user's review and keep their artifacts
9
+ * warm. Reviews are read-only; the daemon NEVER sends anything.
10
+ */
11
+ export function startDaemon(opts) {
12
+ let timer = null;
13
+ let stopped = false;
14
+ const status = {
15
+ enabled: true,
16
+ polling: false,
17
+ repos: opts.repos,
18
+ intervalMs: opts.intervalMs,
19
+ polls: 0,
20
+ errors: 0,
21
+ lastPollAt: null,
22
+ nextPollAt: new Date(Date.now()).toISOString(),
23
+ lastSummary: null,
24
+ autoSend: opts.autoSend,
25
+ autoSendThreshold: opts.autoSendThreshold,
26
+ autoSent: 0,
27
+ autoSendCandidates: 0,
28
+ };
29
+ const log = (m) => console.log(`[daemon] ${m}`);
30
+ async function poll() {
31
+ if (status.polling)
32
+ return;
33
+ status.polling = true;
34
+ status.lastPollAt = new Date().toISOString();
35
+ try {
36
+ const filters = opts.repos.length > 0 ? opts.repos : [undefined];
37
+ const seen = new Set();
38
+ const refs = (await Promise.all(filters.map((repo) => searchAwaitingMe(repo))))
39
+ .flat()
40
+ .filter((r) => {
41
+ const id = `${r.owner}/${r.repo}#${r.number}`;
42
+ if (seen.has(id))
43
+ return false;
44
+ seen.add(id);
45
+ return true;
46
+ });
47
+ let reviewed = 0;
48
+ let skipped = 0;
49
+ let failed = 0;
50
+ await pool(refs, opts.parallel, async (ref) => {
51
+ const label = `${ref.owner}/${ref.repo}#${ref.number}`;
52
+ try {
53
+ const result = await reviewPr(ref, {
54
+ model: opts.model,
55
+ onProgress: (m) => log(`[${label}] ${m}`),
56
+ });
57
+ if (result.skipped)
58
+ skipped++;
59
+ else {
60
+ reviewed++;
61
+ await handleAutoSend(result.artifact);
62
+ }
63
+ }
64
+ catch (err) {
65
+ failed++;
66
+ status.errors++;
67
+ log(`[${label}] failed: ${err instanceof Error ? err.message : err}`);
68
+ }
69
+ });
70
+ status.lastSummary = `${refs.length} awaiting; ${reviewed} reviewed, ${skipped} up to date${failed > 0 ? `, ${failed} failed` : ""}`;
71
+ log(status.lastSummary);
72
+ }
73
+ catch (err) {
74
+ status.errors++;
75
+ status.lastSummary = `poll failed: ${err instanceof Error ? err.message : err}`;
76
+ log(status.lastSummary);
77
+ }
78
+ finally {
79
+ status.polls++;
80
+ status.polling = false;
81
+ if (!stopped) {
82
+ status.nextPollAt = new Date(Date.now() + opts.intervalMs).toISOString();
83
+ timer = setTimeout(poll, opts.intervalMs);
84
+ }
85
+ }
86
+ }
87
+ async function handleAutoSend(artifact) {
88
+ const decision = evaluateAutoSend(artifact, opts.autoSendThreshold);
89
+ if (decision.eligible)
90
+ status.autoSendCandidates++;
91
+ const entryBase = {
92
+ at: new Date().toISOString(),
93
+ id: artifact.id,
94
+ recommendation: artifact.verdict?.recommendation ?? null,
95
+ confidence: artifact.verdict?.confidence ?? null,
96
+ mode: opts.autoSend,
97
+ };
98
+ if (!decision.eligible) {
99
+ await appendAutoSendLog({ ...entryBase, decision: decision.reason, sent: false });
100
+ return;
101
+ }
102
+ if (opts.autoSend === "shadow") {
103
+ log(`[${artifact.id}] WOULD auto-send: ${decision.reason} (shadow mode — nothing sent)`);
104
+ await appendAutoSendLog({ ...entryBase, decision: `would send: ${decision.reason}`, sent: false });
105
+ return;
106
+ }
107
+ try {
108
+ const payload = buildReviewPayload(artifact, "APPROVE");
109
+ const { url } = await submitReview({ owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number }, { event: payload.event, body: payload.body, comments: payload.comments });
110
+ await updateArtifactByKey(artifactKey(artifact.id), (a) => ({
111
+ ...a,
112
+ status: "sent",
113
+ sent: { at: new Date().toISOString(), event: "APPROVE", url, auto: true },
114
+ calibration: computeCalibration(a, "APPROVE"),
115
+ }));
116
+ status.autoSent++;
117
+ log(`[${artifact.id}] AUTO-SENT approve (${decision.reason})${url ? ` — ${url}` : ""}`);
118
+ await appendAutoSendLog({ ...entryBase, decision: `auto-sent: ${decision.reason}`, sent: true });
119
+ }
120
+ catch (err) {
121
+ status.errors++;
122
+ const message = err instanceof Error ? err.message : String(err);
123
+ log(`[${artifact.id}] auto-send failed: ${message}`);
124
+ await appendAutoSendLog({ ...entryBase, decision: `auto-send failed: ${message}`, sent: false });
125
+ }
126
+ }
127
+ void poll();
128
+ return {
129
+ status: () => ({ ...status }),
130
+ stop: () => {
131
+ stopped = true;
132
+ if (timer)
133
+ clearTimeout(timer);
134
+ },
135
+ };
136
+ }
@@ -1,6 +1,7 @@
1
1
  import { serve } from "@hono/node-server";
2
2
  import { serveStatic } from "@hono/node-server/serve-static";
3
3
  import { Hono } from "hono";
4
+ import { getCookie, setCookie } from "hono/cookie";
4
5
  import { promises as fs } from "node:fs";
5
6
  import path from "node:path";
6
7
  import { fileURLToPath } from "node:url";
@@ -8,11 +9,28 @@ import { randomUUID } from "node:crypto";
8
9
  import { ArtifactStatusSchema, VerdictSchema, artifactKey } from "../core/artifact.js";
9
10
  import { toMarkdown } from "../core/export.js";
10
11
  import { submitReview } from "../core/gh.js";
11
- import { buildReviewPayload } from "../core/send.js";
12
+ import { buildReviewPayload, computeCalibration } from "../core/send.js";
12
13
  import { listArtifacts, loadArtifactByKey, updateArtifactByKey } from "../core/state.js";
13
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
- export async function startServer(opts) {
15
+ export async function buildApp(opts) {
15
16
  const app = new Hono();
17
+ if (opts.token) {
18
+ const token = opts.token;
19
+ app.use("*", async (c, next) => {
20
+ const query = c.req.query("token");
21
+ const cookie = getCookie(c, "cerber_token");
22
+ const header = c.req.header("authorization");
23
+ if (query === token || cookie === token || header === `Bearer ${token}`) {
24
+ if (query === token && cookie !== token) {
25
+ // First visit via ?token=… — set the cookie so the SPA's asset and API requests pass.
26
+ setCookie(c, "cerber_token", token, { httpOnly: true, sameSite: "Strict", path: "/" });
27
+ }
28
+ return next();
29
+ }
30
+ return c.text("unauthorized — pass ?token=… or Authorization: Bearer …\n", 401);
31
+ });
32
+ }
33
+ app.get("/api/daemon", (c) => c.json(opts.daemon ? opts.daemon.status() : { enabled: false }));
16
34
  app.get("/api/reviews", async (c) => {
17
35
  const artifacts = await listArtifacts();
18
36
  return c.json(artifacts.map((a) => ({
@@ -74,6 +92,9 @@ export async function startServer(opts) {
74
92
  ...(body.status !== undefined
75
93
  ? { status: body.status }
76
94
  : {}),
95
+ ...(body.body !== undefined && body.body !== cm.body && cm.origin === "ai"
96
+ ? { editedByUser: true }
97
+ : {}),
77
98
  }
78
99
  : cm),
79
100
  }));
@@ -95,6 +116,7 @@ export async function startServer(opts) {
95
116
  chapterId: body.chapterId ?? null,
96
117
  origin: "user",
97
118
  status: "draft",
119
+ editedByUser: false,
98
120
  },
99
121
  ],
100
122
  }));
@@ -142,7 +164,8 @@ export async function startServer(opts) {
142
164
  const updated = await updateArtifactByKey(c.req.param("key"), (a) => ({
143
165
  ...a,
144
166
  status: "sent",
145
- sent: { at: new Date().toISOString(), event: payload.event, url },
167
+ sent: { at: new Date().toISOString(), event: payload.event, url, auto: false },
168
+ calibration: computeCalibration(a, payload.event),
146
169
  }));
147
170
  return c.json(updated);
148
171
  }
@@ -172,7 +195,14 @@ export async function startServer(opts) {
172
195
  else {
173
196
  app.get("/", (c) => c.text("cerber API is running, but the web cockpit is not built.\nRun `pnpm build` in the cerber repo, or use the API: GET /api/reviews\n"));
174
197
  }
198
+ return app;
199
+ }
200
+ export async function startServer(opts) {
201
+ const app = await buildApp(opts);
175
202
  serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
176
- console.log(`cerber cockpit: http://${opts.host}:${info.port}`);
203
+ const tokenHint = opts.token ? `/?token=${opts.token}` : "";
204
+ console.log(`cerber cockpit: http://${opts.host}:${info.port}${tokenHint}`);
205
+ if (opts.daemon)
206
+ console.log("daemon: polling for PRs awaiting your review");
177
207
  });
178
208
  }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.3.0",
3
+ "version": "0.5.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
  "repository": "github:fullstackhouse/cerber",
7
7
  "type": "module",
8
+ "packageManager": "pnpm@10.33.2",
8
9
  "bin": {
9
10
  "cerber": "dist/cli/index.js"
10
11
  },
@@ -36,6 +37,8 @@
36
37
  "@types/react": "^19.0.4",
37
38
  "@types/react-dom": "^19.0.2",
38
39
  "@vitejs/plugin-react": "^4.3.4",
40
+ "dompurify": "^3.4.13",
41
+ "marked": "^18.0.10",
39
42
  "react": "^19.0.0",
40
43
  "react-dom": "^19.0.0",
41
44
  "tsx": "^4.19.2",
@@ -1 +1 @@
1
- :host,:root{--d2h-bg-color:#fff;--d2h-border-color:#ddd;--d2h-dim-color:rgba(0,0,0,.3);--d2h-line-border-color:#eee;--d2h-file-header-bg-color:#f7f7f7;--d2h-file-header-border-color:#d8d8d8;--d2h-empty-placeholder-bg-color:#f1f1f1;--d2h-empty-placeholder-border-color:#e1e1e1;--d2h-selected-color:#c8e1ff;--d2h-ins-bg-color:#dfd;--d2h-ins-border-color:#b4e2b4;--d2h-ins-highlight-bg-color:#97f295;--d2h-ins-label-color:#399839;--d2h-del-bg-color:#fee8e9;--d2h-del-border-color:#e9aeae;--d2h-del-highlight-bg-color:#ffb6ba;--d2h-del-label-color:#c33;--d2h-change-del-color:#fdf2d0;--d2h-change-ins-color:#ded;--d2h-info-bg-color:#f8fafd;--d2h-info-border-color:#d5e4f2;--d2h-change-label-color:#d0b44c;--d2h-moved-label-color:#3572b0;--d2h-dark-color:#e6edf3;--d2h-dark-bg-color:#0d1117;--d2h-dark-border-color:#30363d;--d2h-dark-dim-color:#6e7681;--d2h-dark-line-border-color:#21262d;--d2h-dark-file-header-bg-color:#161b22;--d2h-dark-file-header-border-color:#30363d;--d2h-dark-empty-placeholder-bg-color:hsla(215,8%,47%,.1);--d2h-dark-empty-placeholder-border-color:#30363d;--d2h-dark-selected-color:rgba(56,139,253,.1);--d2h-dark-ins-bg-color:rgba(46,160,67,.15);--d2h-dark-ins-border-color:rgba(46,160,67,.4);--d2h-dark-ins-highlight-bg-color:rgba(46,160,67,.4);--d2h-dark-ins-label-color:#3fb950;--d2h-dark-del-bg-color:rgba(248,81,73,.1);--d2h-dark-del-border-color:rgba(248,81,73,.4);--d2h-dark-del-highlight-bg-color:rgba(248,81,73,.4);--d2h-dark-del-label-color:#f85149;--d2h-dark-change-del-color:rgba(210,153,34,.2);--d2h-dark-change-ins-color:rgba(46,160,67,.25);--d2h-dark-info-bg-color:rgba(56,139,253,.1);--d2h-dark-info-border-color:rgba(56,139,253,.4);--d2h-dark-change-label-color:#d29922;--d2h-dark-moved-label-color:#3572b0}.d2h-wrapper{text-align:left}.d2h-file-header{background-color:#f7f7f7;background-color:var(--d2h-file-header-bg-color);border-bottom:1px solid #d8d8d8;border-bottom:1px solid var(--d2h-file-header-border-color);display:-webkit-box;display:-ms-flexbox;display:flex;font-family:Source Sans Pro,Helvetica Neue,Helvetica,Arial,sans-serif;height:35px;padding:5px 10px}.d2h-file-header.d2h-sticky-header{position:sticky;top:0;z-index:1}.d2h-file-stats{display:-webkit-box;display:-ms-flexbox;display:flex;font-size:14px;margin-left:auto}.d2h-lines-added{border:1px solid #b4e2b4;border:1px solid var(--d2h-ins-border-color);border-radius:5px 0 0 5px;color:#399839;color:var(--d2h-ins-label-color);padding:2px;text-align:right;vertical-align:middle}.d2h-lines-deleted{border:1px solid #e9aeae;border:1px solid var(--d2h-del-border-color);border-radius:0 5px 5px 0;color:#c33;color:var(--d2h-del-label-color);margin-left:1px;padding:2px;text-align:left;vertical-align:middle}.d2h-file-name-wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;font-size:15px;width:100%}.d2h-file-name{overflow-x:hidden;text-overflow:ellipsis;white-space:nowrap}.d2h-file-wrapper{border:1px solid #ddd;border:1px solid var(--d2h-border-color);border-radius:3px;margin-bottom:1em}.d2h-file-collapse{-webkit-box-pack:end;-ms-flex-pack:end;cursor:pointer;display:none;font-size:12px;justify-content:flex-end;-webkit-box-align:center;-ms-flex-align:center;align-items:center;border:1px solid #ddd;border:1px solid var(--d2h-border-color);border-radius:3px;padding:4px 8px}.d2h-file-collapse.d2h-selected{background-color:#c8e1ff;background-color:var(--d2h-selected-color)}.d2h-file-collapse-input{margin:0 4px 0 0}.d2h-diff-table{border-collapse:collapse;font-family:Menlo,Consolas,monospace;font-size:13px;width:100%}.d2h-files-diff{display:-webkit-box;display:-ms-flexbox;display:flex;width:100%}.d2h-file-diff{overflow-y:hidden}.d2h-file-diff.d2h-d-none,.d2h-files-diff.d2h-d-none{display:none}.d2h-file-side-diff{display:inline-block;overflow-x:scroll;overflow-y:hidden;width:50%}.d2h-code-line{padding:0 8em;width:calc(100% - 16em)}.d2h-code-line,.d2h-code-side-line{display:inline-block;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;white-space:nowrap}.d2h-code-side-line{padding:0 4.5em;width:calc(100% - 9em)}.d2h-code-line-ctn{background:none;display:inline-block;padding:0;word-wrap:normal;-webkit-user-select:text;-moz-user-select:text;-ms-user-select:text;user-select:text;vertical-align:middle;white-space:pre;width:100%}.d2h-code-line del,.d2h-code-side-line del{background-color:#ffb6ba;background-color:var(--d2h-del-highlight-bg-color)}.d2h-code-line del,.d2h-code-line ins,.d2h-code-side-line del,.d2h-code-side-line ins{border-radius:.2em;display:inline-block;margin-top:-1px;-webkit-text-decoration:none;text-decoration:none}.d2h-code-line ins,.d2h-code-side-line ins{background-color:#97f295;background-color:var(--d2h-ins-highlight-bg-color);text-align:left}.d2h-code-line-prefix{background:none;display:inline;padding:0;word-wrap:normal;white-space:pre}.line-num1{float:left}.line-num1,.line-num2{-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden;padding:0 .5em;text-overflow:ellipsis;width:3.5em}.line-num2{float:right}.d2h-code-linenumber{background-color:#fff;background-color:var(--d2h-bg-color);border:solid #eee;border:solid var(--d2h-line-border-color);border-width:0 1px;-webkit-box-sizing:border-box;box-sizing:border-box;color:#0000004d;color:var(--d2h-dim-color);cursor:pointer;display:inline-block;position:absolute;text-align:right;width:7.5em}.d2h-code-linenumber:after{content:"​"}.d2h-code-side-linenumber{background-color:#fff;background-color:var(--d2h-bg-color);border:solid #eee;border:solid var(--d2h-line-border-color);border-width:0 1px;-webkit-box-sizing:border-box;box-sizing:border-box;color:#0000004d;color:var(--d2h-dim-color);cursor:pointer;display:inline-block;overflow:hidden;padding:0 .5em;position:absolute;text-align:right;text-overflow:ellipsis;width:4em}.d2h-code-side-linenumber:after{content:"​"}.d2h-code-side-emptyplaceholder,.d2h-emptyplaceholder{background-color:#f1f1f1;background-color:var(--d2h-empty-placeholder-bg-color);border-color:#e1e1e1;border-color:var(--d2h-empty-placeholder-border-color)}.d2h-code-line-prefix,.d2h-code-linenumber,.d2h-code-side-linenumber,.d2h-emptyplaceholder{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.d2h-code-linenumber,.d2h-code-side-linenumber{direction:rtl}.d2h-del{background-color:#fee8e9;background-color:var(--d2h-del-bg-color);border-color:#e9aeae;border-color:var(--d2h-del-border-color)}.d2h-ins{background-color:#dfd;background-color:var(--d2h-ins-bg-color);border-color:#b4e2b4;border-color:var(--d2h-ins-border-color)}.d2h-info{background-color:#f8fafd;background-color:var(--d2h-info-bg-color);border-color:#d5e4f2;border-color:var(--d2h-info-border-color);color:#0000004d;color:var(--d2h-dim-color)}.d2h-file-diff .d2h-del.d2h-change{background-color:#fdf2d0;background-color:var(--d2h-change-del-color)}.d2h-file-diff .d2h-ins.d2h-change{background-color:#ded;background-color:var(--d2h-change-ins-color)}.d2h-file-list-wrapper{margin-bottom:10px}.d2h-file-list-wrapper a{-webkit-text-decoration:none;text-decoration:none}.d2h-file-list-wrapper a,.d2h-file-list-wrapper a:visited{color:#3572b0;color:var(--d2h-moved-label-color)}.d2h-file-list-header{text-align:left}.d2h-file-list-title{font-weight:700}.d2h-file-list-line{display:-webkit-box;display:-ms-flexbox;display:flex;text-align:left}.d2h-file-list{display:block;list-style:none;margin:0;padding:0}.d2h-file-list>li{border-bottom:1px solid #ddd;border-bottom:1px solid var(--d2h-border-color);margin:0;padding:5px 10px}.d2h-file-list>li:last-child{border-bottom:none}.d2h-file-switch{cursor:pointer;display:none;font-size:10px}.d2h-icon{fill:currentColor;margin-right:10px;vertical-align:middle}.d2h-deleted{color:#c33;color:var(--d2h-del-label-color)}.d2h-added{color:#399839;color:var(--d2h-ins-label-color)}.d2h-changed{color:#d0b44c;color:var(--d2h-change-label-color)}.d2h-moved{color:#3572b0;color:var(--d2h-moved-label-color)}.d2h-tag{background-color:#fff;background-color:var(--d2h-bg-color);display:-webkit-box;display:-ms-flexbox;display:flex;font-size:10px;margin-left:5px;padding:0 2px}.d2h-deleted-tag{border:1px solid #c33;border:1px solid var(--d2h-del-label-color)}.d2h-added-tag{border:1px solid #399839;border:1px solid var(--d2h-ins-label-color)}.d2h-changed-tag{border:1px solid #d0b44c;border:1px solid var(--d2h-change-label-color)}.d2h-moved-tag{border:1px solid #3572b0;border:1px solid var(--d2h-moved-label-color)}.d2h-dark-color-scheme{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);color:#e6edf3;color:var(--d2h-dark-color)}.d2h-dark-color-scheme .d2h-file-header{background-color:#161b22;background-color:var(--d2h-dark-file-header-bg-color);border-bottom:#30363d;border-bottom:var(--d2h-dark-file-header-border-color)}.d2h-dark-color-scheme .d2h-lines-added{border:1px solid rgba(46,160,67,.4);border:1px solid var(--d2h-dark-ins-border-color);color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-dark-color-scheme .d2h-lines-deleted{border:1px solid rgba(248,81,73,.4);border:1px solid var(--d2h-dark-del-border-color);color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-dark-color-scheme .d2h-code-line del,.d2h-dark-color-scheme .d2h-code-side-line del{background-color:#f8514966;background-color:var(--d2h-dark-del-highlight-bg-color)}.d2h-dark-color-scheme .d2h-code-line ins,.d2h-dark-color-scheme .d2h-code-side-line ins{background-color:#2ea04366;background-color:var(--d2h-dark-ins-highlight-bg-color)}.d2h-dark-color-scheme .d2h-diff-tbody{border-color:#30363d;border-color:var(--d2h-dark-border-color)}.d2h-dark-color-scheme .d2h-code-side-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-dark-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder,.d2h-dark-color-scheme .d2h-files-diff .d2h-emptyplaceholder{background-color:#6e76811a;background-color:var(--d2h-dark-empty-placeholder-bg-color);border-color:#30363d;border-color:var(--d2h-dark-empty-placeholder-border-color)}.d2h-dark-color-scheme .d2h-code-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-dark-color-scheme .d2h-del{background-color:#f851491a;background-color:var(--d2h-dark-del-bg-color);border-color:#f8514966;border-color:var(--d2h-dark-del-border-color)}.d2h-dark-color-scheme .d2h-ins{background-color:#2ea04326;background-color:var(--d2h-dark-ins-bg-color);border-color:#2ea04366;border-color:var(--d2h-dark-ins-border-color)}.d2h-dark-color-scheme .d2h-info{background-color:#388bfd1a;background-color:var(--d2h-dark-info-bg-color);border-color:#388bfd66;border-color:var(--d2h-dark-info-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-dark-color-scheme .d2h-file-diff .d2h-del.d2h-change{background-color:#d2992233;background-color:var(--d2h-dark-change-del-color)}.d2h-dark-color-scheme .d2h-file-diff .d2h-ins.d2h-change{background-color:#2ea04340;background-color:var(--d2h-dark-change-ins-color)}.d2h-dark-color-scheme .d2h-file-wrapper{border:1px solid #30363d;border:1px solid var(--d2h-dark-border-color)}.d2h-dark-color-scheme .d2h-file-collapse{border:1px solid #0d1117;border:1px solid var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-file-collapse.d2h-selected{background-color:#388bfd1a;background-color:var(--d2h-dark-selected-color)}.d2h-dark-color-scheme .d2h-file-list-wrapper a,.d2h-dark-color-scheme .d2h-file-list-wrapper a:visited{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-dark-color-scheme .d2h-file-list>li{border-bottom:1px solid #0d1117;border-bottom:1px solid var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-deleted{color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-dark-color-scheme .d2h-added{color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-dark-color-scheme .d2h-changed{color:#d29922;color:var(--d2h-dark-change-label-color)}.d2h-dark-color-scheme .d2h-moved{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-dark-color-scheme .d2h-tag{background-color:#0d1117;background-color:var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-deleted-tag{border:1px solid #f85149;border:1px solid var(--d2h-dark-del-label-color)}.d2h-dark-color-scheme .d2h-added-tag{border:1px solid #3fb950;border:1px solid var(--d2h-dark-ins-label-color)}.d2h-dark-color-scheme .d2h-changed-tag{border:1px solid #d29922;border:1px solid var(--d2h-dark-change-label-color)}.d2h-dark-color-scheme .d2h-moved-tag{border:1px solid #3572b0;border:1px solid var(--d2h-dark-moved-label-color)}@media(prefers-color-scheme:dark){.d2h-auto-color-scheme{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);color:#e6edf3;color:var(--d2h-dark-color)}.d2h-auto-color-scheme .d2h-file-header{background-color:#161b22;background-color:var(--d2h-dark-file-header-bg-color);border-bottom:#30363d;border-bottom:var(--d2h-dark-file-header-border-color)}.d2h-auto-color-scheme .d2h-lines-added{border:1px solid rgba(46,160,67,.4);border:1px solid var(--d2h-dark-ins-border-color);color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-auto-color-scheme .d2h-lines-deleted{border:1px solid rgba(248,81,73,.4);border:1px solid var(--d2h-dark-del-border-color);color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-auto-color-scheme .d2h-code-line del,.d2h-auto-color-scheme .d2h-code-side-line del{background-color:#f8514966;background-color:var(--d2h-dark-del-highlight-bg-color)}.d2h-auto-color-scheme .d2h-code-line ins,.d2h-auto-color-scheme .d2h-code-side-line ins{background-color:#2ea04366;background-color:var(--d2h-dark-ins-highlight-bg-color)}.d2h-auto-color-scheme .d2h-diff-tbody{border-color:#30363d;border-color:var(--d2h-dark-border-color)}.d2h-auto-color-scheme .d2h-code-side-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-auto-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder,.d2h-auto-color-scheme .d2h-files-diff .d2h-emptyplaceholder{background-color:#6e76811a;background-color:var(--d2h-dark-empty-placeholder-bg-color);border-color:#30363d;border-color:var(--d2h-dark-empty-placeholder-border-color)}.d2h-auto-color-scheme .d2h-code-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-auto-color-scheme .d2h-del{background-color:#f851491a;background-color:var(--d2h-dark-del-bg-color);border-color:#f8514966;border-color:var(--d2h-dark-del-border-color)}.d2h-auto-color-scheme .d2h-ins{background-color:#2ea04326;background-color:var(--d2h-dark-ins-bg-color);border-color:#2ea04366;border-color:var(--d2h-dark-ins-border-color)}.d2h-auto-color-scheme .d2h-info{background-color:#388bfd1a;background-color:var(--d2h-dark-info-bg-color);border-color:#388bfd66;border-color:var(--d2h-dark-info-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-auto-color-scheme .d2h-file-diff .d2h-del.d2h-change{background-color:#d2992233;background-color:var(--d2h-dark-change-del-color)}.d2h-auto-color-scheme .d2h-file-diff .d2h-ins.d2h-change{background-color:#2ea04340;background-color:var(--d2h-dark-change-ins-color)}.d2h-auto-color-scheme .d2h-file-wrapper{border:1px solid #30363d;border:1px solid var(--d2h-dark-border-color)}.d2h-auto-color-scheme .d2h-file-collapse{border:1px solid #0d1117;border:1px solid var(--d2h-dark-bg-color)}.d2h-auto-color-scheme .d2h-file-collapse.d2h-selected{background-color:#388bfd1a;background-color:var(--d2h-dark-selected-color)}.d2h-auto-color-scheme .d2h-file-list-wrapper a,.d2h-auto-color-scheme .d2h-file-list-wrapper a:visited{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-auto-color-scheme .d2h-file-list>li{border-bottom:1px solid #0d1117;border-bottom:1px solid var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-deleted{color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-auto-color-scheme .d2h-added{color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-auto-color-scheme .d2h-changed{color:#d29922;color:var(--d2h-dark-change-label-color)}.d2h-auto-color-scheme .d2h-moved{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-auto-color-scheme .d2h-tag{background-color:#0d1117;background-color:var(--d2h-dark-bg-color)}.d2h-auto-color-scheme .d2h-deleted-tag{border:1px solid #f85149;border:1px solid var(--d2h-dark-del-label-color)}.d2h-auto-color-scheme .d2h-added-tag{border:1px solid #3fb950;border:1px solid var(--d2h-dark-ins-label-color)}.d2h-auto-color-scheme .d2h-changed-tag{border:1px solid #d29922;border:1px solid var(--d2h-dark-change-label-color)}.d2h-auto-color-scheme .d2h-moved-tag{border:1px solid #3572b0;border:1px solid var(--d2h-dark-moved-label-color)}}:root{--bg: #ffffff;--fg: #1f2328;--muted: #656d76;--border: #d0d7de;--green: #1a7f37;--red: #cf222e;--yellow: #9a6700;--accent: #0969da}*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;color:var(--fg);background:var(--bg)}.app{max-width:1100px;margin:0 auto;padding:0 16px 64px}.topbar{display:flex;align-items:baseline;gap:12px;padding:16px 0;border-bottom:1px solid var(--border);margin-bottom:16px}.brand{font-size:20px;font-weight:700;text-decoration:none;color:var(--fg)}.tagline{color:var(--muted);font-size:13px}a{color:var(--accent)}.muted{color:var(--muted);font-size:.92em}.error{color:var(--red);background:#ffebe9;border:1px solid rgba(207,34,46,.3);padding:8px 12px;border-radius:6px}.empty{text-align:center;color:var(--muted);margin-top:64px}.empty pre{display:inline-block;background:#f6f8fa;padding:8px 16px;border-radius:6px}.queue-stats{display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap}.queue{width:100%;border-collapse:collapse;font-size:14px}.queue th{text-align:left;color:var(--muted);font-weight:600;padding:6px 8px;border-bottom:1px solid var(--border)}.queue td{padding:8px;border-bottom:1px solid var(--border);vertical-align:top}.pr-link{font-weight:600;text-decoration:none}.status{font-size:12px;padding:2px 8px;border-radius:999px;border:1px solid var(--border);white-space:nowrap}.status-ready{background:#dafbe1;border-color:#1a7f3766}.status-running{background:#ddf4ff;border-color:#0969da66}.status-failed{background:#ffebe9;border-color:#cf222e66}.badge{font-size:12px;padding:2px 8px;border-radius:999px;white-space:nowrap}.badge-green{background:#dafbe1;color:var(--green)}.badge-yellow{background:#fff8c5;color:var(--yellow)}.badge-red{background:#ffebe9;color:var(--red)}.badge-muted{color:var(--muted)}.add{color:var(--green)}.del{color:var(--red)}.back{display:inline-block;margin:8px 0;text-decoration:none}.detail h1{font-size:22px;margin:4px 0}.verdict{border:1px solid var(--border);border-left-width:4px;border-radius:6px;padding:8px 12px;margin:12px 0}.verdict-approve{border-left-color:var(--green)}.verdict-comment{border-left-color:var(--yellow)}.verdict-request_changes{border-left-color:var(--red)}.verdict p{margin:4px 0 0;color:var(--muted);font-size:14px}.summary-body{white-space:pre-wrap;line-height:1.55}.chapter{border:1px solid var(--border);border-radius:6px;margin:12px 0;padding:4px 16px 12px}.chapter-title{cursor:pointer;-webkit-user-select:none;user-select:none;font-size:16px}.chapter-explanation{white-space:pre-wrap;line-height:1.5}.comment{border:1px solid var(--border);border-left:4px solid var(--yellow);border-radius:6px;padding:8px 12px;margin:8px 0;font-size:14px}.comment-loc{font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;color:var(--muted);margin-bottom:4px}.comment-body{white-space:pre-wrap;line-height:1.5}.diff{margin-top:8px;font-size:12px}button{font:inherit;font-size:12px;padding:2px 10px;border:1px solid var(--border);border-radius:6px;background:#f6f8fa;cursor:pointer}button:hover{background:#eef1f4}.comment-actions{margin-left:8px;display:inline-flex;gap:4px}.comment-dropped{opacity:.5}.comment-approved{border-left-color:var(--green)}.comment-edit,.add-comment textarea{width:100%;font:inherit;font-size:14px;padding:6px 8px;border:1px solid var(--border);border-radius:6px}.add-comment{border:1px dashed var(--border);border-radius:6px;padding:8px 12px;margin:8px 0;display:flex;flex-direction:column;gap:6px}.add-comment-row{display:flex;gap:8px;align-items:center}.add-comment select,.add-comment input{font:inherit;font-size:13px;padding:4px 6px;border:1px solid var(--border);border-radius:6px}.add-comment-btn{margin:8px 0}.verdict-override{margin-left:12px;font-size:13px;padding:2px 6px;border:1px solid var(--border);border-radius:6px}.send-panel{border:2px solid var(--accent);border-radius:8px;padding:12px 16px;margin:24px 0}.send-panel.sent{border-color:var(--green);background:#dafbe1}.send-panel h2{margin:0 0 4px}.send-controls{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.send-controls select{font:inherit;font-size:14px;padding:4px 8px;border:1px solid var(--border);border-radius:6px}.send-btn{background:var(--accent);color:#fff;border-color:var(--accent);font-size:14px;padding:6px 14px}.send-btn:hover{background:#0757b3}.send-btn-confirm{background:var(--red);border-color:var(--red)}.send-btn-confirm:hover{background:#a91e28}.confirm-group{display:inline-flex;gap:8px;align-items:center}.body-preview{background:#f6f8fa;border:1px solid var(--border);border-radius:6px;padding:12px;white-space:pre-wrap;font-size:13px;max-height:400px;overflow:auto}.export-link{font-size:13px}.post-actions{margin-top:16px}.diff .d2h-file-header{font-family:ui-monospace,SFMono-Regular,monospace}
1
+ :host,:root{--d2h-bg-color:#fff;--d2h-border-color:#ddd;--d2h-dim-color:rgba(0,0,0,.3);--d2h-line-border-color:#eee;--d2h-file-header-bg-color:#f7f7f7;--d2h-file-header-border-color:#d8d8d8;--d2h-empty-placeholder-bg-color:#f1f1f1;--d2h-empty-placeholder-border-color:#e1e1e1;--d2h-selected-color:#c8e1ff;--d2h-ins-bg-color:#dfd;--d2h-ins-border-color:#b4e2b4;--d2h-ins-highlight-bg-color:#97f295;--d2h-ins-label-color:#399839;--d2h-del-bg-color:#fee8e9;--d2h-del-border-color:#e9aeae;--d2h-del-highlight-bg-color:#ffb6ba;--d2h-del-label-color:#c33;--d2h-change-del-color:#fdf2d0;--d2h-change-ins-color:#ded;--d2h-info-bg-color:#f8fafd;--d2h-info-border-color:#d5e4f2;--d2h-change-label-color:#d0b44c;--d2h-moved-label-color:#3572b0;--d2h-dark-color:#e6edf3;--d2h-dark-bg-color:#0d1117;--d2h-dark-border-color:#30363d;--d2h-dark-dim-color:#6e7681;--d2h-dark-line-border-color:#21262d;--d2h-dark-file-header-bg-color:#161b22;--d2h-dark-file-header-border-color:#30363d;--d2h-dark-empty-placeholder-bg-color:hsla(215,8%,47%,.1);--d2h-dark-empty-placeholder-border-color:#30363d;--d2h-dark-selected-color:rgba(56,139,253,.1);--d2h-dark-ins-bg-color:rgba(46,160,67,.15);--d2h-dark-ins-border-color:rgba(46,160,67,.4);--d2h-dark-ins-highlight-bg-color:rgba(46,160,67,.4);--d2h-dark-ins-label-color:#3fb950;--d2h-dark-del-bg-color:rgba(248,81,73,.1);--d2h-dark-del-border-color:rgba(248,81,73,.4);--d2h-dark-del-highlight-bg-color:rgba(248,81,73,.4);--d2h-dark-del-label-color:#f85149;--d2h-dark-change-del-color:rgba(210,153,34,.2);--d2h-dark-change-ins-color:rgba(46,160,67,.25);--d2h-dark-info-bg-color:rgba(56,139,253,.1);--d2h-dark-info-border-color:rgba(56,139,253,.4);--d2h-dark-change-label-color:#d29922;--d2h-dark-moved-label-color:#3572b0}.d2h-wrapper{text-align:left}.d2h-file-header{background-color:#f7f7f7;background-color:var(--d2h-file-header-bg-color);border-bottom:1px solid #d8d8d8;border-bottom:1px solid var(--d2h-file-header-border-color);display:-webkit-box;display:-ms-flexbox;display:flex;font-family:Source Sans Pro,Helvetica Neue,Helvetica,Arial,sans-serif;height:35px;padding:5px 10px}.d2h-file-header.d2h-sticky-header{position:sticky;top:0;z-index:1}.d2h-file-stats{display:-webkit-box;display:-ms-flexbox;display:flex;font-size:14px;margin-left:auto}.d2h-lines-added{border:1px solid #b4e2b4;border:1px solid var(--d2h-ins-border-color);border-radius:5px 0 0 5px;color:#399839;color:var(--d2h-ins-label-color);padding:2px;text-align:right;vertical-align:middle}.d2h-lines-deleted{border:1px solid #e9aeae;border:1px solid var(--d2h-del-border-color);border-radius:0 5px 5px 0;color:#c33;color:var(--d2h-del-label-color);margin-left:1px;padding:2px;text-align:left;vertical-align:middle}.d2h-file-name-wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;font-size:15px;width:100%}.d2h-file-name{overflow-x:hidden;text-overflow:ellipsis;white-space:nowrap}.d2h-file-wrapper{border:1px solid #ddd;border:1px solid var(--d2h-border-color);border-radius:3px;margin-bottom:1em}.d2h-file-collapse{-webkit-box-pack:end;-ms-flex-pack:end;cursor:pointer;display:none;font-size:12px;justify-content:flex-end;-webkit-box-align:center;-ms-flex-align:center;align-items:center;border:1px solid #ddd;border:1px solid var(--d2h-border-color);border-radius:3px;padding:4px 8px}.d2h-file-collapse.d2h-selected{background-color:#c8e1ff;background-color:var(--d2h-selected-color)}.d2h-file-collapse-input{margin:0 4px 0 0}.d2h-diff-table{border-collapse:collapse;font-family:Menlo,Consolas,monospace;font-size:13px;width:100%}.d2h-files-diff{display:-webkit-box;display:-ms-flexbox;display:flex;width:100%}.d2h-file-diff{overflow-y:hidden}.d2h-file-diff.d2h-d-none,.d2h-files-diff.d2h-d-none{display:none}.d2h-file-side-diff{display:inline-block;overflow-x:scroll;overflow-y:hidden;width:50%}.d2h-code-line{padding:0 8em;width:calc(100% - 16em)}.d2h-code-line,.d2h-code-side-line{display:inline-block;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;white-space:nowrap}.d2h-code-side-line{padding:0 4.5em;width:calc(100% - 9em)}.d2h-code-line-ctn{background:none;display:inline-block;padding:0;word-wrap:normal;-webkit-user-select:text;-moz-user-select:text;-ms-user-select:text;user-select:text;vertical-align:middle;white-space:pre;width:100%}.d2h-code-line del,.d2h-code-side-line del{background-color:#ffb6ba;background-color:var(--d2h-del-highlight-bg-color)}.d2h-code-line del,.d2h-code-line ins,.d2h-code-side-line del,.d2h-code-side-line ins{border-radius:.2em;display:inline-block;margin-top:-1px;-webkit-text-decoration:none;text-decoration:none}.d2h-code-line ins,.d2h-code-side-line ins{background-color:#97f295;background-color:var(--d2h-ins-highlight-bg-color);text-align:left}.d2h-code-line-prefix{background:none;display:inline;padding:0;word-wrap:normal;white-space:pre}.line-num1{float:left}.line-num1,.line-num2{-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden;padding:0 .5em;text-overflow:ellipsis;width:3.5em}.line-num2{float:right}.d2h-code-linenumber{background-color:#fff;background-color:var(--d2h-bg-color);border:solid #eee;border:solid var(--d2h-line-border-color);border-width:0 1px;-webkit-box-sizing:border-box;box-sizing:border-box;color:#0000004d;color:var(--d2h-dim-color);cursor:pointer;display:inline-block;position:absolute;text-align:right;width:7.5em}.d2h-code-linenumber:after{content:"​"}.d2h-code-side-linenumber{background-color:#fff;background-color:var(--d2h-bg-color);border:solid #eee;border:solid var(--d2h-line-border-color);border-width:0 1px;-webkit-box-sizing:border-box;box-sizing:border-box;color:#0000004d;color:var(--d2h-dim-color);cursor:pointer;display:inline-block;overflow:hidden;padding:0 .5em;position:absolute;text-align:right;text-overflow:ellipsis;width:4em}.d2h-code-side-linenumber:after{content:"​"}.d2h-code-side-emptyplaceholder,.d2h-emptyplaceholder{background-color:#f1f1f1;background-color:var(--d2h-empty-placeholder-bg-color);border-color:#e1e1e1;border-color:var(--d2h-empty-placeholder-border-color)}.d2h-code-line-prefix,.d2h-code-linenumber,.d2h-code-side-linenumber,.d2h-emptyplaceholder{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.d2h-code-linenumber,.d2h-code-side-linenumber{direction:rtl}.d2h-del{background-color:#fee8e9;background-color:var(--d2h-del-bg-color);border-color:#e9aeae;border-color:var(--d2h-del-border-color)}.d2h-ins{background-color:#dfd;background-color:var(--d2h-ins-bg-color);border-color:#b4e2b4;border-color:var(--d2h-ins-border-color)}.d2h-info{background-color:#f8fafd;background-color:var(--d2h-info-bg-color);border-color:#d5e4f2;border-color:var(--d2h-info-border-color);color:#0000004d;color:var(--d2h-dim-color)}.d2h-file-diff .d2h-del.d2h-change{background-color:#fdf2d0;background-color:var(--d2h-change-del-color)}.d2h-file-diff .d2h-ins.d2h-change{background-color:#ded;background-color:var(--d2h-change-ins-color)}.d2h-file-list-wrapper{margin-bottom:10px}.d2h-file-list-wrapper a{-webkit-text-decoration:none;text-decoration:none}.d2h-file-list-wrapper a,.d2h-file-list-wrapper a:visited{color:#3572b0;color:var(--d2h-moved-label-color)}.d2h-file-list-header{text-align:left}.d2h-file-list-title{font-weight:700}.d2h-file-list-line{display:-webkit-box;display:-ms-flexbox;display:flex;text-align:left}.d2h-file-list{display:block;list-style:none;margin:0;padding:0}.d2h-file-list>li{border-bottom:1px solid #ddd;border-bottom:1px solid var(--d2h-border-color);margin:0;padding:5px 10px}.d2h-file-list>li:last-child{border-bottom:none}.d2h-file-switch{cursor:pointer;display:none;font-size:10px}.d2h-icon{fill:currentColor;margin-right:10px;vertical-align:middle}.d2h-deleted{color:#c33;color:var(--d2h-del-label-color)}.d2h-added{color:#399839;color:var(--d2h-ins-label-color)}.d2h-changed{color:#d0b44c;color:var(--d2h-change-label-color)}.d2h-moved{color:#3572b0;color:var(--d2h-moved-label-color)}.d2h-tag{background-color:#fff;background-color:var(--d2h-bg-color);display:-webkit-box;display:-ms-flexbox;display:flex;font-size:10px;margin-left:5px;padding:0 2px}.d2h-deleted-tag{border:1px solid #c33;border:1px solid var(--d2h-del-label-color)}.d2h-added-tag{border:1px solid #399839;border:1px solid var(--d2h-ins-label-color)}.d2h-changed-tag{border:1px solid #d0b44c;border:1px solid var(--d2h-change-label-color)}.d2h-moved-tag{border:1px solid #3572b0;border:1px solid var(--d2h-moved-label-color)}.d2h-dark-color-scheme{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);color:#e6edf3;color:var(--d2h-dark-color)}.d2h-dark-color-scheme .d2h-file-header{background-color:#161b22;background-color:var(--d2h-dark-file-header-bg-color);border-bottom:#30363d;border-bottom:var(--d2h-dark-file-header-border-color)}.d2h-dark-color-scheme .d2h-lines-added{border:1px solid rgba(46,160,67,.4);border:1px solid var(--d2h-dark-ins-border-color);color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-dark-color-scheme .d2h-lines-deleted{border:1px solid rgba(248,81,73,.4);border:1px solid var(--d2h-dark-del-border-color);color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-dark-color-scheme .d2h-code-line del,.d2h-dark-color-scheme .d2h-code-side-line del{background-color:#f8514966;background-color:var(--d2h-dark-del-highlight-bg-color)}.d2h-dark-color-scheme .d2h-code-line ins,.d2h-dark-color-scheme .d2h-code-side-line ins{background-color:#2ea04366;background-color:var(--d2h-dark-ins-highlight-bg-color)}.d2h-dark-color-scheme .d2h-diff-tbody{border-color:#30363d;border-color:var(--d2h-dark-border-color)}.d2h-dark-color-scheme .d2h-code-side-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-dark-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder,.d2h-dark-color-scheme .d2h-files-diff .d2h-emptyplaceholder{background-color:#6e76811a;background-color:var(--d2h-dark-empty-placeholder-bg-color);border-color:#30363d;border-color:var(--d2h-dark-empty-placeholder-border-color)}.d2h-dark-color-scheme .d2h-code-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-dark-color-scheme .d2h-del{background-color:#f851491a;background-color:var(--d2h-dark-del-bg-color);border-color:#f8514966;border-color:var(--d2h-dark-del-border-color)}.d2h-dark-color-scheme .d2h-ins{background-color:#2ea04326;background-color:var(--d2h-dark-ins-bg-color);border-color:#2ea04366;border-color:var(--d2h-dark-ins-border-color)}.d2h-dark-color-scheme .d2h-info{background-color:#388bfd1a;background-color:var(--d2h-dark-info-bg-color);border-color:#388bfd66;border-color:var(--d2h-dark-info-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-dark-color-scheme .d2h-file-diff .d2h-del.d2h-change{background-color:#d2992233;background-color:var(--d2h-dark-change-del-color)}.d2h-dark-color-scheme .d2h-file-diff .d2h-ins.d2h-change{background-color:#2ea04340;background-color:var(--d2h-dark-change-ins-color)}.d2h-dark-color-scheme .d2h-file-wrapper{border:1px solid #30363d;border:1px solid var(--d2h-dark-border-color)}.d2h-dark-color-scheme .d2h-file-collapse{border:1px solid #0d1117;border:1px solid var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-file-collapse.d2h-selected{background-color:#388bfd1a;background-color:var(--d2h-dark-selected-color)}.d2h-dark-color-scheme .d2h-file-list-wrapper a,.d2h-dark-color-scheme .d2h-file-list-wrapper a:visited{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-dark-color-scheme .d2h-file-list>li{border-bottom:1px solid #0d1117;border-bottom:1px solid var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-deleted{color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-dark-color-scheme .d2h-added{color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-dark-color-scheme .d2h-changed{color:#d29922;color:var(--d2h-dark-change-label-color)}.d2h-dark-color-scheme .d2h-moved{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-dark-color-scheme .d2h-tag{background-color:#0d1117;background-color:var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-deleted-tag{border:1px solid #f85149;border:1px solid var(--d2h-dark-del-label-color)}.d2h-dark-color-scheme .d2h-added-tag{border:1px solid #3fb950;border:1px solid var(--d2h-dark-ins-label-color)}.d2h-dark-color-scheme .d2h-changed-tag{border:1px solid #d29922;border:1px solid var(--d2h-dark-change-label-color)}.d2h-dark-color-scheme .d2h-moved-tag{border:1px solid #3572b0;border:1px solid var(--d2h-dark-moved-label-color)}@media(prefers-color-scheme:dark){.d2h-auto-color-scheme{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);color:#e6edf3;color:var(--d2h-dark-color)}.d2h-auto-color-scheme .d2h-file-header{background-color:#161b22;background-color:var(--d2h-dark-file-header-bg-color);border-bottom:#30363d;border-bottom:var(--d2h-dark-file-header-border-color)}.d2h-auto-color-scheme .d2h-lines-added{border:1px solid rgba(46,160,67,.4);border:1px solid var(--d2h-dark-ins-border-color);color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-auto-color-scheme .d2h-lines-deleted{border:1px solid rgba(248,81,73,.4);border:1px solid var(--d2h-dark-del-border-color);color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-auto-color-scheme .d2h-code-line del,.d2h-auto-color-scheme .d2h-code-side-line del{background-color:#f8514966;background-color:var(--d2h-dark-del-highlight-bg-color)}.d2h-auto-color-scheme .d2h-code-line ins,.d2h-auto-color-scheme .d2h-code-side-line ins{background-color:#2ea04366;background-color:var(--d2h-dark-ins-highlight-bg-color)}.d2h-auto-color-scheme .d2h-diff-tbody{border-color:#30363d;border-color:var(--d2h-dark-border-color)}.d2h-auto-color-scheme .d2h-code-side-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-auto-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder,.d2h-auto-color-scheme .d2h-files-diff .d2h-emptyplaceholder{background-color:#6e76811a;background-color:var(--d2h-dark-empty-placeholder-bg-color);border-color:#30363d;border-color:var(--d2h-dark-empty-placeholder-border-color)}.d2h-auto-color-scheme .d2h-code-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-auto-color-scheme .d2h-del{background-color:#f851491a;background-color:var(--d2h-dark-del-bg-color);border-color:#f8514966;border-color:var(--d2h-dark-del-border-color)}.d2h-auto-color-scheme .d2h-ins{background-color:#2ea04326;background-color:var(--d2h-dark-ins-bg-color);border-color:#2ea04366;border-color:var(--d2h-dark-ins-border-color)}.d2h-auto-color-scheme .d2h-info{background-color:#388bfd1a;background-color:var(--d2h-dark-info-bg-color);border-color:#388bfd66;border-color:var(--d2h-dark-info-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-auto-color-scheme .d2h-file-diff .d2h-del.d2h-change{background-color:#d2992233;background-color:var(--d2h-dark-change-del-color)}.d2h-auto-color-scheme .d2h-file-diff .d2h-ins.d2h-change{background-color:#2ea04340;background-color:var(--d2h-dark-change-ins-color)}.d2h-auto-color-scheme .d2h-file-wrapper{border:1px solid #30363d;border:1px solid var(--d2h-dark-border-color)}.d2h-auto-color-scheme .d2h-file-collapse{border:1px solid #0d1117;border:1px solid var(--d2h-dark-bg-color)}.d2h-auto-color-scheme .d2h-file-collapse.d2h-selected{background-color:#388bfd1a;background-color:var(--d2h-dark-selected-color)}.d2h-auto-color-scheme .d2h-file-list-wrapper a,.d2h-auto-color-scheme .d2h-file-list-wrapper a:visited{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-auto-color-scheme .d2h-file-list>li{border-bottom:1px solid #0d1117;border-bottom:1px solid var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-deleted{color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-auto-color-scheme .d2h-added{color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-auto-color-scheme .d2h-changed{color:#d29922;color:var(--d2h-dark-change-label-color)}.d2h-auto-color-scheme .d2h-moved{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-auto-color-scheme .d2h-tag{background-color:#0d1117;background-color:var(--d2h-dark-bg-color)}.d2h-auto-color-scheme .d2h-deleted-tag{border:1px solid #f85149;border:1px solid var(--d2h-dark-del-label-color)}.d2h-auto-color-scheme .d2h-added-tag{border:1px solid #3fb950;border:1px solid var(--d2h-dark-ins-label-color)}.d2h-auto-color-scheme .d2h-changed-tag{border:1px solid #d29922;border:1px solid var(--d2h-dark-change-label-color)}.d2h-auto-color-scheme .d2h-moved-tag{border:1px solid #3572b0;border:1px solid var(--d2h-dark-moved-label-color)}}:root{--bg: #ffffff;--fg: #1f2328;--muted: #656d76;--border: #d0d7de;--green: #1a7f37;--red: #cf222e;--yellow: #9a6700;--accent: #0969da}*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;color:var(--fg);background:var(--bg)}.app{max-width:1100px;margin:0 auto;padding:0 16px 64px}.topbar{display:flex;align-items:baseline;gap:12px;padding:16px 0;border-bottom:1px solid var(--border);margin-bottom:16px}.brand{font-size:20px;font-weight:700;text-decoration:none;color:var(--fg)}.tagline{color:var(--muted);font-size:13px}a{color:var(--accent)}.muted{color:var(--muted);font-size:.92em}.error{color:var(--red);background:#ffebe9;border:1px solid rgba(207,34,46,.3);padding:8px 12px;border-radius:6px}.empty{text-align:center;color:var(--muted);margin-top:64px}.empty pre{display:inline-block;background:#f6f8fa;padding:8px 16px;border-radius:6px}.md{line-height:1.55}.md>:first-child{margin-top:0}.md>:last-child{margin-bottom:0}.md p{margin:8px 0}.md code{font-family:ui-monospace,SFMono-Regular,monospace;font-size:.9em;background:#f0f2f5;padding:1px 5px;border-radius:4px}.md pre{background:#f6f8fa;border:1px solid var(--border);border-radius:6px;padding:10px 12px;overflow-x:auto}.md pre code{background:none;padding:0;font-size:12px}.md ul,.md ol{margin:8px 0;padding-left:24px}.md blockquote{margin:8px 0;padding-left:12px;border-left:3px solid var(--border);color:var(--muted)}.md h1,.md h2,.md h3{font-size:1.05em;margin:12px 0 4px}.daemon-strip{background:#ddf4ff;border:1px solid rgba(9,105,218,.3);border-radius:6px;padding:6px 12px;margin-bottom:12px;font-size:13px;display:flex;gap:12px;flex-wrap:wrap}.queue-stats{display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap}.queue{width:100%;border-collapse:collapse;font-size:14px}.queue th{text-align:left;color:var(--muted);font-weight:600;padding:6px 8px;border-bottom:1px solid var(--border)}.queue td{padding:8px;border-bottom:1px solid var(--border);vertical-align:top}.pr-link{font-weight:600;text-decoration:none}.status{font-size:12px;padding:2px 8px;border-radius:999px;border:1px solid var(--border);white-space:nowrap}.status-ready{background:#dafbe1;border-color:#1a7f3766}.status-running{background:#ddf4ff;border-color:#0969da66}.status-failed{background:#ffebe9;border-color:#cf222e66}.badge{font-size:12px;padding:2px 8px;border-radius:999px;white-space:nowrap}.badge-green{background:#dafbe1;color:var(--green)}.badge-yellow{background:#fff8c5;color:var(--yellow)}.badge-red{background:#ffebe9;color:var(--red)}.badge-muted{color:var(--muted)}.add{color:var(--green)}.del{color:var(--red)}.back{display:inline-block;margin:8px 0;text-decoration:none}.detail h1{font-size:22px;margin:4px 0}.verdict{border:1px solid var(--border);border-left-width:4px;border-radius:6px;padding:8px 12px;margin:12px 0}.verdict-approve{border-left-color:var(--green)}.verdict-comment{border-left-color:var(--yellow)}.verdict-request_changes{border-left-color:var(--red)}.verdict p{margin:4px 0 0;color:var(--muted);font-size:14px}.summary-body{white-space:pre-wrap;line-height:1.55}.chapter{border:1px solid var(--border);border-radius:6px;margin:12px 0;padding:4px 16px 12px}.chapter-title{cursor:pointer;-webkit-user-select:none;user-select:none;font-size:16px}.chapter-explanation{white-space:pre-wrap;line-height:1.5}.comment{border:1px solid var(--border);border-left:4px solid var(--yellow);border-radius:6px;padding:8px 12px;margin:8px 0;font-size:14px}.comment-loc{font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;color:var(--muted);margin-bottom:4px}.comment-body{white-space:pre-wrap;line-height:1.5}.diff{margin-top:8px;font-size:12px}button{font:inherit;font-size:12px;padding:2px 10px;border:1px solid var(--border);border-radius:6px;background:#f6f8fa;cursor:pointer}button:hover{background:#eef1f4}.comment-actions{margin-left:8px;display:inline-flex;gap:4px}.comment-dropped{opacity:.5}.comment-approved{border-left-color:var(--green)}.comment-edit,.add-comment textarea{width:100%;font:inherit;font-size:14px;padding:6px 8px;border:1px solid var(--border);border-radius:6px}.add-comment{border:1px dashed var(--border);border-radius:6px;padding:8px 12px;margin:8px 0;display:flex;flex-direction:column;gap:6px}.add-comment-row{display:flex;gap:8px;align-items:center}.add-comment select,.add-comment input{font:inherit;font-size:13px;padding:4px 6px;border:1px solid var(--border);border-radius:6px}.add-comment-btn{margin:8px 0}.verdict-override{margin-left:12px;font-size:13px;padding:2px 6px;border:1px solid var(--border);border-radius:6px}.send-panel{border:2px solid var(--accent);border-radius:8px;padding:12px 16px;margin:24px 0}.send-panel.sent{border-color:var(--green);background:#dafbe1}.send-panel h2{margin:0 0 4px}.send-controls{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.send-controls select{font:inherit;font-size:14px;padding:4px 8px;border:1px solid var(--border);border-radius:6px}.send-btn{background:var(--accent);color:#fff;border-color:var(--accent);font-size:14px;padding:6px 14px}.send-btn:hover{background:#0757b3}.send-btn-confirm{background:var(--red);border-color:var(--red)}.send-btn-confirm:hover{background:#a91e28}.confirm-group{display:inline-flex;gap:8px;align-items:center}.body-preview{background:#f6f8fa;border:1px solid var(--border);border-radius:6px;padding:12px;white-space:pre-wrap;font-size:13px;max-height:400px;overflow:auto}.export-link{font-size:13px}.post-actions{margin-top:16px}.diff .d2h-file-header{font-family:ui-monospace,SFMono-Regular,monospace}