@fullstackhouse/cerber 0.3.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.
@@ -0,0 +1,41 @@
1
+ import { newSideLines } from "./diff.js";
2
+ export function eventForRecommendation(rec) {
3
+ return { approve: "APPROVE", comment: "COMMENT", request_changes: "REQUEST_CHANGES" }[rec];
4
+ }
5
+ /**
6
+ * Build the GitHub review payload from an artifact.
7
+ * Includes every comment that is not dropped. Comments whose line is not part
8
+ * of the diff (GitHub would reject them) are folded into the review body.
9
+ */
10
+ export function buildReviewPayload(artifact, event) {
11
+ const active = artifact.comments.filter((c) => c.status !== "dropped");
12
+ const anchorable = newSideLines(artifact.diff);
13
+ const inline = [];
14
+ const folded = [];
15
+ for (const c of active) {
16
+ if (c.line != null && anchorable.get(c.path)?.has(c.line)) {
17
+ inline.push({ path: c.path, line: c.line, side: "RIGHT", body: c.body });
18
+ }
19
+ else {
20
+ folded.push(c);
21
+ }
22
+ }
23
+ const parts = [];
24
+ if (artifact.summary) {
25
+ parts.push("## Summary", "", artifact.summary);
26
+ }
27
+ if (artifact.chapters.length > 0) {
28
+ parts.push("", "## Walkthrough", "");
29
+ for (const ch of artifact.chapters) {
30
+ parts.push(`**${ch.title}**`, "", ch.explanation, "");
31
+ }
32
+ }
33
+ if (folded.length > 0) {
34
+ parts.push("", "## Additional notes", "");
35
+ for (const c of folded) {
36
+ parts.push(`- \`${c.path}${c.line != null ? `:${c.line}` : ""}\` — ${c.body}`);
37
+ }
38
+ }
39
+ parts.push("", "---", "_Reviewed with [cerber](https://github.com/fullstackhouse/cerber) 🐕 — drafted by AI, sent by a human._");
40
+ return { event, body: parts.join("\n").trim(), comments: inline, folded };
41
+ }
@@ -0,0 +1,77 @@
1
+ import { promises as fs } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { ArtifactSchema, artifactKey } from "./artifact.js";
5
+ export function cerberHome() {
6
+ return process.env.CERBER_HOME ?? path.join(os.homedir(), ".cerber");
7
+ }
8
+ function reviewsDir() {
9
+ return path.join(cerberHome(), "reviews");
10
+ }
11
+ function artifactPath(id) {
12
+ return path.join(reviewsDir(), `${artifactKey(id)}.json`);
13
+ }
14
+ export async function saveArtifact(artifact) {
15
+ await fs.mkdir(reviewsDir(), { recursive: true });
16
+ const file = artifactPath(artifact.id);
17
+ const tmp = `${file}.tmp`;
18
+ await fs.writeFile(tmp, JSON.stringify(artifact, null, 2));
19
+ await fs.rename(tmp, file);
20
+ return file;
21
+ }
22
+ export async function loadArtifact(id) {
23
+ try {
24
+ const raw = await fs.readFile(artifactPath(id), "utf8");
25
+ return ArtifactSchema.parse(JSON.parse(raw));
26
+ }
27
+ catch (err) {
28
+ if (err.code === "ENOENT")
29
+ return null;
30
+ throw err;
31
+ }
32
+ }
33
+ export async function loadArtifactByKey(key) {
34
+ try {
35
+ const raw = await fs.readFile(path.join(reviewsDir(), `${key}.json`), "utf8");
36
+ return ArtifactSchema.parse(JSON.parse(raw));
37
+ }
38
+ catch (err) {
39
+ if (err.code === "ENOENT")
40
+ return null;
41
+ throw err;
42
+ }
43
+ }
44
+ /** Load by key, apply a mutation, bump updatedAt, save. Returns the new artifact. */
45
+ export async function updateArtifactByKey(key, mutate) {
46
+ const artifact = await loadArtifactByKey(key);
47
+ if (!artifact)
48
+ return null;
49
+ const updated = { ...mutate(artifact), updatedAt: new Date().toISOString() };
50
+ await saveArtifact(updated);
51
+ return updated;
52
+ }
53
+ export async function listArtifacts() {
54
+ let entries;
55
+ try {
56
+ entries = await fs.readdir(reviewsDir());
57
+ }
58
+ catch (err) {
59
+ if (err.code === "ENOENT")
60
+ return [];
61
+ throw err;
62
+ }
63
+ const artifacts = [];
64
+ for (const entry of entries) {
65
+ if (!entry.endsWith(".json"))
66
+ continue;
67
+ try {
68
+ const raw = await fs.readFile(path.join(reviewsDir(), entry), "utf8");
69
+ artifacts.push(ArtifactSchema.parse(JSON.parse(raw)));
70
+ }
71
+ catch {
72
+ // Skip corrupt files rather than breaking the whole listing.
73
+ }
74
+ }
75
+ artifacts.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
76
+ return artifacts;
77
+ }
@@ -0,0 +1,65 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * Run `claude -p` headless with the prompt on stdin, return the result text.
4
+ * Rides the user's existing Claude Code login — no API key handling here.
5
+ */
6
+ export function runClaude(prompt, opts = {}) {
7
+ const args = ["-p", "--output-format", "json"];
8
+ if (opts.model)
9
+ args.push("--model", opts.model);
10
+ return new Promise((resolve, reject) => {
11
+ const child = spawn(opts.bin ?? "claude", args, {
12
+ stdio: ["pipe", "pipe", "pipe"],
13
+ });
14
+ let stdout = "";
15
+ let stderr = "";
16
+ child.stdout.on("data", (d) => (stdout += d));
17
+ child.stderr.on("data", (d) => (stderr += d));
18
+ child.on("error", (err) => reject(new Error(`Failed to start claude CLI: ${err.message}. Is Claude Code installed and on PATH?`)));
19
+ child.on("close", (code) => {
20
+ if (code !== 0) {
21
+ reject(new Error(`claude exited with code ${code}: ${stderr.trim() || stdout.slice(0, 500)}`));
22
+ return;
23
+ }
24
+ try {
25
+ const wrapper = JSON.parse(stdout);
26
+ resolve({
27
+ text: wrapper.result ?? "",
28
+ costUsd: typeof wrapper.total_cost_usd === "number" ? wrapper.total_cost_usd : null,
29
+ model: wrapper.modelUsage ? Object.keys(wrapper.modelUsage)[0] ?? null : null,
30
+ });
31
+ }
32
+ catch {
33
+ reject(new Error(`Could not parse claude output as JSON wrapper: ${stdout.slice(0, 500)}`));
34
+ }
35
+ });
36
+ child.stdin.write(prompt);
37
+ child.stdin.end();
38
+ });
39
+ }
40
+ /** Extract a JSON object from model output that may be wrapped in fences or prose. */
41
+ export function extractJson(text) {
42
+ const trimmed = text.trim();
43
+ // Fast path: it's already pure JSON.
44
+ try {
45
+ return JSON.parse(trimmed);
46
+ }
47
+ catch {
48
+ // fall through
49
+ }
50
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
51
+ if (fenced) {
52
+ try {
53
+ return JSON.parse(fenced[1].trim());
54
+ }
55
+ catch {
56
+ // fall through
57
+ }
58
+ }
59
+ const first = trimmed.indexOf("{");
60
+ const last = trimmed.lastIndexOf("}");
61
+ if (first !== -1 && last > first) {
62
+ return JSON.parse(trimmed.slice(first, last + 1));
63
+ }
64
+ throw new Error("No JSON object found in model output");
65
+ }
@@ -0,0 +1,71 @@
1
+ /** Keep the prompt within a sane context budget. */
2
+ export const MAX_DIFF_CHARS = 300_000;
3
+ export function buildReviewPrompt(pr, diff) {
4
+ const truncated = diff.length > MAX_DIFF_CHARS;
5
+ const diffBody = truncated ? diff.slice(0, MAX_DIFF_CHARS) + "\n[... diff truncated ...]" : diff;
6
+ const prompt = `You are an expert senior code reviewer. Review the following GitHub pull request.
7
+
8
+ Respond with ONLY a JSON object (no markdown fences, no prose before or after) matching exactly this shape:
9
+
10
+ {
11
+ "summary": "markdown — what this PR actually does and why, written top-down: problem first, then the solution, then notable technical details. 1-3 paragraphs.",
12
+ "chapters": [
13
+ {
14
+ "id": "short-kebab-id",
15
+ "title": "Human title of this logical group of changes",
16
+ "explanation": "markdown — what this group changes and why, what to pay attention to",
17
+ "files": ["path/to/file.ts", "path/other.ts"]
18
+ }
19
+ ],
20
+ "comments": [
21
+ {
22
+ "path": "path/to/file.ts",
23
+ "line": 42,
24
+ "body": "markdown — the review comment. Concrete, actionable, kind.",
25
+ "chapterId": "short-kebab-id"
26
+ }
27
+ ],
28
+ "verdict": {
29
+ "recommendation": "approve" | "comment" | "request_changes",
30
+ "confidence": 0-100,
31
+ "reasoning": "why this recommendation, and what makes you more/less sure"
32
+ }
33
+ }
34
+
35
+ Rules:
36
+ - Group ALL changed files into chapters that tell the story of the PR (schema first, then logic, then tests, etc). Every changed file must appear in exactly one chapter.
37
+ - "line" is the line number in the NEW version of the file (the "+" side of the diff). Use null for file-level comments. Only reference lines that appear in the diff.
38
+ - Only write comments that are worth a human reading: real bugs, risky patterns, missing tests, unclear naming. No nitpicks a linter would catch. An empty comments array is a perfectly good review of a clean PR.
39
+ - "confidence" reflects how sure you are the recommendation is right given what you can see. You only see the diff, not the full codebase — factor that in.
40
+ - Never invent files or lines not present in the diff.
41
+
42
+ ## Pull request
43
+
44
+ Repo: ${pr.owner}/${pr.repo}
45
+ PR #${pr.number}: ${pr.title}
46
+ Author: ${pr.author}
47
+ Branch: ${pr.headRefName} -> ${pr.baseRefName}
48
+ Stats: ${pr.changedFiles} files, +${pr.additions} -${pr.deletions}
49
+
50
+ ### Description
51
+
52
+ ${pr.body || "(no description)"}
53
+
54
+ ### Diff
55
+
56
+ \`\`\`diff
57
+ ${diffBody}
58
+ \`\`\`
59
+ `;
60
+ return { prompt, truncated };
61
+ }
62
+ export function buildRetryPrompt(originalPrompt, badOutput, error) {
63
+ return `${originalPrompt}
64
+
65
+ ---
66
+ Your previous response could not be parsed as the required JSON.
67
+ Error: ${error}
68
+ Previous response (truncated): ${badOutput.slice(0, 2_000)}
69
+
70
+ Respond again with ONLY the valid JSON object described above.`;
71
+ }
@@ -0,0 +1,130 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { AiReviewSchema, SCHEMA_VERSION, artifactId, } from "../core/artifact.js";
3
+ import { fetchPrDiff, fetchPrInfo } from "../core/gh.js";
4
+ import { loadArtifact, saveArtifact } from "../core/state.js";
5
+ import { extractJson, runClaude } from "./claude.js";
6
+ import { buildReviewPrompt, buildRetryPrompt } from "./prompt.js";
7
+ /** Statuses that a fresh artifact can keep without a re-run. */
8
+ const FRESH_STATUSES = new Set(["ready", "reviewed", "sent", "skipped"]);
9
+ /**
10
+ * Fetch a PR, run the AI review, persist the artifact at each stage.
11
+ * Everything stays local — nothing is ever written to GitHub.
12
+ */
13
+ export async function reviewPr(ref, opts = {}) {
14
+ const log = opts.onProgress ?? (() => { });
15
+ const now = () => new Date().toISOString();
16
+ log(`Fetching ${ref.owner}/${ref.repo}#${ref.number}…`);
17
+ const pr = await fetchPrInfo(ref);
18
+ if (!opts.force) {
19
+ const existing = await loadArtifact(artifactId(pr));
20
+ if (existing &&
21
+ FRESH_STATUSES.has(existing.status) &&
22
+ existing.pr.headSha !== "" &&
23
+ existing.pr.headSha === pr.headSha) {
24
+ log(`Up to date (head ${pr.headSha.slice(0, 7)}, status ${existing.status}) — skipping. Use --force to re-review.`);
25
+ return { artifact: existing, skipped: true };
26
+ }
27
+ }
28
+ const diff = await fetchPrDiff(ref);
29
+ let artifact = {
30
+ schemaVersion: SCHEMA_VERSION,
31
+ id: artifactId(pr),
32
+ status: "running",
33
+ createdAt: now(),
34
+ updatedAt: now(),
35
+ pr,
36
+ diff,
37
+ summary: "",
38
+ chapters: [],
39
+ comments: [],
40
+ verdict: null,
41
+ run: { model: opts.model ?? null, startedAt: now(), finishedAt: null, costUsd: null, error: null },
42
+ sent: null,
43
+ };
44
+ await saveArtifact(artifact);
45
+ const { prompt, truncated } = buildReviewPrompt(pr, diff);
46
+ if (truncated)
47
+ log("Warning: diff exceeds the context budget and was truncated.");
48
+ log(`Reviewing with Claude${opts.model ? ` (${opts.model})` : ""}… this can take a few minutes.`);
49
+ try {
50
+ const review = await runAiReview(prompt, opts);
51
+ // The prompt demands each file in exactly one chapter, but models drift:
52
+ // keep a file only in the first chapter that claims it.
53
+ const seen = new Set();
54
+ const chapters = review.ai.chapters.map((ch) => {
55
+ const files = ch.files.filter((f) => !seen.has(f));
56
+ files.forEach((f) => seen.add(f));
57
+ return { ...ch, files };
58
+ });
59
+ artifact = {
60
+ ...artifact,
61
+ status: "ready",
62
+ updatedAt: now(),
63
+ summary: review.ai.summary,
64
+ chapters,
65
+ comments: review.ai.comments.map((c) => ({
66
+ id: randomUUID(),
67
+ path: c.path,
68
+ line: c.line,
69
+ body: c.body,
70
+ chapterId: c.chapterId ?? null,
71
+ origin: "ai",
72
+ status: "draft",
73
+ })),
74
+ verdict: review.ai.verdict,
75
+ run: {
76
+ model: review.model ?? opts.model ?? null,
77
+ startedAt: artifact.run.startedAt,
78
+ finishedAt: now(),
79
+ costUsd: review.costUsd,
80
+ error: null,
81
+ },
82
+ };
83
+ }
84
+ catch (err) {
85
+ artifact = {
86
+ ...artifact,
87
+ status: "failed",
88
+ updatedAt: now(),
89
+ run: {
90
+ ...artifact.run,
91
+ finishedAt: now(),
92
+ error: err instanceof Error ? err.message : String(err),
93
+ },
94
+ };
95
+ await saveArtifact(artifact);
96
+ throw err;
97
+ }
98
+ await saveArtifact(artifact);
99
+ return { artifact, skipped: false };
100
+ }
101
+ /** Run tasks with bounded concurrency, preserving order of results. */
102
+ export async function pool(items, limit, fn) {
103
+ const results = new Array(items.length);
104
+ let next = 0;
105
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
106
+ while (next < items.length) {
107
+ const idx = next++;
108
+ results[idx] = await fn(items[idx]);
109
+ }
110
+ });
111
+ await Promise.all(workers);
112
+ return results;
113
+ }
114
+ async function runAiReview(prompt, opts) {
115
+ const first = await runClaude(prompt, { model: opts.model });
116
+ try {
117
+ return { ai: AiReviewSchema.parse(extractJson(first.text)), costUsd: first.costUsd, model: first.model };
118
+ }
119
+ catch (err) {
120
+ const message = err instanceof Error ? err.message : String(err);
121
+ opts.onProgress?.("Model output failed validation — retrying once…");
122
+ const second = await runClaude(buildRetryPrompt(prompt, first.text, message), { model: opts.model });
123
+ const cost = (first.costUsd ?? 0) + (second.costUsd ?? 0);
124
+ return {
125
+ ai: AiReviewSchema.parse(extractJson(second.text)),
126
+ costUsd: cost > 0 ? cost : null,
127
+ model: second.model,
128
+ };
129
+ }
130
+ }
@@ -0,0 +1,178 @@
1
+ import { serve } from "@hono/node-server";
2
+ import { serveStatic } from "@hono/node-server/serve-static";
3
+ import { Hono } from "hono";
4
+ import { promises as fs } from "node:fs";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { randomUUID } from "node:crypto";
8
+ import { ArtifactStatusSchema, VerdictSchema, artifactKey } from "../core/artifact.js";
9
+ import { toMarkdown } from "../core/export.js";
10
+ import { submitReview } from "../core/gh.js";
11
+ import { buildReviewPayload } from "../core/send.js";
12
+ import { listArtifacts, loadArtifactByKey, updateArtifactByKey } from "../core/state.js";
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ export async function startServer(opts) {
15
+ const app = new Hono();
16
+ app.get("/api/reviews", async (c) => {
17
+ const artifacts = await listArtifacts();
18
+ return c.json(artifacts.map((a) => ({
19
+ id: a.id,
20
+ key: artifactKey(a.id),
21
+ status: a.status,
22
+ updatedAt: a.updatedAt,
23
+ pr: {
24
+ title: a.pr.title,
25
+ url: a.pr.url,
26
+ author: a.pr.author,
27
+ owner: a.pr.owner,
28
+ repo: a.pr.repo,
29
+ number: a.pr.number,
30
+ additions: a.pr.additions,
31
+ deletions: a.pr.deletions,
32
+ changedFiles: a.pr.changedFiles,
33
+ },
34
+ verdict: a.verdict,
35
+ commentCount: a.comments.filter((cm) => cm.status !== "dropped").length,
36
+ costUsd: a.run?.costUsd ?? null,
37
+ })));
38
+ });
39
+ app.get("/api/reviews/:key", async (c) => {
40
+ const artifact = await loadArtifactByKey(c.req.param("key"));
41
+ if (!artifact)
42
+ return c.json({ error: "not found" }, 404);
43
+ return c.json(artifact);
44
+ });
45
+ // ---- Mutations (all local — artifact edits only) ----
46
+ app.patch("/api/reviews/:key", async (c) => {
47
+ const body = await c.req.json();
48
+ const updated = await updateArtifactByKey(c.req.param("key"), (a) => {
49
+ const next = { ...a };
50
+ if (body.status !== undefined) {
51
+ next.status = ArtifactStatusSchema.parse(body.status);
52
+ }
53
+ if (body.verdictRecommendation !== undefined && next.verdict) {
54
+ next.verdict = {
55
+ ...next.verdict,
56
+ recommendation: VerdictSchema.shape.recommendation.parse(body.verdictRecommendation),
57
+ };
58
+ }
59
+ return next;
60
+ });
61
+ if (!updated)
62
+ return c.json({ error: "not found" }, 404);
63
+ return c.json(updated);
64
+ });
65
+ app.patch("/api/reviews/:key/comments/:id", async (c) => {
66
+ const body = await c.req.json();
67
+ const id = c.req.param("id");
68
+ const updated = await updateArtifactByKey(c.req.param("key"), (a) => ({
69
+ ...a,
70
+ comments: a.comments.map((cm) => cm.id === id
71
+ ? {
72
+ ...cm,
73
+ ...(body.body !== undefined ? { body: String(body.body) } : {}),
74
+ ...(body.status !== undefined
75
+ ? { status: body.status }
76
+ : {}),
77
+ }
78
+ : cm),
79
+ }));
80
+ if (!updated)
81
+ return c.json({ error: "not found" }, 404);
82
+ return c.json(updated);
83
+ });
84
+ app.post("/api/reviews/:key/comments", async (c) => {
85
+ const body = await c.req.json();
86
+ const updated = await updateArtifactByKey(c.req.param("key"), (a) => ({
87
+ ...a,
88
+ comments: [
89
+ ...a.comments,
90
+ {
91
+ id: randomUUID(),
92
+ path: String(body.path ?? ""),
93
+ line: body.line != null ? Number(body.line) : null,
94
+ body: String(body.body ?? ""),
95
+ chapterId: body.chapterId ?? null,
96
+ origin: "user",
97
+ status: "draft",
98
+ },
99
+ ],
100
+ }));
101
+ if (!updated)
102
+ return c.json({ error: "not found" }, 404);
103
+ return c.json(updated);
104
+ });
105
+ app.delete("/api/reviews/:key/comments/:id", async (c) => {
106
+ const id = c.req.param("id");
107
+ const updated = await updateArtifactByKey(c.req.param("key"), (a) => ({
108
+ ...a,
109
+ comments: a.comments.filter((cm) => cm.id !== id),
110
+ }));
111
+ if (!updated)
112
+ return c.json({ error: "not found" }, 404);
113
+ return c.json(updated);
114
+ });
115
+ // ---- Export (local file download) ----
116
+ app.get("/api/reviews/:key/export", async (c) => {
117
+ const artifact = await loadArtifactByKey(c.req.param("key"));
118
+ if (!artifact)
119
+ return c.json({ error: "not found" }, 404);
120
+ c.header("Content-Type", "text/markdown; charset=utf-8");
121
+ c.header("Content-Disposition", `attachment; filename="review-${artifactKey(artifact.id)}.md"`);
122
+ return c.body(toMarkdown(artifact));
123
+ });
124
+ // ---- Send: THE ONLY GITHUB WRITE. Explicit user action from the cockpit. ----
125
+ app.post("/api/reviews/:key/send", async (c) => {
126
+ const { event, confirm } = await c.req.json();
127
+ if (confirm !== true) {
128
+ return c.json({ error: "send requires an explicit confirm: true" }, 400);
129
+ }
130
+ if (!["APPROVE", "COMMENT", "REQUEST_CHANGES"].includes(event)) {
131
+ return c.json({ error: `invalid event: ${event}` }, 400);
132
+ }
133
+ const artifact = await loadArtifactByKey(c.req.param("key"));
134
+ if (!artifact)
135
+ return c.json({ error: "not found" }, 404);
136
+ if (artifact.sent) {
137
+ return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
138
+ }
139
+ const payload = buildReviewPayload(artifact, event);
140
+ try {
141
+ 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 });
142
+ const updated = await updateArtifactByKey(c.req.param("key"), (a) => ({
143
+ ...a,
144
+ status: "sent",
145
+ sent: { at: new Date().toISOString(), event: payload.event, url },
146
+ }));
147
+ return c.json(updated);
148
+ }
149
+ catch (err) {
150
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 502);
151
+ }
152
+ });
153
+ // Preview what send would post, without posting anything.
154
+ app.get("/api/reviews/:key/send-preview", async (c) => {
155
+ const artifact = await loadArtifactByKey(c.req.param("key"));
156
+ if (!artifact)
157
+ return c.json({ error: "not found" }, 404);
158
+ const event = (c.req.query("event") ?? "COMMENT");
159
+ return c.json(buildReviewPayload(artifact, event));
160
+ });
161
+ // Static cockpit build. In the published package web/dist ships alongside dist/.
162
+ // __dirname is src/server (dev via tsx) or dist/server (built) — both are two levels below the repo root.
163
+ const webDist = path.resolve(__dirname, "../../web/dist");
164
+ const hasWebBuild = await fs
165
+ .access(path.join(webDist, "index.html"))
166
+ .then(() => true)
167
+ .catch(() => false);
168
+ if (hasWebBuild) {
169
+ app.use("/*", serveStatic({ root: path.relative(process.cwd(), webDist) }));
170
+ app.get("*", serveStatic({ path: path.relative(process.cwd(), path.join(webDist, "index.html")) }));
171
+ }
172
+ else {
173
+ 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
+ }
175
+ serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
176
+ console.log(`cerber cockpit: http://${opts.host}:${info.port}`);
177
+ });
178
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@fullstackhouse/cerber",
3
+ "version": "0.3.0",
4
+ "description": "AI code-review cockpit: Claude reviews your PRs into local artifacts; nothing reaches GitHub until you say so.",
5
+ "license": "MIT",
6
+ "repository": "github:fullstackhouse/cerber",
7
+ "type": "module",
8
+ "bin": {
9
+ "cerber": "dist/cli/index.js"
10
+ },
11
+ "engines": {
12
+ "node": ">=20"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "web/dist",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "dev": "tsx src/cli/index.ts",
22
+ "build": "tsc -p tsconfig.build.json && vite build --config web/vite.config.ts",
23
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p web/tsconfig.json --noEmit",
24
+ "test": "vitest run",
25
+ "prepublishOnly": "pnpm build"
26
+ },
27
+ "dependencies": {
28
+ "@hono/node-server": "^1.13.7",
29
+ "commander": "^13.1.0",
30
+ "diff2html": "^3.4.51",
31
+ "hono": "^4.6.16",
32
+ "zod": "^3.24.1"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^22.10.5",
36
+ "@types/react": "^19.0.4",
37
+ "@types/react-dom": "^19.0.2",
38
+ "@vitejs/plugin-react": "^4.3.4",
39
+ "react": "^19.0.0",
40
+ "react-dom": "^19.0.0",
41
+ "tsx": "^4.19.2",
42
+ "typescript": "^5.7.3",
43
+ "vite": "^6.0.7",
44
+ "vitest": "^2.1.8"
45
+ }
46
+ }
@@ -0,0 +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}