@fullstackhouse/cerber 0.12.1 → 0.14.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
@@ -78,9 +78,11 @@ Each review is a plain JSON artifact in `~/.cerber/reviews/` (override with
78
78
  - **Chapters** — the changed files grouped into a logical walkthrough, each
79
79
  with a title, an explanation, and its slice of the diff
80
80
  - **Draft comments** — inline, anchored to file/line, only things worth a
81
- human's time
81
+ human's time. Findings are graded **blocker / minor / nit** — the words mean
82
+ what they mean everywhere, and only a blocker stands between the PR and
83
+ approval; a question or note carries no grade at all
82
84
  - **Verdict** — approve / comment / request changes, with a 0–100 confidence
83
- score and reasoning
85
+ score and reasoning that names the worst finding still standing
84
86
  - **Run metadata** — model, whether it read the source or only the diff, and
85
87
  the token spend as an API-rate equivalent
86
88
 
@@ -1,5 +1,12 @@
1
1
  import { z } from "zod";
2
2
  export const SCHEMA_VERSION = 1;
3
+ /**
4
+ * How hard a finding presses on the merge. The words carry their own
5
+ * semantics: a blocker is the only tier that stops approval; minor is
6
+ * "should fix, author's call"; nit is taste. Absent (null) means the comment
7
+ * is not a finding at all — a question, a note, praise — not "unknown".
8
+ */
9
+ export const SeveritySchema = z.enum(["blocker", "minor", "nit"]);
3
10
  /** A draft inline comment. Never sent to GitHub unless the user explicitly sends the review. */
4
11
  export const CommentSchema = z.object({
5
12
  id: z.string(),
@@ -8,6 +15,8 @@ export const CommentSchema = z.object({
8
15
  line: z.number().int().positive().nullable(),
9
16
  body: z.string(),
10
17
  chapterId: z.string().nullable().default(null),
18
+ /** Merge impact of this finding; null for remarks that are not findings. */
19
+ severity: SeveritySchema.nullable().default(null),
11
20
  origin: z.enum(["ai", "user"]),
12
21
  status: z.enum(["draft", "approved", "dropped"]).default("draft"),
13
22
  /** Set when the user edits an AI comment's body — calibration signal. */
@@ -43,6 +52,9 @@ export const PrInfoSchema = z.object({
43
52
  headSha: z.string().default(""),
44
53
  /** OPEN | CLOSED | MERGED, as of the last fetch. */
45
54
  state: z.enum(["OPEN", "CLOSED", "MERGED"]).default("OPEN"),
55
+ /** GitHub reports a draft's `state` as OPEN, so draftness needs its own field.
56
+ * Defaulted: artifacts written before this existed still load. */
57
+ isDraft: z.boolean().default(false),
46
58
  additions: z.number().int().default(0),
47
59
  deletions: z.number().int().default(0),
48
60
  changedFiles: z.number().int().default(0),
@@ -111,7 +123,13 @@ export const RevisionSchema = z.discriminatedUnion("kind", [
111
123
  z.object({ kind: z.literal("summary"), body: z.string() }),
112
124
  z.object({ kind: z.literal("verdict"), verdict: VerdictSchema }),
113
125
  z.object({ kind: z.literal("chapter"), chapterId: z.string(), title: z.string().optional(), explanation: z.string().optional() }),
114
- z.object({ kind: z.literal("comment-edit"), commentId: z.string(), body: z.string() }),
126
+ z.object({
127
+ kind: z.literal("comment-edit"),
128
+ commentId: z.string(),
129
+ body: z.string(),
130
+ /** Re-grade while editing; omitted = grade unchanged, null = not a finding. */
131
+ severity: SeveritySchema.nullable().optional(),
132
+ }),
115
133
  z.object({ kind: z.literal("comment-drop"), commentId: z.string() }),
116
134
  z.object({
117
135
  kind: z.literal("comment-add"),
@@ -119,6 +137,7 @@ export const RevisionSchema = z.discriminatedUnion("kind", [
119
137
  line: z.number().int().positive().nullable(),
120
138
  body: z.string(),
121
139
  chapterId: z.string().nullable().default(null),
140
+ severity: SeveritySchema.nullable().default(null),
122
141
  }),
123
142
  ]);
124
143
  /** A revision the run declined to make, and why — shown in the transcript. */
@@ -217,6 +236,7 @@ export const AiReviewSchema = z.object({
217
236
  line: z.number().int().positive().nullable(),
218
237
  body: z.string(),
219
238
  chapterId: z.string().nullable().optional(),
239
+ severity: SeveritySchema.nullable().optional(),
220
240
  })),
221
241
  verdict: VerdictSchema,
222
242
  });
@@ -16,6 +16,15 @@ export function evaluateAutoSend(artifact, threshold) {
16
16
  reason: `recommendation is ${artifact.verdict.recommendation} — only approve auto-sends`,
17
17
  };
18
18
  }
19
+ // An approve verdict standing next to a live blocker finding contradicts
20
+ // itself; a contradiction is a human's to resolve, never auto-sent.
21
+ const blockers = artifact.comments.filter((c) => c.status !== "dropped" && c.severity === "blocker");
22
+ if (blockers.length > 0) {
23
+ return {
24
+ eligible: false,
25
+ reason: `verdict says approve but ${blockers.length} blocker finding(s) still stand — a human resolves that`,
26
+ };
27
+ }
19
28
  if (artifact.verdict.confidence < threshold) {
20
29
  return {
21
30
  eligible: false,
@@ -1,3 +1,7 @@
1
+ /** Same convention as Send: the grade travels in the text, ungraded stays bare. */
2
+ function grade(c) {
3
+ return c.severity ? `**${c.severity}:** ` : "";
4
+ }
1
5
  /** Render an artifact as a standalone markdown review document. */
2
6
  export function toMarkdown(artifact) {
3
7
  const lines = [];
@@ -29,7 +33,7 @@ export function toMarkdown(artifact) {
29
33
  lines.push("");
30
34
  const comments = artifact.comments.filter((c) => c.chapterId === ch.id && c.status !== "dropped");
31
35
  for (const c of comments) {
32
- lines.push(`> **${c.path}${c.line != null ? `:${c.line}` : ""}** — ${c.body.replace(/\n/g, "\n> ")}`);
36
+ lines.push(`> **${c.path}${c.line != null ? `:${c.line}` : ""}** — ${grade(c)}${c.body.replace(/\n/g, "\n> ")}`);
33
37
  lines.push("");
34
38
  }
35
39
  }
package/dist/core/gh.js CHANGED
@@ -45,7 +45,7 @@ export async function fetchPrInfo(ref) {
45
45
  "--repo",
46
46
  `${ref.owner}/${ref.repo}`,
47
47
  "--json",
48
- "title,url,author,body,baseRefName,headRefName,headRefOid,additions,deletions,changedFiles,state",
48
+ "title,url,author,body,baseRefName,headRefName,headRefOid,additions,deletions,changedFiles,state,isDraft",
49
49
  ]);
50
50
  const raw = JSON.parse(out);
51
51
  return PrInfoSchema.parse({
@@ -60,6 +60,7 @@ export async function fetchPrInfo(ref) {
60
60
  headRefName: raw.headRefName,
61
61
  headSha: raw.headRefOid ?? "",
62
62
  state: raw.state ?? "OPEN",
63
+ isDraft: raw.isDraft ?? false,
63
64
  additions: raw.additions ?? 0,
64
65
  deletions: raw.deletions ?? 0,
65
66
  changedFiles: raw.changedFiles ?? 0,
@@ -92,11 +93,18 @@ async function membershipCheck(args) {
92
93
  export async function fetchPrDiff(ref) {
93
94
  return gh(["pr", "diff", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`]);
94
95
  }
95
- /** Map `gh search prs` JSON output to PR refs. Drafts are excluded. */
96
+ /**
97
+ * Map `gh search prs` JSON output to PR refs.
98
+ *
99
+ * Drafts are included. The search is already `--review-requested=@me`, so a
100
+ * draft that reaches this point carries an explicit review request — the author
101
+ * pressed the button, which makes it a review someone is waiting on rather than
102
+ * work-in-progress to leave alone. Filtering drafts here would be redundant with
103
+ * a stronger filter that already ran, and it would contradict the promise the
104
+ * daemon config makes: the inbox holds what awaits you, drafts and all.
105
+ */
96
106
  export function mapSearchResults(raw) {
97
- return raw
98
- .filter((r) => !r.isDraft)
99
- .flatMap((r) => {
107
+ return raw.flatMap((r) => {
100
108
  const [owner, repo] = r.repository.nameWithOwner.split("/");
101
109
  if (!owner || !repo)
102
110
  return [];
@@ -184,7 +192,7 @@ export function searchAwaitingArgs(repoFilter, limit = 50) {
184
192
  args.push("--repo", repoFilter);
185
193
  return args;
186
194
  }
187
- /** Open, non-draft PRs where the current gh user's review is requested. */
195
+ /** Open PRs where the current gh user's review is requested, drafts included. */
188
196
  export async function searchAwaitingMe(repoFilter, limit = 50) {
189
197
  const out = await gh(searchAwaitingArgs(repoFilter, limit));
190
198
  return mapSearchResults(JSON.parse(out));
@@ -39,7 +39,7 @@ export function mergeConcurrentEdits(before, after, current) {
39
39
  // Absent from `before` means the user added it while the turn ran.
40
40
  if (!then)
41
41
  return true;
42
- return then.body !== now.body || then.status !== now.status;
42
+ return then.body !== now.body || then.status !== now.status || then.severity !== now.severity;
43
43
  };
44
44
  const merged = current.comments.map((now) => userTouched(now.id, now) ? now : fromTurn.get(now.id) ?? now);
45
45
  // Comments the turn itself added are not in `current` yet. Only those: a
@@ -115,7 +115,14 @@ export function applyRevisions(artifact, revisions, opts = {}) {
115
115
  }
116
116
  next = {
117
117
  ...next,
118
- comments: next.comments.map((c) => c.id === revision.commentId ? { ...c, body: revision.body } : c),
118
+ comments: next.comments.map((c) => c.id === revision.commentId
119
+ ? {
120
+ ...c,
121
+ body: revision.body,
122
+ // Omitted = the turn is not re-grading; null = "not a finding".
123
+ severity: revision.severity === undefined ? c.severity : revision.severity,
124
+ }
125
+ : c),
119
126
  };
120
127
  applied.push(revision);
121
128
  break;
@@ -149,6 +156,7 @@ export function applyRevisions(artifact, revisions, opts = {}) {
149
156
  line: revision.line,
150
157
  body: revision.body,
151
158
  chapterId: revision.chapterId,
159
+ severity: revision.severity,
152
160
  origin: "ai",
153
161
  status: "draft",
154
162
  editedByUser: false,
package/dist/core/send.js CHANGED
@@ -2,6 +2,14 @@ import { newSideLines } from "./diff.js";
2
2
  export function eventForRecommendation(rec) {
3
3
  return { approve: "APPROVE", comment: "COMMENT", request_changes: "REQUEST_CHANGES" }[rec];
4
4
  }
5
+ /**
6
+ * The grade travels in the text: to a reader used to review convention, an
7
+ * unprefixed comment says "please change this", so leaving a nit bare would
8
+ * invert its meaning. Ungraded remarks (questions, notes) stay bare.
9
+ */
10
+ function withGrade(c) {
11
+ return c.severity ? `**${c.severity}:** ${c.body}` : c.body;
12
+ }
5
13
  /**
6
14
  * Build the GitHub review payload from an artifact.
7
15
  * Includes every comment that is not dropped. Comments whose line is not part
@@ -17,7 +25,7 @@ export function buildReviewPayload(artifact, event) {
17
25
  // holds different code — posting there would point the author at something
18
26
  // the comment was never about.
19
27
  if (!c.drifted && c.line != null && anchorable.get(c.path)?.has(c.line)) {
20
- inline.push({ path: c.path, line: c.line, side: "RIGHT", body: c.body });
28
+ inline.push({ path: c.path, line: c.line, side: "RIGHT", body: withGrade(c) });
21
29
  }
22
30
  else {
23
31
  folded.push(c);
@@ -39,7 +47,7 @@ export function buildReviewPayload(artifact, event) {
39
47
  // A drifted comment's line no longer exists, so name it as "was at" —
40
48
  // pointing a reader at a line number that moved is worse than no number.
41
49
  const at = c.line != null ? `:${c.drifted ? `~${c.line}` : c.line}` : "";
42
- parts.push(`- \`${c.path}${at}\` — ${c.body}`);
50
+ parts.push(`- \`${c.path}${at}\` — ${withGrade(c)}`);
43
51
  }
44
52
  }
45
53
  parts.push("", "---", "_Reviewed with [cerber](https://github.com/fullstackhouse/cerber) 🐕 — drafted by AI, sent by a human._");
@@ -49,6 +49,7 @@ Respond with ONLY a JSON object (no markdown fences, no prose before or after) m
49
49
  "path": "path/to/file.ts",
50
50
  "line": 42,
51
51
  "body": "markdown — the review comment. First sentence states the problem plainly; then the evidence or fix. Concrete, actionable, kind.",
52
+ "severity": "blocker" | "minor" | "nit" | null,
52
53
  "chapterId": "short-kebab-id"
53
54
  }
54
55
  ],
@@ -70,6 +71,13 @@ Rules:
70
71
  - 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.
71
72
  - "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.
72
73
  - 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.
74
+ - "severity" is the finding's merge impact. The words mean what they mean everywhere:
75
+ - "blocker" — you would not approve with this in: data loss, a security hole, wrong behavior on a path people actually hit, a broken build. The only tier that blocks.
76
+ - "minor" — should be fixed, but the author's call; you would approve and not chase it.
77
+ - "nit" — taste: naming, wording, formatting.
78
+ - null — not a finding at all: a question, a note, praise.
79
+ - Grade severity as if the finding is true. If you do not believe it enough to grade it, do not write it — or ask it as a question with severity null. Never lower a grade to hedge doubt; doubt belongs in the verdict's "confidence", nowhere else.
80
+ - The verdict must follow from the worst finding still standing: any blocker → "request_changes"; none → "approve" (or "comment" when you genuinely take no position). Name the deciding finding in "reasoning". Never request changes without a blocker in the list, and never approve past one.
73
81
  ${confidenceRule}
74
82
  - Never invent files or lines not present in the diff.
75
83
 
@@ -122,7 +130,8 @@ function renderReview(artifact) {
122
130
  .map((c) => {
123
131
  const mine = c.origin === "user" || c.editedByUser ? " [THE USER'S OWN WRITING]" : "";
124
132
  const dropped = c.status === "dropped" ? " [dropped]" : "";
125
- return `- ${c.id} — ${c.path}:${c.line ?? "file-level"}${mine}${dropped}\n ${c.body}`;
133
+ const graded = c.severity ? ` [${c.severity}]` : "";
134
+ return `- ${c.id} — ${c.path}:${c.line ?? "file-level"}${graded}${mine}${dropped}\n ${c.body}`;
126
135
  })
127
136
  .join("\n");
128
137
  const verdict = artifact.verdict
@@ -192,9 +201,9 @@ Respond with ONLY a JSON object (no markdown fences, no prose before or after) m
192
201
  { "kind": "summary", "body": "the rewritten summary" },
193
202
  { "kind": "verdict", "verdict": { "recommendation": "approve" | "comment" | "request_changes", "confidence": 0-100, "reasoning": "..." } },
194
203
  { "kind": "chapter", "chapterId": "...", "title": "optional new title", "explanation": "optional new explanation" },
195
- { "kind": "comment-edit", "commentId": "...", "body": "the rewritten comment" },
204
+ { "kind": "comment-edit", "commentId": "...", "body": "the rewritten comment", "severity": "blocker" | "minor" | "nit" | null (optional — omit to leave the grade as it is) },
196
205
  { "kind": "comment-drop", "commentId": "..." },
197
- { "kind": "comment-add", "path": "path/to/file.ts", "line": 42, "body": "...", "chapterId": "..." }
206
+ { "kind": "comment-add", "path": "path/to/file.ts", "line": 42, "body": "...", "severity": "blocker" | "minor" | "nit" | null, "chapterId": "..." }
198
207
  ]
199
208
  }
200
209
 
@@ -205,6 +214,7 @@ Rules:
205
214
  - Comments marked [THE USER'S OWN WRITING] are theirs. Do not edit or drop them. If one of them is what needs changing, say so in your reply and let them do it.
206
215
  - Every "commentId" and "chapterId" must be one listed below. Never invent one.
207
216
  - "line" is a line in the NEW version of the file and must appear in the diff. Use null for a file-level comment.
217
+ - "severity" is the finding's merge impact — "blocker" (would not approve with this in; the only tier that blocks), "minor" (should fix, author's call), "nit" (taste), null (not a finding: a question, a note). Grade as if the finding is true; hedge in the reply, never in the grade. A verdict revision must still follow from the worst finding left standing.
208
218
  - Your reply is prose to a person, not a changelog. The cockpit already shows what you changed — do not list your own edits back to them.
209
219
 
210
220
  Writing style:
@@ -164,6 +164,7 @@ async function runReview(ref, opts) {
164
164
  line: c.line,
165
165
  body: c.body,
166
166
  chapterId: c.chapterId ?? null,
167
+ severity: c.severity ?? null,
167
168
  origin: "ai",
168
169
  status: "draft",
169
170
  editedByUser: false,
@@ -30,6 +30,7 @@ export function stubArtifact(ref) {
30
30
  headRefName: "",
31
31
  headSha: "",
32
32
  state: "OPEN",
33
+ isDraft: ref.isDraft,
33
34
  additions: 0,
34
35
  deletions: 0,
35
36
  changedFiles: 0,
@@ -48,7 +49,19 @@ export function stubArtifact(ref) {
48
49
  pendingChat: null,
49
50
  };
50
51
  }
51
- /** An artifact nothing has touched yet — safe to delete when its PR leaves the queue. */
52
+ /**
53
+ * An artifact nothing has touched yet — safe to delete when its PR leaves the
54
+ * queue.
55
+ *
56
+ * Read this before loosening it. Artifacts now arrive two ways: the daemon
57
+ * discovers them, and the user pastes a PR into the cockpit. The reaper below
58
+ * deletes anything absent from the awaiting search, which is only ever correct
59
+ * for the first kind — a pasted PR was never in that search and never will be.
60
+ * What keeps it safe is `run === null`: the cockpit's create endpoint persists
61
+ * a run block before it answers, so a pulled-in review is never a pure stub,
62
+ * not even during the minutes before its first AI output lands. Widen this
63
+ * predicate and you delete work the user asked for.
64
+ */
52
65
  export function isPureStub(artifact) {
53
66
  return artifact.status === "awaiting" && artifact.comments.length === 0 && artifact.run === null;
54
67
  }
@@ -111,8 +124,20 @@ export function startDaemon(opts) {
111
124
  async function syncQueue(refs) {
112
125
  let discovered = 0;
113
126
  for (const ref of refs) {
114
- if (await loadArtifact(artifactId(ref)))
127
+ const existing = await loadArtifact(artifactId(ref));
128
+ if (existing) {
129
+ // The search result is what the PR is right now; the artifact can
130
+ // predate a draft→ready flip. Refresh from it — it costs nothing, it's
131
+ // already in hand — or the queue keeps calling a ready PR a draft until
132
+ // something happens to re-review it.
133
+ if (existing.pr.isDraft !== ref.isDraft) {
134
+ await updateArtifactByKey(artifactKey(existing.id), (a) => ({
135
+ ...a,
136
+ pr: { ...a.pr, isDraft: ref.isDraft },
137
+ }));
138
+ }
115
139
  continue;
140
+ }
116
141
  await saveArtifact(stubArtifact(ref));
117
142
  discovered++;
118
143
  }
@@ -157,13 +182,17 @@ export function startDaemon(opts) {
157
182
  stateCheckedAt.set(artifact.id, Date.now());
158
183
  try {
159
184
  const pr = await fetchPrInfo(artifact.pr);
160
- if (pr.state !== "OPEN") {
185
+ // This artifact isn't in the awaiting search, so the cheap draft refresh
186
+ // above never sees it — a PR pulled in from the cockpit's paste box gets
187
+ // its draft flag corrected here instead. Same fetch either way.
188
+ if (pr.state !== "OPEN" || pr.isDraft !== artifact.pr.isDraft) {
161
189
  await updateArtifactByKey(artifactKey(artifact.id), (a) => ({
162
190
  ...a,
163
- pr: { ...a.pr, state: pr.state },
191
+ pr: { ...a.pr, state: pr.state, isDraft: pr.isDraft },
164
192
  }));
165
- log(`[${artifact.id}] ${pr.state.toLowerCase()} — archived`);
166
193
  }
194
+ if (pr.state !== "OPEN")
195
+ log(`[${artifact.id}] ${pr.state.toLowerCase()} — archived`);
167
196
  }
168
197
  catch {
169
198
  // Same: book-keeping can wait for the next poll.
@@ -6,15 +6,16 @@ import { promises as fs } from "node:fs";
6
6
  import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { randomUUID } from "node:crypto";
9
- import { ArtifactStatusSchema, VerdictSchema, artifactKey } from "../core/artifact.js";
9
+ import { isPureStub } from "./daemon.js";
10
+ import { ArtifactStatusSchema, SCHEMA_VERSION, SeveritySchema, VerdictSchema, artifactId, artifactKey, } from "../core/artifact.js";
10
11
  import { toMarkdown } from "../core/export.js";
11
- import { fetchPrDiff, fetchPrInfo, submitReview } from "../core/gh.js";
12
+ import { fetchPrDiff, fetchPrInfo, parsePrRef, submitReview } from "../core/gh.js";
12
13
  import { z } from "zod";
13
14
  import { DaemonConfigSchema, configPath, loadConfig, saveConfig } from "../core/config.js";
14
15
  import { refreshArtifact } from "../core/refresh.js";
15
16
  import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
16
17
  import { buildReviewPayload, computeCalibration } from "../core/send.js";
17
- import { listArtifacts, loadArtifactByKey, reconcileRunning, updateArtifactByKey, } from "../core/state.js";
18
+ import { listArtifacts, loadArtifact, loadArtifactByKey, reconcileRunning, saveArtifact, updateArtifactByKey, } from "../core/state.js";
18
19
  import { isReviewRunning } from "../runner/inflight.js";
19
20
  import { reviewPr } from "../runner/review.js";
20
21
  import { runChatTurn } from "../runner/chat.js";
@@ -141,6 +142,7 @@ export async function buildApp(opts) {
141
142
  repo: a.pr.repo,
142
143
  number: a.pr.number,
143
144
  state: a.pr.state,
145
+ isDraft: a.pr.isDraft,
144
146
  additions: a.pr.additions,
145
147
  deletions: a.pr.deletions,
146
148
  changedFiles: a.pr.changedFiles,
@@ -159,6 +161,110 @@ export async function buildApp(opts) {
159
161
  sent: a.sent ? { at: a.sent.at, event: a.sent.event, url: a.sent.url, auto: a.sent.auto ?? false } : null,
160
162
  })));
161
163
  });
164
+ // ---- Pull a PR in: review anything, not just what the inbox found ----
165
+ //
166
+ // The daemon only ever discovers PRs GitHub is asking *you* to review. This
167
+ // is the other door: paste a URL and cerber reviews it — your own PR, one you
168
+ // were never requested on, one you already settled and want a second read of.
169
+ const CreateReviewSchema = z.object({ input: z.string().min(1) });
170
+ app.post("/api/reviews", async (c) => {
171
+ const parsed = CreateReviewSchema.safeParse(await c.req.json().catch(() => null));
172
+ if (!parsed.success)
173
+ return c.json({ error: "expected { input: \"<pr url>\" }" }, 400);
174
+ let ref;
175
+ try {
176
+ ref = parsePrRef(parsed.data.input.trim());
177
+ }
178
+ catch (err) {
179
+ // A bare number is the one parse failure worth explaining: parsePrRef can
180
+ // resolve it against a repo, and the cockpit has none to offer.
181
+ const message = /bare number/.test(err instanceof Error ? err.message : "")
182
+ ? "A number alone doesn't say which repo — paste the full PR URL, or owner/repo#123."
183
+ : err instanceof Error
184
+ ? err.message
185
+ : String(err);
186
+ return c.json({ error: message }, 400);
187
+ }
188
+ // Two tabs, or a retried request, can both get here before either has saved
189
+ // anything. Letting both through would start a second run whose inflight
190
+ // claim throws, and whose failure handler would then mark the *first* run's
191
+ // artifact failed underneath it.
192
+ if (isReviewRunning(artifactId(ref))) {
193
+ return c.json({ error: "a review of this PR is already running" }, 409);
194
+ }
195
+ // Already here? Hand back what we have rather than reviewing it twice — but
196
+ // only if there is something to hand back. A pure stub is the daemon saying
197
+ // "GitHub is asking you for this", not a review; pasting its URL is the user
198
+ // asking for the review that does not exist yet, so that falls through and
199
+ // runs. Handing the stub over instead would answer 200 while silently
200
+ // dropping what was actually requested.
201
+ const existing = await loadArtifact(artifactId(ref));
202
+ if (existing && !isPureStub(existing)) {
203
+ return c.json({ ...existing, key: artifactKey(existing.id) }, 200);
204
+ }
205
+ // Validated in-request, on purpose. The run below is detached, so a typo'd
206
+ // URL, a private repo or a logged-out `gh` would otherwise fail with no
207
+ // artifact to record it on and no response left to report it in. The PR
208
+ // this fetches is also what the artifact is built from, so it costs nothing.
209
+ let pr;
210
+ try {
211
+ pr = await fetchPrInfo(ref);
212
+ }
213
+ catch (err) {
214
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 502);
215
+ }
216
+ const withSource = opts.withSource;
217
+ const now = new Date().toISOString();
218
+ // Saved as `running`, with a run block, BEFORE the 202 — not as an
219
+ // `awaiting` stub. reviewPr spends minutes on the diff, trust and checkout
220
+ // before it first writes the artifact itself, and for a PR that isn't in
221
+ // the awaiting search a stub sitting in that window is exactly what the
222
+ // daemon's reaper deletes (isPureStub). Persisting the run up front is what
223
+ // makes this artifact survive its own first poll.
224
+ const artifact = {
225
+ schemaVersion: SCHEMA_VERSION,
226
+ id: artifactId(ref),
227
+ status: "running",
228
+ createdAt: now,
229
+ updatedAt: now,
230
+ pr,
231
+ diff: "",
232
+ summary: "",
233
+ chapters: [],
234
+ comments: [],
235
+ verdict: null,
236
+ run: {
237
+ model: null,
238
+ startedAt: now,
239
+ finishedAt: null,
240
+ costUsd: null,
241
+ error: null,
242
+ withSource: withSource !== false,
243
+ trusted: false,
244
+ sessionId: null,
245
+ },
246
+ sent: null,
247
+ refresh: null,
248
+ calibration: null,
249
+ chat: [],
250
+ preChat: null,
251
+ pendingChat: null,
252
+ };
253
+ await saveArtifact(artifact);
254
+ void reviewPr(ref, {
255
+ force: true,
256
+ withSource,
257
+ trust: opts.trust,
258
+ onProgress: (m) => console.log(`[pull ${artifact.id}] ${m}`),
259
+ }).catch(async (err) => {
260
+ const message = err instanceof Error ? err.message : String(err);
261
+ console.error(`[pull ${artifact.id}] failed: ${message}`);
262
+ await updateArtifactByKey(artifactKey(artifact.id), (a) => a.status === "running"
263
+ ? { ...a, status: "failed", run: a.run ? { ...a.run, error: message } : null }
264
+ : a).catch(() => { });
265
+ });
266
+ return c.json({ ...artifact, key: artifactKey(artifact.id) }, 202);
267
+ });
162
268
  app.get("/api/reviews/:key", async (c) => {
163
269
  const artifact = await loadArtifactByKey(c.req.param("key"));
164
270
  if (!artifact)
@@ -194,6 +300,9 @@ export async function buildApp(opts) {
194
300
  ? {
195
301
  ...cm,
196
302
  ...(body.body !== undefined ? { body: String(body.body) } : {}),
303
+ ...(body.severity !== undefined
304
+ ? { severity: SeveritySchema.nullable().parse(body.severity) }
305
+ : {}),
197
306
  ...(body.status !== undefined
198
307
  ? { status: body.status }
199
308
  : {}),
@@ -219,6 +328,7 @@ export async function buildApp(opts) {
219
328
  line: body.line != null ? Number(body.line) : null,
220
329
  body: String(body.body ?? ""),
221
330
  chapterId: body.chapterId ?? null,
331
+ severity: SeveritySchema.nullable().parse(body.severity ?? null),
222
332
  origin: "user",
223
333
  status: "draft",
224
334
  editedByUser: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.12.1",
3
+ "version": "0.14.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
  "author": "Full Stack House (https://fullstack.house)",
@@ -7,4 +7,4 @@
7
7
 
8
8
  Outdated base version: https://github.com/primer/github-syntax-light
9
9
  Current colors taken from GitHub's CSS
10
- */.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id{color:#005cc5}.hljs-regexp,.hljs-string,.hljs-meta .hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-comment,.hljs-code,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}:root{--bg: #fcfcfd;--surface: #ffffff;--fg: #14171a;--fg-2: #3a3f45;--fg-3: #4a5058;--muted: #6b7178;--faint: #9aa0a8;--fainter: #b6bbc1;--line: #e6e8eb;--line-2: #dcdfe4;--line-3: #eef0f2;--accent: #3d4fd6;--accent-dark: #2c3aa8;--accent-wash: #f4f5ff;--green: #1a7f5a;--amber: #a06a00;--red: #c2314a;--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;--prose: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Helvetica, sans-serif;--measure: 1560px}*{box-sizing:border-box}body{margin:0;font-family:var(--mono);font-size:12px;color:var(--fg);background:var(--bg)}a{color:var(--accent)}a:hover{color:var(--accent-dark)}h1,h2,h3{font-size:13px;font-weight:700;margin:0}.app{padding-bottom:48px}.wrap{max-width:var(--measure);margin:0 auto;padding:0 40px}.bar-inner{max-width:var(--measure);margin:0 auto;padding:0 40px;display:flex;align-items:center;gap:14px}.grow{flex-grow:1}.pad{padding:24px 40px}.muted,.faint{color:var(--faint)}.warn{color:var(--amber)}.danger{color:var(--red)}.add{color:var(--green)}.del{color:var(--red)}.icon{flex-shrink:0}.lab{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint)}.key{font-size:10px;border:1px solid var(--line-2);border-bottom-width:2px;border-radius:3px;padding:1px 5px;color:var(--muted);background:var(--surface)}.hint{display:inline-flex;gap:5px;align-items:center;font-size:10.5px;color:var(--faint);white-space:nowrap}.prose{font-family:var(--prose);font-size:13px;line-height:1.6;color:var(--fg-2);text-wrap:pretty;max-width:720px}.prose.lead{font-size:13.5px;line-height:1.62;color:var(--fg)}.error{color:var(--red);background:#c2314a0f;border:1px solid rgba(194,49,74,.3);padding:8px 12px;border-radius:4px;margin:10px 0}.empty{text-align:center;color:var(--faint);margin:64px auto;flex-direction:column}.empty pre{display:inline-block;background:var(--line-3);padding:8px 16px;border-radius:4px}button{font:inherit;cursor:pointer}.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;font-size:11px;color:var(--fg-3);border:1px solid var(--line-2);border-radius:4px;padding:6px 10px;background:var(--surface);white-space:nowrap;text-decoration:none}.btn:hover:not(:disabled){color:var(--fg);border-color:var(--fainter)}.btn:disabled{opacity:.5;cursor:default}.btn-sm{font-size:10.5px;padding:3px 8px;gap:5px}.btn-accent{color:var(--accent);border-color:#3d4fd659}.btn-keep-on{color:#fff;background:var(--green);border-color:var(--green)}.btn-drop-on{color:var(--red);border-color:#c2314a66}.btn-dark{color:#fff;background:var(--fg);border-color:var(--fg);font-size:11.5px;padding:7px 14px}.btn-dark:disabled{color:var(--faint);background:#f0f1f3;border-color:var(--line-2)}.btn-dashed{display:inline-flex;align-items:center;gap:5px;font-size:10.5px;color:var(--muted);border:1px dashed var(--line-2);border-radius:4px;padding:5px 10px;background:transparent;margin-top:10px}.btn-primary{display:flex;align-items:center;justify-content:center;gap:8px;height:40px;padding:0 14px;font-size:12px;font-weight:500;color:#fff;background:var(--accent);border:1px solid var(--accent);border-radius:4px}.btn-primary .key{border-color:#ffffff4d;color:#ffffffbf;background:transparent}.link{display:inline-flex;align-items:center;gap:5px;font:inherit;font-size:11px;color:var(--accent);background:none;border:none;padding:0;text-decoration:none}.link:hover:not(:disabled){color:var(--accent-dark)}.link:disabled{color:var(--faint);cursor:default}.topbar{border-bottom:1px solid var(--line);background:var(--surface)}.topbar .bar-inner{padding-top:14px;padding-bottom:14px}.brand{display:inline-flex;align-items:center;gap:7px;font-size:15px;font-weight:700;letter-spacing:-.02em;color:var(--fg);text-decoration:none}.brand .logo{color:var(--accent);flex-shrink:0}.tagline{font-size:11px;color:var(--faint)}.topbar-link{font-size:11px;color:var(--muted);text-decoration:none;border-left:1px solid var(--line);padding-left:14px;display:inline-flex;align-items:center}.topbar-link:hover{color:var(--fg)}.bell{background:none;border:0;border-left:1px solid var(--line);padding:0 0 0 14px;font-family:inherit;cursor:pointer}.bell-on,.bell-ask{color:var(--accent)}.bell-blocked{color:var(--fainter);cursor:help}.daemon-bar{border-bottom:1px solid var(--line);background:#f7f8fa;font-size:11px;color:var(--muted)}.daemon-bar .bar-inner{padding-top:8px;padding-bottom:8px;gap:18px;flex-wrap:wrap}.daemon-bar b{color:var(--fg);font-weight:400}.daemon-live{display:inline-flex;align-items:center;gap:6px;color:var(--fg)}.daemon-bar .warn{display:inline-flex;align-items:center;gap:6px}.dot{width:5px;height:5px;border-radius:999px;background:var(--green);display:inline-block}.dot-off{background:var(--fainter)}.tabs{display:flex;align-items:center;gap:2px;padding-top:14px}.tab{font-size:12px;padding:6px 12px;border:none;border-bottom:2px solid transparent;border-radius:4px 4px 0 0;background:none;color:var(--muted)}.tab:hover{color:var(--fg)}.tab-on{color:var(--fg);font-weight:500;border-bottom-color:var(--accent)}.tab-count{color:var(--fainter);margin-left:4px}.tab-on .tab-count{color:var(--accent)}.table-head,.row{display:flex;gap:12px;align-items:center}.table-head{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);padding:12px 10px 7px;border-bottom:1px solid var(--line-2)}.row{font-size:12px;padding:8px 10px;cursor:pointer;border-bottom:1px solid var(--line-3);border-left:2px solid transparent}.row:hover{background:#f7f8fa}.row-on{background:var(--accent-wash);border-left-color:var(--accent)}.row-on .col-caret{color:var(--accent)}.row-on .col-title{font-weight:500}.tag-done{flex-shrink:0;color:var(--muted);background:var(--line-3)}.tag-awaiting{flex-shrink:0;color:var(--amber);background:#a06a001a}.col-caret{width:12px;flex-shrink:0;color:transparent}.col-slug{width:184px;flex-shrink:0;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.col-title{flex-grow:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.col-author{width:104px;flex-shrink:0;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.col-verdict{width:136px;flex-shrink:0;text-align:right;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.col-comments{width:26px;flex-shrink:0;text-align:right;color:var(--muted)}.col-size{width:104px;flex-shrink:0;text-align:right;color:var(--muted)}.col-age{width:44px;flex-shrink:0;text-align:right;color:var(--faint)}.table-head .col-verdict,.table-head .col-slug,.table-head .col-author{color:var(--faint);font-weight:400}.cursor-strip{display:flex;gap:18px;align-items:flex-start;border:1px solid var(--line-2);border-top:none;border-radius:0 0 4px 4px;background:var(--surface);padding:14px 16px}.cursor-strip-body{flex-grow:1;min-width:0}.cursor-strip-body .prose{margin:6px 0 0;max-width:700px}.cursor-strip-meta{font-size:10.5px;color:var(--faint);margin-top:8px}.cursor-strip-actions{display:flex;flex-direction:column;gap:6px;width:176px;flex-shrink:0}.cursor-strip-row{display:flex;gap:6px}.cursor-strip-row .btn{flex-grow:1;padding:7px 0}.drawers{margin-top:16px;padding-bottom:8px}.drawer+.drawer{margin-top:6px}.drawer-toggle{font-size:11.5px;color:var(--muted);background:none;border:none;padding:4px 0}.drawer-toggle:hover{color:var(--fg)}.drawer-rows{margin:8px 0 14px}.row-settled{opacity:.62}.row-settled:hover,.row-awaiting{opacity:1}.review-top{position:sticky;top:0;z-index:20;background:var(--surface)}.crumb{border-bottom:1px solid var(--line);background:var(--surface);font-size:11.5px;color:var(--muted)}.crumb .bar-inner{padding-top:12px;padding-bottom:12px;gap:12px}.crumb a{text-decoration:none}.crumb-sep{color:var(--line-2)}.crumb-slug{color:var(--fg)}.crumb-next{font-size:11px;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.walk{display:inline-flex;gap:2px}.walk-btn{display:inline-flex;align-items:center;justify-content:center;padding:4px 6px;border:1px solid var(--line-2);border-radius:3px;background:var(--surface);color:var(--fg-3)}.walk-btn:disabled{color:#d4d7db;border-color:var(--line-3);cursor:default}.review-head{border-bottom:1px solid var(--line);background:var(--surface)}.review-head .wrap{padding-top:20px;padding-bottom:16px}.review-head-top,.review-head-meta{display:flex;align-items:center;gap:14px}.review-head-top{margin-bottom:8px}.review-title{font-size:19px;font-weight:700;letter-spacing:-.025em;color:var(--fg);text-decoration:none}.review-title:hover{color:var(--accent)}.review-head-meta{font-size:11px;color:var(--muted);gap:10px;flex-wrap:wrap}.verdict-pick{display:inline-flex;align-items:center;gap:5px;font-size:10.5px;padding:4px 9px;border-radius:4px;border:1px solid transparent;background:none;color:var(--faint);white-space:nowrap}.verdict-pick:hover{color:var(--fg-3)}.verdict-pick-on{background:#f0f1f3;border-color:var(--line-2);font-weight:500}.review-body{display:flex;align-items:flex-start;gap:0}.rail{width:236px;flex-shrink:0;padding:22px 20px 22px 0;position:sticky;top:var(--top-h, 0px);z-index:5;align-self:flex-start;max-height:calc(100vh - var(--top-h, 0px));overflow-y:auto}.chapter,.comment,.verdict-bar{scroll-margin-top:calc(var(--top-h, 0px) + 12px)}.rail-lab{display:block;margin-top:22px}.rail .lab{margin-bottom:10px}.rail-item{display:flex;align-items:center;gap:6px;width:100%;text-align:left;font:inherit;font-size:11.5px;line-height:1.5;padding:5px 0 5px 9px;margin-left:-11px;background:none;border:none;border-left:2px solid transparent;color:var(--muted)}.rail-item:hover{color:var(--fg)}.rail-item-on{border-left-color:var(--accent);color:var(--fg);font-weight:500}.rail-group{margin-bottom:2px}.rail-count{font-size:10.5px;color:var(--fainter)}.rail-comment{display:flex;align-items:center;gap:6px;width:100%;font:inherit;font-size:11px;text-align:left;padding:3px 0 3px 20px;margin-left:-11px;background:none;border:none;border-left:2px solid transparent;color:var(--muted)}.rail-comment:hover{color:var(--fg)}.rail-comment-loc{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rail-comment-dropped{opacity:.55}.rail-comment-dropped .rail-comment-loc{text-decoration:line-through}.rail-dot{font-size:8px}.comment-flash{animation:comment-flash 1.8s ease-out}@keyframes comment-flash{0%,60%{background:var(--accent-wash);box-shadow:inset 0 0 0 2px #3d4fd673}}.rail-facts{font-size:11.5px;line-height:1.85;color:var(--muted)}.rail-facts b{color:var(--fg);font-weight:400}.main{flex-grow:1;min-width:0;padding:22px 0 0}.card{border:1px solid var(--line);border-radius:4px;background:var(--surface);margin-bottom:10px}.card-head{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;padding:11px 16px;border-bottom:1px solid var(--line-3)}.card-head .faint{font-size:11px}.card-body{padding:14px 16px}.card-body .lab{display:block;margin-bottom:7px}.card-body .prose{margin:0}.card-body .btn{margin-top:12px}.card-why{margin-bottom:8px}.chapter{border:1px solid var(--line);border-radius:4px;background:var(--surface);margin-bottom:10px;overflow:hidden}.chapter-head{display:flex;align-items:center;gap:10px;padding:11px 16px;cursor:pointer;-webkit-user-select:none;user-select:none}.chapter-body{padding:14px 16px 16px;border-top:1px solid var(--line-3)}.chapter-meta{font-size:11px;font-weight:400}.chapter-explanation{margin:0 0 14px}.comment{border:1px solid var(--line);border-radius:4px;background:#fbfbfc;padding:12px 14px;margin:8px 0}.comment-dropped{opacity:.5}.comment-dropped .comment-body{text-decoration:line-through}.comment-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px}.comment-loc{font-size:10.5px;color:var(--faint)}.comment-body{font-size:13px;line-height:1.58;color:var(--fg);max-width:640px}.comment-edit,.add-comment textarea,.chat-input{width:100%;font-family:var(--mono);font-size:12.5px;line-height:1.55;color:var(--fg);padding:10px 12px;border:1px solid var(--line-2);border-radius:4px;background:var(--surface);resize:vertical}.add-comment{border:1px dashed var(--line-2);border-radius:4px;padding:10px 12px;margin:10px 0;display:flex;flex-direction:column;gap:8px}.add-comment-row{display:flex;gap:8px;align-items:center}.add-comment select,.add-comment input{font:inherit;font-size:11.5px;padding:5px 7px;border:1px solid var(--line-2);border-radius:4px;background:var(--surface)}.inline-comment-row td{padding:0;background:#fbfbfc;border-top:1px solid var(--line);border-bottom:1px solid var(--line);white-space:normal}.inline-comment-row .comment{margin:0;border:none;border-radius:0;padding-left:44px;max-width:none}.diff{margin-top:8px;font-size:11.5px}.diff .d2h-file-header,.diff .d2h-file-name{font-family:var(--mono);font-size:11px}.diff .hljs{display:inline;padding:0;background:transparent;color:inherit}.freshness{background:#a06a0012;border:1px solid rgba(160,106,0,.3);color:#57430a;padding:10px 12px;border-radius:4px;margin-bottom:10px;line-height:1.55;font-size:11.5px}.freshness code{background:#0000000f;padding:1px 4px;border-radius:3px}.freshness-running{background:#3d4fd60f;border-color:#3d4fd64d;color:#26307a}.freshness .btn{margin-left:8px}.chat-panel{margin:18px 0 10px}.chat-log{display:flex;flex-direction:column;gap:14px;margin-bottom:14px}.chat-turn{border-left:2px solid var(--line-2);padding:2px 0 2px 12px}.chat-turn-user{border-left-color:var(--accent)}.chat-turn-pending{border-left-style:dashed}.chat-turn-who{font-size:10.5px;color:var(--faint);margin-bottom:4px}.chat-turn-body{max-width:700px;color:var(--fg)}.chat-revision{font-size:10.5px;color:var(--green);margin-top:6px}.chat-refusal{font-size:10.5px;color:var(--amber);margin-top:6px}.chat-waiting{font-size:11.5px;color:var(--muted);margin-top:6px}.chat-progress{list-style:none;margin:0;padding:0}.chat-progress li{opacity:.55;padding:1px 0}.chat-progress li.now{opacity:1}.chat-progress li.now:after{content:"…"}.chat-failed{font-size:11.5px;color:var(--red);margin-top:6px}.chat-refs{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:8px}.chat-ref{font-size:10.5px;font-family:var(--mono);padding:3px 9px;border:1px solid var(--line-2);border-radius:999px;color:var(--muted);background:transparent}.chat-refs-pending .chat-ref{cursor:pointer;color:var(--accent);border-color:#3d4fd659}.chat-actions{display:flex;align-items:center;gap:12px;margin-top:10px}.send-panel{border:1px solid var(--line-2);border-radius:4px;background:#f7f8fa;padding:16px 18px}.verdict-bar+.send-panel{border-radius:0 0 4px 4px}.send-top{display:flex;align-items:baseline;gap:12px;margin-bottom:14px}.send-top .faint{font-size:11px}.send-top .link{text-decoration:underline;text-underline-offset:2px}.send-action{display:flex;align-items:center;gap:16px;flex-wrap:wrap}.send-btn{display:inline-flex;align-items:center;gap:10px;height:44px;padding:0 20px;font-size:13px;font-weight:500;color:#fff;border:none;border-radius:4px;box-shadow:0 1px 2px #14171a1f}.send-btn:disabled{opacity:.6;cursor:default}.send-btn .key{border-color:#ffffff4d;color:#fffc;background:transparent}.send-as{position:relative}.send-as-toggle{font:inherit;font-size:11.5px;color:var(--fg-3);background:none;border:none;padding:0}.send-as-toggle b{color:var(--fg);font-weight:500;border-bottom:1px dotted var(--fainter)}.send-as-why{font-size:10.5px;color:var(--faint);margin-top:3px}.menu{position:absolute;top:34px;left:0;z-index:10;background:var(--surface);border:1px solid var(--line-2);border-radius:4px;box-shadow:0 6px 18px #14171a17;padding:4px;min-width:190px}.menu-item{display:flex;align-items:center;gap:9px;width:100%;font:inherit;font-size:11.5px;text-align:left;padding:7px 9px;border:none;border-radius:3px;background:none;color:var(--fg-3)}.menu-item:hover{background:#f7f8fa}.menu-item-on{background:#f4f5f7;color:var(--fg)}.menu-check{font-size:10px;color:var(--green)}.verdict-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;border:1px solid var(--line-2);border-bottom:none;border-radius:4px 4px 0 0;background:var(--surface);padding:10px 18px;margin-top:18px}.verdict-bar .lab{margin-right:4px}.verdict-bar .faint{font-size:10.5px}.send-footer{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:16px;padding-top:14px;border-top:1px solid var(--line-2);font-size:10.5px}.body-preview{margin-top:14px;padding:14px 16px;background:var(--surface);border:1px solid var(--line);border-radius:4px;font-family:var(--prose);font-size:12.5px;line-height:1.62;color:var(--fg-2);white-space:pre-wrap;max-width:760px;max-height:420px;overflow:auto}.sent-strip{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;border:1px solid rgba(26,127,90,.4);background:#1a7f5a12;border-radius:4px;padding:14px 16px;font-size:11.5px}.sent-strip strong{color:var(--green);font-size:13px;white-space:nowrap}.sent-strip a{display:inline-flex;align-items:center;gap:5px;white-space:nowrap;text-decoration:none}.post-actions{display:flex;align-items:center;gap:12px;margin-top:12px;font-size:10.5px}.post-actions .link{font-size:10.5px;color:var(--muted)}.post-actions .link:hover{color:var(--fg)}.md>:first-child{margin-top:0}.md>:last-child{margin-bottom:0}.md p{margin:8px 0}.md code{font-family:var(--mono);font-size:.9em;background:#f0f2f5;padding:1px 5px;border-radius:3px}.md pre{background:#f7f8fa;border:1px solid var(--line);border-radius:4px;padding:10px 12px;overflow-x:auto}.md pre code{background:none;padding:0;font-size:11.5px}.md ul,.md ol{margin:8px 0;padding-left:24px}.md blockquote{margin:8px 0;padding-left:12px;border-left:2px solid var(--line-2);color:var(--muted)}.md h1,.md h2,.md h3{font-size:1.05em;margin:12px 0 4px}.settings{max-width:780px;margin:0 auto;padding:24px 40px 64px;font-family:var(--prose);font-size:13px;line-height:1.6;color:var(--fg-2)}.settings .back{display:inline-block;font-family:var(--mono);font-size:11.5px;text-decoration:none;margin-bottom:16px}.settings h1{font-family:var(--mono);font-size:15px;margin:32px 0 8px}.settings h2{font-family:var(--mono);font-size:13px;margin:28px 0 8px}.settings code{font-family:var(--mono);font-size:.92em;background:#f0f2f5;padding:1px 5px;border-radius:3px}.inbox-toggle{display:block;margin:8px 0}.trust-add{display:flex;gap:8px;margin:20px 0 12px}.trust-add input{flex:1;font:inherit;padding:7px 10px;color:var(--fg);border:1px solid var(--line-2);border-radius:4px;background:var(--surface)}.trust-table{border-collapse:collapse;width:100%}.trust-table td{padding:6px 10px 6px 0;border-bottom:1px solid var(--line);vertical-align:baseline}.trust-actions{text-align:right}.trust-deny code{text-decoration:line-through}.tone-approve{color:var(--green)}.tone-comment{color:var(--amber)}.tone-changes{color:var(--red)}.tone-running,.tone-sent,.tone-none{color:var(--muted);font-weight:400}.tone-failed{color:var(--red)}.tone-awaiting{color:var(--accent)}.tone-bg-approve{background:var(--green)}.tone-bg-comment{background:var(--amber)}.tone-bg-changes{background:var(--red)}.tone-border-approve{border-left:2px solid var(--green)}.tone-border-comment{border-left:2px solid var(--amber)}.tone-border-changes{border-left:2px solid var(--red)}.tone-border-none{border-left:2px solid var(--line-2)}.chip{font-size:11.5px;font-weight:500;padding:3px 9px;border-radius:3px;border:1px solid currentColor;background:#14171a08;white-space:nowrap}.chip-btn{display:inline-flex;align-items:center;gap:6px;font-family:inherit}.chip-btn:hover{background:#14171a12}.tag{font-size:10px;letter-spacing:.06em;padding:1px 6px;border-radius:3px;color:var(--muted);background:#f0f1f3;white-space:nowrap}.tag-yours{color:var(--accent);background:#3d4fd617}.tag-kept{color:var(--green);background:#1a7f5a1a}.tag-drift{color:var(--amber);background:#a06a001a}
10
+ */.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id{color:#005cc5}.hljs-regexp,.hljs-string,.hljs-meta .hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-comment,.hljs-code,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}:root{--bg: #fcfcfd;--surface: #ffffff;--fg: #14171a;--fg-2: #3a3f45;--fg-3: #4a5058;--muted: #6b7178;--faint: #9aa0a8;--fainter: #b6bbc1;--line: #e6e8eb;--line-2: #dcdfe4;--line-3: #eef0f2;--accent: #3d4fd6;--accent-dark: #2c3aa8;--accent-wash: #f4f5ff;--green: #1a7f5a;--amber: #a06a00;--red: #c2314a;--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;--prose: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Helvetica, sans-serif;--measure: 1560px}*{box-sizing:border-box}body{margin:0;font-family:var(--mono);font-size:12px;color:var(--fg);background:var(--bg)}a{color:var(--accent)}a:hover{color:var(--accent-dark)}h1,h2,h3{font-size:13px;font-weight:700;margin:0}.app{padding-bottom:48px}.wrap{max-width:var(--measure);margin:0 auto;padding:0 40px}.bar-inner{max-width:var(--measure);margin:0 auto;padding:0 40px;display:flex;align-items:center;gap:14px}.grow{flex-grow:1}.pad{padding:24px 40px}.muted,.faint{color:var(--faint)}.warn{color:var(--amber)}.danger{color:var(--red)}.add{color:var(--green)}.del{color:var(--red)}.icon{flex-shrink:0}.lab{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint)}.key{font-size:10px;border:1px solid var(--line-2);border-bottom-width:2px;border-radius:3px;padding:1px 5px;color:var(--muted);background:var(--surface)}.hint{display:inline-flex;gap:5px;align-items:center;font-size:10.5px;color:var(--faint);white-space:nowrap}.prose{font-family:var(--prose);font-size:13px;line-height:1.6;color:var(--fg-2);text-wrap:pretty;max-width:720px}.prose.lead{font-size:13.5px;line-height:1.62;color:var(--fg)}.error{color:var(--red);background:#c2314a0f;border:1px solid rgba(194,49,74,.3);padding:8px 12px;border-radius:4px;margin:10px 0}.empty{text-align:center;color:var(--faint);margin:64px auto;flex-direction:column}.empty pre{display:inline-block;background:var(--line-3);padding:8px 16px;border-radius:4px}button{font:inherit;cursor:pointer}.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;font-size:11px;color:var(--fg-3);border:1px solid var(--line-2);border-radius:4px;padding:6px 10px;background:var(--surface);white-space:nowrap;text-decoration:none}.btn:hover:not(:disabled){color:var(--fg);border-color:var(--fainter)}.btn:disabled{opacity:.5;cursor:default}.btn-sm{font-size:10.5px;padding:3px 8px;gap:5px}.btn-accent{color:var(--accent);border-color:#3d4fd659}.btn-keep-on{color:#fff;background:var(--green);border-color:var(--green)}.btn-drop-on{color:var(--red);border-color:#c2314a66}.btn-dark{color:#fff;background:var(--fg);border-color:var(--fg);font-size:11.5px;padding:7px 14px}.btn-dark:disabled{color:var(--faint);background:#f0f1f3;border-color:var(--line-2)}.btn-dashed{display:inline-flex;align-items:center;gap:5px;font-size:10.5px;color:var(--muted);border:1px dashed var(--line-2);border-radius:4px;padding:5px 10px;background:transparent;margin-top:10px}.btn-primary{display:flex;align-items:center;justify-content:center;gap:8px;height:40px;padding:0 14px;font-size:12px;font-weight:500;color:#fff;background:var(--accent);border:1px solid var(--accent);border-radius:4px}.btn-primary .key{border-color:#ffffff4d;color:#ffffffbf;background:transparent}.link{display:inline-flex;align-items:center;gap:5px;font:inherit;font-size:11px;color:var(--accent);background:none;border:none;padding:0;text-decoration:none}.link:hover:not(:disabled){color:var(--accent-dark)}.link:disabled{color:var(--faint);cursor:default}.topbar{border-bottom:1px solid var(--line);background:var(--surface)}.topbar .bar-inner{padding-top:14px;padding-bottom:14px}.brand{display:inline-flex;align-items:center;gap:7px;font-size:15px;font-weight:700;letter-spacing:-.02em;color:var(--fg);text-decoration:none}.brand .logo{color:var(--accent);flex-shrink:0}.tagline{font-size:11px;color:var(--faint)}.topbar-link{font-size:11px;color:var(--muted);text-decoration:none;border-left:1px solid var(--line);padding-left:14px;display:inline-flex;align-items:center}.topbar-link:hover{color:var(--fg)}.bell{background:none;border:0;border-left:1px solid var(--line);padding:0 0 0 14px;font-family:inherit;cursor:pointer}.bell-on,.bell-ask{color:var(--accent)}.bell-blocked{color:var(--fainter);cursor:help}.daemon-bar{border-bottom:1px solid var(--line);background:#f7f8fa;font-size:11px;color:var(--muted)}.daemon-bar .bar-inner{padding-top:8px;padding-bottom:8px;gap:18px;flex-wrap:wrap}.daemon-bar b{color:var(--fg);font-weight:400}.daemon-live{display:inline-flex;align-items:center;gap:6px;color:var(--fg)}.daemon-bar .warn{display:inline-flex;align-items:center;gap:6px}.dot{width:5px;height:5px;border-radius:999px;background:var(--green);display:inline-block}.dot-off{background:var(--fainter)}.tabs{display:flex;align-items:center;gap:2px;padding-top:14px}.tab{font-size:12px;padding:6px 12px;border:none;border-bottom:2px solid transparent;border-radius:4px 4px 0 0;background:none;color:var(--muted)}.tab:hover{color:var(--fg)}.tab-on{color:var(--fg);font-weight:500;border-bottom-color:var(--accent)}.tab-count{color:var(--fainter);margin-left:4px}.tab-on .tab-count{color:var(--accent)}.table-head,.row{display:flex;gap:12px;align-items:center}.table-head{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);padding:12px 10px 7px;border-bottom:1px solid var(--line-2)}.row{font-size:12px;padding:8px 10px;cursor:pointer;border-bottom:1px solid var(--line-3);border-left:2px solid transparent}.row:hover{background:#f7f8fa}.row-on{background:var(--accent-wash);border-left-color:var(--accent)}.row-on .col-caret{color:var(--accent)}.row-on .col-title{font-weight:500}.tag-done{flex-shrink:0;color:var(--muted);background:var(--line-3)}.tag-awaiting{flex-shrink:0;color:var(--amber);background:#a06a001a}.pull{display:flex;align-items:center;gap:6px;position:relative;color:var(--muted)}.tabs .pull{margin-right:16px}.empty .pull{justify-content:center;margin-top:10px}.pull:hover,.pull:focus-within{color:var(--fg-3)}.pull-input{width:200px;max-width:100%;font:inherit;font-size:12px;padding:2px 0;color:var(--fg);background:transparent;border:none;border-bottom:1px solid transparent;transition:width .12s ease,border-color .12s ease}.pull-input:hover::placeholder{color:var(--fg)}.pull-input:focus{outline:none;width:320px;border-bottom-color:var(--accent)}.pull-input::placeholder{color:var(--muted);transition:color .12s ease}.btn-small{padding:2px 8px;font-size:10px}.pull-error{position:absolute;top:100%;right:0;z-index:5;margin-top:4px;max-width:360px;padding:5px 8px;font-size:11px;line-height:1.4;color:var(--red);background:var(--bg);border:1px solid var(--line-2);border-radius:4px;box-shadow:0 2px 8px #14171a14}.tag-draft{flex-shrink:0;color:var(--muted);background:var(--line-3)}.col-caret{width:12px;flex-shrink:0;color:transparent}.col-slug{width:184px;flex-shrink:0;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.col-title{flex-grow:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.col-author{width:104px;flex-shrink:0;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.col-verdict{width:136px;flex-shrink:0;text-align:right;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.col-comments{width:26px;flex-shrink:0;text-align:right;color:var(--muted)}.col-size{width:104px;flex-shrink:0;text-align:right;color:var(--muted)}.col-age{width:44px;flex-shrink:0;text-align:right;color:var(--faint)}.table-head .col-verdict,.table-head .col-slug,.table-head .col-author{color:var(--faint);font-weight:400}.cursor-strip{display:flex;gap:18px;align-items:flex-start;border:1px solid var(--line-2);border-top:none;border-radius:0 0 4px 4px;background:var(--surface);padding:14px 16px}.cursor-strip-body{flex-grow:1;min-width:0}.cursor-strip-body .prose{margin:6px 0 0;max-width:700px}.cursor-strip-meta{font-size:10.5px;color:var(--faint);margin-top:8px}.cursor-strip-actions{display:flex;flex-direction:column;gap:6px;width:176px;flex-shrink:0}.cursor-strip-row{display:flex;gap:6px}.cursor-strip-row .btn{flex-grow:1;padding:7px 0}.drawers{margin-top:16px;padding-bottom:8px}.drawer+.drawer{margin-top:6px}.drawer-toggle{font-size:11.5px;color:var(--muted);background:none;border:none;padding:4px 0}.drawer-toggle:hover{color:var(--fg)}.drawer-rows{margin:8px 0 14px}.row-settled{opacity:.62}.row-settled:hover,.row-awaiting{opacity:1}.review-top{position:sticky;top:0;z-index:20;background:var(--surface)}.crumb{border-bottom:1px solid var(--line);background:var(--surface);font-size:11.5px;color:var(--muted)}.crumb .bar-inner{padding-top:12px;padding-bottom:12px;gap:12px}.crumb a{text-decoration:none}.crumb-sep{color:var(--line-2)}.crumb-slug{color:var(--fg)}.crumb-next{font-size:11px;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.walk{display:inline-flex;gap:2px}.walk-btn{display:inline-flex;align-items:center;justify-content:center;padding:4px 6px;border:1px solid var(--line-2);border-radius:3px;background:var(--surface);color:var(--fg-3)}.walk-btn:disabled{color:#d4d7db;border-color:var(--line-3);cursor:default}.review-head{border-bottom:1px solid var(--line);background:var(--surface)}.review-head .wrap{padding-top:20px;padding-bottom:16px}.review-head-top,.review-head-meta{display:flex;align-items:center;gap:14px}.review-head-top{margin-bottom:8px}.review-title{font-size:19px;font-weight:700;letter-spacing:-.025em;color:var(--fg);text-decoration:none}.review-title:hover{color:var(--accent)}.review-head-meta{font-size:11px;color:var(--muted);gap:10px;flex-wrap:wrap}.verdict-pick{display:inline-flex;align-items:center;gap:5px;font-size:10.5px;padding:4px 9px;border-radius:4px;border:1px solid transparent;background:none;color:var(--faint);white-space:nowrap}.verdict-pick:hover{color:var(--fg-3)}.verdict-pick-on{background:#f0f1f3;border-color:var(--line-2);font-weight:500}.review-body{display:flex;align-items:flex-start;gap:0}.rail{width:236px;flex-shrink:0;padding:22px 20px 22px 0;position:sticky;top:var(--top-h, 0px);z-index:5;align-self:flex-start;max-height:calc(100vh - var(--top-h, 0px));overflow-y:auto}.chapter,.comment,.verdict-bar{scroll-margin-top:calc(var(--top-h, 0px) + 12px)}.rail-lab{display:block;margin-top:22px}.rail .lab{margin-bottom:10px}.rail-item{display:flex;align-items:center;gap:6px;width:100%;text-align:left;font:inherit;font-size:11.5px;line-height:1.5;padding:5px 0 5px 9px;margin-left:-11px;background:none;border:none;border-left:2px solid transparent;color:var(--muted)}.rail-item:hover{color:var(--fg)}.rail-item-on{border-left-color:var(--accent);color:var(--fg);font-weight:500}.rail-group{margin-bottom:2px}.rail-count{font-size:10.5px;color:var(--fainter)}.rail-comment{display:flex;align-items:center;gap:6px;width:100%;font:inherit;font-size:11px;text-align:left;padding:3px 0 3px 20px;margin-left:-11px;background:none;border:none;border-left:2px solid transparent;color:var(--muted)}.rail-comment:hover{color:var(--fg)}.rail-comment-loc{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rail-comment-dropped{opacity:.55}.rail-comment-dropped .rail-comment-loc{text-decoration:line-through}.rail-dot{font-size:8px}.comment-flash{animation:comment-flash 1.8s ease-out}@keyframes comment-flash{0%,60%{background:var(--accent-wash);box-shadow:inset 0 0 0 2px #3d4fd673}}.rail-facts{font-size:11.5px;line-height:1.85;color:var(--muted)}.rail-facts b{color:var(--fg);font-weight:400}.main{flex-grow:1;min-width:0;padding:22px 0 0}.card{border:1px solid var(--line);border-radius:4px;background:var(--surface);margin-bottom:10px}.card-head{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;padding:11px 16px;border-bottom:1px solid var(--line-3)}.card-head .faint{font-size:11px}.card-body{padding:14px 16px}.card-body .lab{display:block;margin-bottom:7px}.card-body .prose{margin:0}.card-body .btn{margin-top:12px}.card-why{margin-bottom:8px}.chapter{border:1px solid var(--line);border-radius:4px;background:var(--surface);margin-bottom:10px;overflow:hidden}.chapter-head{display:flex;align-items:center;gap:10px;padding:11px 16px;cursor:pointer;-webkit-user-select:none;user-select:none}.chapter-body{padding:14px 16px 16px;border-top:1px solid var(--line-3)}.chapter-meta{font-size:11px;font-weight:400}.chapter-explanation{margin:0 0 14px}.comment{border:1px solid var(--line);border-radius:4px;background:#fbfbfc;padding:12px 14px;margin:8px 0}.comment-dropped{opacity:.5}.comment-dropped .comment-body{text-decoration:line-through}.comment-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px}.comment-loc{font-size:10.5px;color:var(--faint)}.comment-body{font-size:13px;line-height:1.58;color:var(--fg);max-width:640px}.comment-edit,.add-comment textarea,.chat-input{width:100%;font-family:var(--mono);font-size:12.5px;line-height:1.55;color:var(--fg);padding:10px 12px;border:1px solid var(--line-2);border-radius:4px;background:var(--surface);resize:vertical}.add-comment{border:1px dashed var(--line-2);border-radius:4px;padding:10px 12px;margin:10px 0;display:flex;flex-direction:column;gap:8px}.add-comment-row{display:flex;gap:8px;align-items:center}.add-comment select,.add-comment input{font:inherit;font-size:11.5px;padding:5px 7px;border:1px solid var(--line-2);border-radius:4px;background:var(--surface)}.inline-comment-row td{padding:0;background:#fbfbfc;border-top:1px solid var(--line);border-bottom:1px solid var(--line);white-space:normal}.inline-comment-row .comment{margin:0;border:none;border-radius:0;padding-left:44px;max-width:none}.diff{margin-top:8px;font-size:11.5px}.diff .d2h-file-header,.diff .d2h-file-name{font-family:var(--mono);font-size:11px}.diff .hljs{display:inline;padding:0;background:transparent;color:inherit}.freshness{background:#a06a0012;border:1px solid rgba(160,106,0,.3);color:#57430a;padding:10px 12px;border-radius:4px;margin-bottom:10px;line-height:1.55;font-size:11.5px}.freshness code{background:#0000000f;padding:1px 4px;border-radius:3px}.freshness-running{background:#3d4fd60f;border-color:#3d4fd64d;color:#26307a}.freshness .btn{margin-left:8px}.chat-panel{margin:18px 0 10px}.chat-log{display:flex;flex-direction:column;gap:14px;margin-bottom:14px}.chat-turn{border-left:2px solid var(--line-2);padding:2px 0 2px 12px}.chat-turn-user{border-left-color:var(--accent)}.chat-turn-pending{border-left-style:dashed}.chat-turn-who{font-size:10.5px;color:var(--faint);margin-bottom:4px}.chat-turn-body{max-width:700px;color:var(--fg)}.chat-revision{font-size:10.5px;color:var(--green);margin-top:6px}.chat-refusal{font-size:10.5px;color:var(--amber);margin-top:6px}.chat-waiting{font-size:11.5px;color:var(--muted);margin-top:6px}.chat-progress{list-style:none;margin:0;padding:0}.chat-progress li{opacity:.55;padding:1px 0}.chat-progress li.now{opacity:1}.chat-progress li.now:after{content:"…"}.chat-failed{font-size:11.5px;color:var(--red);margin-top:6px}.chat-refs{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:8px}.chat-ref{font-size:10.5px;font-family:var(--mono);padding:3px 9px;border:1px solid var(--line-2);border-radius:999px;color:var(--muted);background:transparent}.chat-refs-pending .chat-ref{cursor:pointer;color:var(--accent);border-color:#3d4fd659}.chat-actions{display:flex;align-items:center;gap:12px;margin-top:10px}.send-panel{border:1px solid var(--line-2);border-radius:4px;background:#f7f8fa;padding:16px 18px}.verdict-bar+.send-panel{border-radius:0 0 4px 4px}.send-top{display:flex;align-items:baseline;gap:12px;margin-bottom:14px}.send-top .faint{font-size:11px}.send-top .link{text-decoration:underline;text-underline-offset:2px}.send-action{display:flex;align-items:center;gap:16px;flex-wrap:wrap}.send-btn{display:inline-flex;align-items:center;gap:10px;height:44px;padding:0 20px;font-size:13px;font-weight:500;color:#fff;border:none;border-radius:4px;box-shadow:0 1px 2px #14171a1f}.send-btn:disabled{opacity:.6;cursor:default}.send-btn .key{border-color:#ffffff4d;color:#fffc;background:transparent}.send-as{position:relative}.send-as-toggle{font:inherit;font-size:11.5px;color:var(--fg-3);background:none;border:none;padding:0}.send-as-toggle b{color:var(--fg);font-weight:500;border-bottom:1px dotted var(--fainter)}.send-as-why{font-size:10.5px;color:var(--faint);margin-top:3px}.menu{position:absolute;top:34px;left:0;z-index:10;background:var(--surface);border:1px solid var(--line-2);border-radius:4px;box-shadow:0 6px 18px #14171a17;padding:4px;min-width:190px}.menu-item{display:flex;align-items:center;gap:9px;width:100%;font:inherit;font-size:11.5px;text-align:left;padding:7px 9px;border:none;border-radius:3px;background:none;color:var(--fg-3)}.menu-item:hover{background:#f7f8fa}.menu-item-on{background:#f4f5f7;color:var(--fg)}.menu-check{font-size:10px;color:var(--green)}.verdict-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;border:1px solid var(--line-2);border-bottom:none;border-radius:4px 4px 0 0;background:var(--surface);padding:10px 18px;margin-top:18px}.verdict-bar .lab{margin-right:4px}.verdict-bar .faint{font-size:10.5px}.send-footer{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:16px;padding-top:14px;border-top:1px solid var(--line-2);font-size:10.5px}.body-preview{margin-top:14px;padding:14px 16px;background:var(--surface);border:1px solid var(--line);border-radius:4px;font-family:var(--prose);font-size:12.5px;line-height:1.62;color:var(--fg-2);white-space:pre-wrap;max-width:760px;max-height:420px;overflow:auto}.sent-strip{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;border:1px solid rgba(26,127,90,.4);background:#1a7f5a12;border-radius:4px;padding:14px 16px;font-size:11.5px}.sent-strip strong{color:var(--green);font-size:13px;white-space:nowrap}.sent-strip a{display:inline-flex;align-items:center;gap:5px;white-space:nowrap;text-decoration:none}.post-actions{display:flex;align-items:center;gap:12px;margin-top:12px;font-size:10.5px}.post-actions .link{font-size:10.5px;color:var(--muted)}.post-actions .link:hover{color:var(--fg)}.md>:first-child{margin-top:0}.md>:last-child{margin-bottom:0}.md p{margin:8px 0}.md code{font-family:var(--mono);font-size:.9em;background:#f0f2f5;padding:1px 5px;border-radius:3px}.md pre{background:#f7f8fa;border:1px solid var(--line);border-radius:4px;padding:10px 12px;overflow-x:auto}.md pre code{background:none;padding:0;font-size:11.5px}.md ul,.md ol{margin:8px 0;padding-left:24px}.md blockquote{margin:8px 0;padding-left:12px;border-left:2px solid var(--line-2);color:var(--muted)}.md h1,.md h2,.md h3{font-size:1.05em;margin:12px 0 4px}.settings{max-width:780px;margin:0 auto;padding:24px 40px 64px;font-family:var(--prose);font-size:13px;line-height:1.6;color:var(--fg-2)}.settings .back{display:inline-block;font-family:var(--mono);font-size:11.5px;text-decoration:none;margin-bottom:16px}.settings h1{font-family:var(--mono);font-size:15px;margin:32px 0 8px}.settings h2{font-family:var(--mono);font-size:13px;margin:28px 0 8px}.settings code{font-family:var(--mono);font-size:.92em;background:#f0f2f5;padding:1px 5px;border-radius:3px}.inbox-toggle{display:block;margin:8px 0}.trust-add{display:flex;gap:8px;margin:20px 0 12px}.trust-add input{flex:1;font:inherit;padding:7px 10px;color:var(--fg);border:1px solid var(--line-2);border-radius:4px;background:var(--surface)}.trust-table{border-collapse:collapse;width:100%}.trust-table td{padding:6px 10px 6px 0;border-bottom:1px solid var(--line);vertical-align:baseline}.trust-actions{text-align:right}.trust-deny code{text-decoration:line-through}.tone-approve{color:var(--green)}.tone-comment{color:var(--amber)}.tone-changes{color:var(--red)}.tone-running,.tone-sent,.tone-none{color:var(--muted);font-weight:400}.tone-failed{color:var(--red)}.tone-awaiting{color:var(--accent)}.tone-bg-approve{background:var(--green)}.tone-bg-comment{background:var(--amber)}.tone-bg-changes{background:var(--red)}.tone-border-approve{border-left:2px solid var(--green)}.tone-border-comment{border-left:2px solid var(--amber)}.tone-border-changes{border-left:2px solid var(--red)}.tone-border-none{border-left:2px solid var(--line-2)}.chip{font-size:11.5px;font-weight:500;padding:3px 9px;border-radius:3px;border:1px solid currentColor;background:#14171a08;white-space:nowrap}.chip-btn{display:inline-flex;align-items:center;gap:6px;font-family:inherit}.chip-btn:hover{background:#14171a12}.tag{font-size:10px;letter-spacing:.06em;padding:1px 6px;border-radius:3px;color:var(--muted);background:#f0f1f3;white-space:nowrap}.tag-yours{color:var(--accent);background:#3d4fd617}.tag-kept{color:var(--green);background:#1a7f5a1a}.tag-drift{color:var(--amber);background:#a06a001a}.tag-sev-blocker{color:var(--red);background:#c2314a1a;font-weight:600}.tag-sev-minor{color:var(--amber);background:#a06a001a}.tag-sev-nit{color:var(--muted);background:#f0f1f3}.sev-counts{font-size:12px;color:var(--muted);margin-bottom:6px}.verdict-hint{display:flex;align-items:center;gap:10px;flex-wrap:wrap;background:#a06a0012;border:1px solid rgba(160,106,0,.3);color:#57430a;padding:10px 12px;border-radius:4px}