@fullstackhouse/cerber 0.9.0 → 0.10.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 +38 -1
- package/dist/core/artifact.js +67 -0
- package/dist/core/gh.js +9 -3
- package/dist/core/revise.js +166 -0
- package/dist/runner/chat.js +215 -0
- package/dist/runner/claude.js +3 -0
- package/dist/runner/prompt.js +132 -0
- package/dist/runner/review.js +19 -1
- package/dist/server/daemon.js +2 -0
- package/dist/server/index.js +62 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-Dmz5lQnG.css → index-BGy7f14m.css} +1 -1
- package/web/dist/assets/index-CmpTqzKD.js +236 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CsWW0EBv.js +0 -236
package/dist/runner/prompt.js
CHANGED
|
@@ -93,6 +93,138 @@ ${diffBody}
|
|
|
93
93
|
`;
|
|
94
94
|
return { prompt, truncated };
|
|
95
95
|
}
|
|
96
|
+
/** Human names for what the user pointed at, so the model can find it. */
|
|
97
|
+
function describeRefs(artifact, refs) {
|
|
98
|
+
if (refs.length === 0)
|
|
99
|
+
return "";
|
|
100
|
+
const lines = refs.map((ref) => {
|
|
101
|
+
if (ref.target === "summary")
|
|
102
|
+
return "- the summary";
|
|
103
|
+
if (ref.target === "verdict")
|
|
104
|
+
return "- the verdict";
|
|
105
|
+
if (ref.target === "chapter") {
|
|
106
|
+
const ch = artifact.chapters.find((c) => c.id === ref.id);
|
|
107
|
+
return `- the chapter "${ch?.title ?? ref.id}" (chapterId: ${ref.id})`;
|
|
108
|
+
}
|
|
109
|
+
const c = artifact.comments.find((x) => x.id === ref.id);
|
|
110
|
+
return c
|
|
111
|
+
? `- the comment on ${c.path}:${c.line ?? "file"} (commentId: ${c.id})`
|
|
112
|
+
: `- comment ${ref.id}`;
|
|
113
|
+
});
|
|
114
|
+
return `\n## What the user is pointing at\n\n${lines.join("\n")}\n`;
|
|
115
|
+
}
|
|
116
|
+
/** The review as it stands right now, with the ids a revision has to name. */
|
|
117
|
+
function renderReview(artifact) {
|
|
118
|
+
const chapters = artifact.chapters
|
|
119
|
+
.map((ch) => `- ${ch.id} — ${ch.title}\n ${ch.explanation}`)
|
|
120
|
+
.join("\n");
|
|
121
|
+
const comments = artifact.comments
|
|
122
|
+
.map((c) => {
|
|
123
|
+
const mine = c.origin === "user" || c.editedByUser ? " [THE USER'S OWN WRITING]" : "";
|
|
124
|
+
const dropped = c.status === "dropped" ? " [dropped]" : "";
|
|
125
|
+
return `- ${c.id} — ${c.path}:${c.line ?? "file-level"}${mine}${dropped}\n ${c.body}`;
|
|
126
|
+
})
|
|
127
|
+
.join("\n");
|
|
128
|
+
const verdict = artifact.verdict
|
|
129
|
+
? `${artifact.verdict.recommendation} (confidence ${artifact.verdict.confidence})\n${artifact.verdict.reasoning}`
|
|
130
|
+
: "(none)";
|
|
131
|
+
return `## The review as it stands
|
|
132
|
+
|
|
133
|
+
### Summary
|
|
134
|
+
|
|
135
|
+
${artifact.summary || "(empty)"}
|
|
136
|
+
|
|
137
|
+
### Chapters
|
|
138
|
+
|
|
139
|
+
${chapters || "(none)"}
|
|
140
|
+
|
|
141
|
+
### Comments
|
|
142
|
+
|
|
143
|
+
${comments || "(none)"}
|
|
144
|
+
|
|
145
|
+
### Verdict
|
|
146
|
+
|
|
147
|
+
${verdict}
|
|
148
|
+
`;
|
|
149
|
+
}
|
|
150
|
+
function renderTranscript(chat) {
|
|
151
|
+
if (chat.length === 0)
|
|
152
|
+
return "";
|
|
153
|
+
const turns = chat
|
|
154
|
+
.map((t) => `${t.role === "user" ? "User" : "You"}: ${t.body}`)
|
|
155
|
+
.join("\n\n");
|
|
156
|
+
return `\n## The conversation so far\n\n${turns}\n`;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The prompt for one turn of a conversation about a drafted review.
|
|
160
|
+
*
|
|
161
|
+
* The point of this over a one-shot "redo it" is that the turn resumes the
|
|
162
|
+
* review's own session, so the model can answer *why* it said something rather
|
|
163
|
+
* than re-deriving an opinion. When resume is unavailable the transcript and
|
|
164
|
+
* the review come along anyway, and the model is told it is arriving cold —
|
|
165
|
+
* confidently citing a file it cannot open is the failure mode to avoid.
|
|
166
|
+
*/
|
|
167
|
+
export function buildChatPrompt(artifact, message, opts = {}) {
|
|
168
|
+
const needsDiff = !opts.resumed;
|
|
169
|
+
const truncated = needsDiff && artifact.diff.length > MAX_DIFF_CHARS;
|
|
170
|
+
const diffSection = needsDiff
|
|
171
|
+
? `\n## Diff\n\n\`\`\`diff\n${truncated ? artifact.diff.slice(0, MAX_DIFF_CHARS) + "\n[... diff truncated ...]" : artifact.diff}\n\`\`\`\n`
|
|
172
|
+
: "";
|
|
173
|
+
const note = opts.sourceNote ? `\n${opts.sourceNote}\n` : "";
|
|
174
|
+
const sourceSection = opts.source
|
|
175
|
+
? (opts.trusted ? TRUSTED_SECTION : SOURCE_SECTION) + note + "\n"
|
|
176
|
+
: `## No source checkout
|
|
177
|
+
|
|
178
|
+
The repository is NOT available to you. Every tool is off. Answer from the diff, the review, and this conversation — and when the honest answer is "I would have to look at the file to be sure", say that rather than describing code you cannot open.
|
|
179
|
+
`;
|
|
180
|
+
const cold = opts.resumed
|
|
181
|
+
? ""
|
|
182
|
+
: `\nYou are picking this review up cold: it was written in an earlier session you no longer hold. Everything you know about it is below. Do not claim to remember reading something — if the review asserts a thing you cannot now confirm, say so.\n`;
|
|
183
|
+
const prompt = `You are the code reviewer who wrote the review below, talking to the user who is about to send it.
|
|
184
|
+
|
|
185
|
+
They are not asking you to re-review the PR. They are questioning the draft you produced. Answer them honestly — defend what you actually checked, concede what you did not, and revise the review where they are right.
|
|
186
|
+
|
|
187
|
+
Respond with ONLY a JSON object (no markdown fences, no prose before or after) matching exactly this shape:
|
|
188
|
+
|
|
189
|
+
{
|
|
190
|
+
"reply": "markdown — what you say back to the user. Plain, direct, 1-3 short paragraphs.",
|
|
191
|
+
"revisions": [
|
|
192
|
+
{ "kind": "summary", "body": "the rewritten summary" },
|
|
193
|
+
{ "kind": "verdict", "verdict": { "recommendation": "approve" | "comment" | "request_changes", "confidence": 0-100, "reasoning": "..." } },
|
|
194
|
+
{ "kind": "chapter", "chapterId": "...", "title": "optional new title", "explanation": "optional new explanation" },
|
|
195
|
+
{ "kind": "comment-edit", "commentId": "...", "body": "the rewritten comment" },
|
|
196
|
+
{ "kind": "comment-drop", "commentId": "..." },
|
|
197
|
+
{ "kind": "comment-add", "path": "path/to/file.ts", "line": 42, "body": "...", "chapterId": "..." }
|
|
198
|
+
]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
Rules:
|
|
202
|
+
- Revise when the user is right, and only then. "revisions" is usually empty or has one entry; an empty array with a reply that explains your reasoning is a perfectly good turn.
|
|
203
|
+
- Do NOT revise merely to seem agreeable. If you checked something and the user is wrong about it, say so and change nothing. A reviewer that folds under every push-back is worthless.
|
|
204
|
+
- Never revise something the user did not raise. Fix what they pointed at, not everything you would now write differently.
|
|
205
|
+
- 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
|
+
- Every "commentId" and "chapterId" must be one listed below. Never invent one.
|
|
207
|
+
- "line" is a line in the NEW version of the file and must appear in the diff. Use null for a file-level comment.
|
|
208
|
+
- 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
|
+
|
|
210
|
+
Writing style:
|
|
211
|
+
- Plain words, short sentences, no jargon. Someone who has NOT read the diff should follow you.
|
|
212
|
+
- Lead with the point. State the claim first, support it after.
|
|
213
|
+
- Do NOT imitate the PR description's writing style, and do not restate the author's claims as your own findings. If the description asserts something, it is a claim to check, not a fact to repeat.
|
|
214
|
+
${cold}
|
|
215
|
+
${sourceSection}## Pull request
|
|
216
|
+
|
|
217
|
+
Repo: ${artifact.pr.owner}/${artifact.pr.repo}
|
|
218
|
+
PR #${artifact.pr.number}: ${artifact.pr.title}
|
|
219
|
+
Author: ${artifact.pr.author}
|
|
220
|
+
|
|
221
|
+
${renderReview(artifact)}${describeRefs(artifact, opts.refs ?? [])}${renderTranscript(artifact.chat)}${diffSection}
|
|
222
|
+
## The user's message
|
|
223
|
+
|
|
224
|
+
${message}
|
|
225
|
+
`;
|
|
226
|
+
return { prompt, truncated };
|
|
227
|
+
}
|
|
96
228
|
export function buildRetryPrompt(originalPrompt, badOutput, error) {
|
|
97
229
|
return `${originalPrompt}
|
|
98
230
|
|
package/dist/runner/review.js
CHANGED
|
@@ -121,10 +121,17 @@ async function runReview(ref, opts) {
|
|
|
121
121
|
error: null,
|
|
122
122
|
withSource: source !== null,
|
|
123
123
|
trusted: trusted && source !== null,
|
|
124
|
+
sessionId: null,
|
|
124
125
|
},
|
|
125
126
|
sent: null,
|
|
126
127
|
refresh: null,
|
|
127
128
|
calibration: null,
|
|
129
|
+
// The conversation is the user's writing, so a re-review keeps it — the
|
|
130
|
+
// chat prompt replays the transcript, which is what makes it survive the
|
|
131
|
+
// session change. The snapshot does NOT carry over: it describes the draft
|
|
132
|
+
// this run just replaced, and resetting to it would restore a dead review.
|
|
133
|
+
chat: existing?.chat ?? [],
|
|
134
|
+
preChat: null,
|
|
128
135
|
};
|
|
129
136
|
await saveArtifact(artifact);
|
|
130
137
|
const { prompt, truncated } = buildReviewPrompt(pr, diff, { source: source !== null, trusted });
|
|
@@ -170,6 +177,9 @@ async function runReview(ref, opts) {
|
|
|
170
177
|
error: null,
|
|
171
178
|
withSource: source !== null,
|
|
172
179
|
trusted: trusted && source !== null,
|
|
180
|
+
// Only worth keeping when there is a checkout to resume into: a session
|
|
181
|
+
// whose working directory was empty has nothing a chat turn could read.
|
|
182
|
+
sessionId: source ? review.sessionId : null,
|
|
173
183
|
},
|
|
174
184
|
};
|
|
175
185
|
// A re-review regenerates the AI's comments, but the human's own comments
|
|
@@ -236,17 +246,25 @@ async function runAiReview(prompt, opts, source, trusted) {
|
|
|
236
246
|
try {
|
|
237
247
|
const first = await runClaude(prompt, claudeOpts);
|
|
238
248
|
try {
|
|
239
|
-
return {
|
|
249
|
+
return {
|
|
250
|
+
ai: AiReviewSchema.parse(extractJson(first.text)),
|
|
251
|
+
costUsd: first.costUsd,
|
|
252
|
+
model: first.model,
|
|
253
|
+
sessionId: first.sessionId,
|
|
254
|
+
};
|
|
240
255
|
}
|
|
241
256
|
catch (err) {
|
|
242
257
|
const message = err instanceof Error ? err.message : String(err);
|
|
243
258
|
opts.onProgress?.("Model output failed validation — retrying once…");
|
|
259
|
+
// A fresh run, not a resume: the retry prompt already carries the whole
|
|
260
|
+
// original prompt, so resuming would send the diff twice.
|
|
244
261
|
const second = await runClaude(buildRetryPrompt(prompt, first.text, message), claudeOpts);
|
|
245
262
|
const cost = (first.costUsd ?? 0) + (second.costUsd ?? 0);
|
|
246
263
|
return {
|
|
247
264
|
ai: AiReviewSchema.parse(extractJson(second.text)),
|
|
248
265
|
costUsd: cost > 0 ? cost : null,
|
|
249
266
|
model: second.model,
|
|
267
|
+
sessionId: second.sessionId ?? first.sessionId,
|
|
250
268
|
};
|
|
251
269
|
}
|
|
252
270
|
}
|
package/dist/server/daemon.js
CHANGED
package/dist/server/index.js
CHANGED
|
@@ -17,6 +17,9 @@ import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
|
17
17
|
import { listArtifacts, loadArtifactByKey, reconcileRunning, updateArtifactByKey, } from "../core/state.js";
|
|
18
18
|
import { isReviewRunning } from "../runner/inflight.js";
|
|
19
19
|
import { reviewPr } from "../runner/review.js";
|
|
20
|
+
import { runChatTurn } from "../runner/chat.js";
|
|
21
|
+
import { mergeConcurrentEdits, restoreReview } from "../core/revise.js";
|
|
22
|
+
import { ChatTurnSchema } from "../core/artifact.js";
|
|
20
23
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
24
|
export async function buildApp(opts) {
|
|
22
25
|
const app = new Hono();
|
|
@@ -294,6 +297,7 @@ export async function buildApp(opts) {
|
|
|
294
297
|
error: null,
|
|
295
298
|
withSource: withSource !== false,
|
|
296
299
|
trusted: false,
|
|
300
|
+
sessionId: null,
|
|
297
301
|
},
|
|
298
302
|
}));
|
|
299
303
|
// Minutes-long: run it detached and let the cockpit poll the artifact.
|
|
@@ -315,6 +319,64 @@ export async function buildApp(opts) {
|
|
|
315
319
|
});
|
|
316
320
|
return c.json(running, 202);
|
|
317
321
|
});
|
|
322
|
+
// ---- Chat: argue with the review before sending it ----
|
|
323
|
+
// Local only. The transcript never reaches GitHub — Send still builds its
|
|
324
|
+
// payload from the summary, comments and verdict alone.
|
|
325
|
+
const ChatRequestSchema = z.object({
|
|
326
|
+
message: z.string().min(1),
|
|
327
|
+
refs: ChatTurnSchema.shape.refs.optional(),
|
|
328
|
+
/** The user said, in so many words, that the run may touch their comments. */
|
|
329
|
+
allowUserComments: z.boolean().optional(),
|
|
330
|
+
});
|
|
331
|
+
app.post("/api/reviews/:key/chat", async (c) => {
|
|
332
|
+
const key = c.req.param("key");
|
|
333
|
+
const artifact = await loadArtifactByKey(key);
|
|
334
|
+
if (!artifact)
|
|
335
|
+
return c.json({ error: "not found" }, 404);
|
|
336
|
+
// A sent review is a record of what was posted — arguing with it now would
|
|
337
|
+
// rewrite history that GitHub already has.
|
|
338
|
+
if (artifact.sent)
|
|
339
|
+
return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
|
|
340
|
+
if (isReviewRunning(artifact.id)) {
|
|
341
|
+
return c.json({ error: "a run for this PR is already in flight" }, 409);
|
|
342
|
+
}
|
|
343
|
+
let request;
|
|
344
|
+
try {
|
|
345
|
+
request = ChatRequestSchema.parse(await c.req.json());
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
const result = await runChatTurn(artifact, request.message, {
|
|
352
|
+
refs: request.refs,
|
|
353
|
+
allowUserComments: request.allowUserComments,
|
|
354
|
+
onProgress: (m) => console.log(`[chat ${artifact.id}] ${m}`),
|
|
355
|
+
});
|
|
356
|
+
// A turn takes minutes and the cockpit stays live throughout, so fold the
|
|
357
|
+
// result onto whatever is on disk now rather than overwriting it — an
|
|
358
|
+
// edit the user made while waiting must not vanish when the answer lands.
|
|
359
|
+
const saved = await updateArtifactByKey(key, (current) => mergeConcurrentEdits(artifact, result.artifact, current));
|
|
360
|
+
return c.json({ artifact: saved, applied: result.applied, refused: result.refused });
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 502);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
// Put the review back the way it was before the conversation started. The
|
|
367
|
+
// conversation itself stays — you lose the edits, not the reasoning.
|
|
368
|
+
app.post("/api/reviews/:key/chat/reset", async (c) => {
|
|
369
|
+
const key = c.req.param("key");
|
|
370
|
+
const artifact = await loadArtifactByKey(key);
|
|
371
|
+
if (!artifact)
|
|
372
|
+
return c.json({ error: "not found" }, 404);
|
|
373
|
+
if (artifact.sent)
|
|
374
|
+
return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
|
|
375
|
+
if (!artifact.preChat)
|
|
376
|
+
return c.json({ error: "this review has not been chatted about" }, 409);
|
|
377
|
+
const updated = await updateArtifactByKey(key, (a) => a.preChat ? restoreReview(a, a.preChat) : a);
|
|
378
|
+
return c.json(updated);
|
|
379
|
+
});
|
|
318
380
|
// ---- Export (local file download) ----
|
|
319
381
|
app.get("/api/reviews/:key/export", async (c) => {
|
|
320
382
|
const artifact = await loadArtifactByKey(c.req.param("key"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullstackhouse/cerber",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "AI code-review cockpit: Claude reviews your PRs into local artifacts; nothing reaches GitHub until you say so.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": "github:fullstackhouse/cerber",
|
|
@@ -7,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: #ffffff;--fg: #1f2328;--muted: #656d76;--border: #d0d7de;--green: #1a7f37;--red: #cf222e;--yellow: #9a6700;--accent: #0969da}*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;color:var(--fg);background:var(--bg)}.app{max-width:1100px;margin:0 auto;padding:0 16px 64px}.topbar{display:flex;align-items:baseline;gap:12px;padding:16px 0;border-bottom:1px solid var(--border);margin-bottom:16px}.brand{font-size:20px;font-weight:700;text-decoration:none;color:var(--fg)}.tagline{color:var(--muted);font-size:13px}a{color:var(--accent)}.muted{color:var(--muted);font-size:.92em}.error{color:var(--red);background:#ffebe9;border:1px solid rgba(207,34,46,.3);padding:8px 12px;border-radius:6px}.empty{text-align:center;color:var(--muted);margin-top:64px}.empty pre{display:inline-block;background:#f6f8fa;padding:8px 16px;border-radius:6px}.md{line-height:1.55}.md>:first-child{margin-top:0}.md>:last-child{margin-bottom:0}.md p{margin:8px 0}.md code{font-family:ui-monospace,SFMono-Regular,monospace;font-size:.9em;background:#f0f2f5;padding:1px 5px;border-radius:4px}.md pre{background:#f6f8fa;border:1px solid var(--border);border-radius:6px;padding:10px 12px;overflow-x:auto}.md pre code{background:none;padding:0;font-size:12px}.md ul,.md ol{margin:8px 0;padding-left:24px}.md blockquote{margin:8px 0;padding-left:12px;border-left:3px solid var(--border);color:var(--muted)}.md h1,.md h2,.md h3{font-size:1.05em;margin:12px 0 4px}.daemon-strip{background:#ddf4ff;border:1px solid rgba(9,105,218,.3);border-radius:6px;padding:6px 12px;margin-bottom:12px;font-size:13px;display:flex;gap:12px;flex-wrap:wrap}.queue-stats{display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap}.queue{width:100%;border-collapse:collapse;font-size:14px}.queue th{text-align:left;color:var(--muted);font-weight:600;padding:6px 8px;border-bottom:1px solid var(--border)}.queue td{padding:8px;border-bottom:1px solid var(--border);vertical-align:top}.pr-link{font-weight:600;text-decoration:none}.status{font-size:12px;padding:2px 8px;border-radius:999px;border:1px solid var(--border);white-space:nowrap}.status-ready{background:#dafbe1;border-color:#1a7f3766}.status-running{background:#ddf4ff;border-color:#0969da66}.status-failed{background:#ffebe9;border-color:#cf222e66}.status-awaiting{background:#fff8c5;border-color:#9a670066}.review-now{font-size:12px;padding:2px 10px}.row-archived{opacity:.6}.archived{margin-top:20px}.archived-toggle{background:none;border:none;color:var(--muted);font-size:13px;padding:4px 0;cursor:pointer}.inbox-toggle{display:block;margin:6px 0}.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)}.badge-warn{background:#fff8c5;color:var(--yellow)}.freshness{background:#fff8c5;border:1px solid rgba(154,103,0,.3);color:#57430a;padding:10px 12px;border-radius:6px;margin:12px 0;line-height:1.5}.freshness code{background:#0000000f;padding:1px 4px;border-radius:4px}.freshness-running{background:#ddf4ff;border-color:#0969da4d;color:#0a3069}.rerun-btn{margin-left:8px}.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;position:sticky;top:0;z-index:5;background:var(--bg);margin:0 -16px;padding:10px 16px;border-radius:6px 6px 0 0}.chapter-title-open{border-bottom:1px solid var(--border)}.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}.inline-comment-row td{padding:8px 12px;background:#f6f8fa;border-top:1px solid var(--border);border-bottom:1px solid var(--border);font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;white-space:normal}.inline-comment-row .comment{margin:0;max-width:820px;background:var(--bg)}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}.send-head{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:10px}.send-as{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:600;color:var(--muted)}.send-spacer{flex:1}.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:disabled{opacity:.6;cursor:default}.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}.diff .hljs{display:inline;padding:0;background:transparent;color:inherit}.topbar-link{margin-left:auto;font-size:13px;color:var(--muted);text-decoration:none}.topbar-link:hover{color:var(--fg);text-decoration:underline}.settings{max-width:760px}.settings h2{font-size:15px;margin-top:32px}.settings p{line-height:1.55}.trust-add{display:flex;gap:8px;margin:20px 0 12px}.trust-add input{flex:1;padding:7px 10px;font:inherit;color:var(--fg);background:var(--bg-alt, transparent);border:1px solid var(--border);border-radius:6px}.trust-table{border-collapse:collapse;width:100%}.trust-table td{padding:6px 10px 6px 0;border-bottom:1px solid var(--border);vertical-align:baseline}.trust-table td:last-child{text-align:right}.trust-deny code{text-decoration:line-through}
|
|
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: #ffffff;--fg: #1f2328;--muted: #656d76;--border: #d0d7de;--green: #1a7f37;--red: #cf222e;--yellow: #9a6700;--accent: #0969da}*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;color:var(--fg);background:var(--bg)}.app{max-width:1100px;margin:0 auto;padding:0 16px 64px}.topbar{display:flex;align-items:baseline;gap:12px;padding:16px 0;border-bottom:1px solid var(--border);margin-bottom:16px}.brand{font-size:20px;font-weight:700;text-decoration:none;color:var(--fg)}.tagline{color:var(--muted);font-size:13px}a{color:var(--accent)}.muted{color:var(--muted);font-size:.92em}.error{color:var(--red);background:#ffebe9;border:1px solid rgba(207,34,46,.3);padding:8px 12px;border-radius:6px}.empty{text-align:center;color:var(--muted);margin-top:64px}.empty pre{display:inline-block;background:#f6f8fa;padding:8px 16px;border-radius:6px}.md{line-height:1.55}.md>:first-child{margin-top:0}.md>:last-child{margin-bottom:0}.md p{margin:8px 0}.md code{font-family:ui-monospace,SFMono-Regular,monospace;font-size:.9em;background:#f0f2f5;padding:1px 5px;border-radius:4px}.md pre{background:#f6f8fa;border:1px solid var(--border);border-radius:6px;padding:10px 12px;overflow-x:auto}.md pre code{background:none;padding:0;font-size:12px}.md ul,.md ol{margin:8px 0;padding-left:24px}.md blockquote{margin:8px 0;padding-left:12px;border-left:3px solid var(--border);color:var(--muted)}.md h1,.md h2,.md h3{font-size:1.05em;margin:12px 0 4px}.daemon-strip{background:#ddf4ff;border:1px solid rgba(9,105,218,.3);border-radius:6px;padding:6px 12px;margin-bottom:12px;font-size:13px;display:flex;gap:12px;flex-wrap:wrap}.queue-stats{display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap}.queue{width:100%;border-collapse:collapse;font-size:14px}.queue th{text-align:left;color:var(--muted);font-weight:600;padding:6px 8px;border-bottom:1px solid var(--border)}.queue td{padding:8px;border-bottom:1px solid var(--border);vertical-align:top}.pr-link{font-weight:600;text-decoration:none}.status{font-size:12px;padding:2px 8px;border-radius:999px;border:1px solid var(--border);white-space:nowrap}.status-ready{background:#dafbe1;border-color:#1a7f3766}.status-running{background:#ddf4ff;border-color:#0969da66}.status-failed{background:#ffebe9;border-color:#cf222e66}.status-awaiting{background:#fff8c5;border-color:#9a670066}.review-now{font-size:12px;padding:2px 10px}.row-archived{opacity:.6}.archived{margin-top:20px}.archived-toggle{background:none;border:none;color:var(--muted);font-size:13px;padding:4px 0;cursor:pointer}.inbox-toggle{display:block;margin:6px 0}.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)}.badge-warn{background:#fff8c5;color:var(--yellow)}.freshness{background:#fff8c5;border:1px solid rgba(154,103,0,.3);color:#57430a;padding:10px 12px;border-radius:6px;margin:12px 0;line-height:1.5}.freshness code{background:#0000000f;padding:1px 4px;border-radius:4px}.freshness-running{background:#ddf4ff;border-color:#0969da4d;color:#0a3069}.rerun-btn{margin-left:8px}.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;position:sticky;top:0;z-index:5;background:var(--bg);margin:0 -16px;padding:10px 16px;border-radius:6px 6px 0 0}.chapter-title-open{border-bottom:1px solid var(--border)}.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}.inline-comment-row td{padding:8px 12px;background:#f6f8fa;border-top:1px solid var(--border);border-bottom:1px solid var(--border);font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;white-space:normal}.inline-comment-row .comment{margin:0;max-width:820px;background:var(--bg)}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}.send-head{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:10px}.send-as{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:600;color:var(--muted)}.send-spacer{flex:1}.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:disabled{opacity:.6;cursor:default}.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}.diff .hljs{display:inline;padding:0;background:transparent;color:inherit}.topbar-link{margin-left:auto;font-size:13px;color:var(--muted);text-decoration:none}.topbar-link:hover{color:var(--fg);text-decoration:underline}.settings{max-width:760px}.settings h2{font-size:15px;margin-top:32px}.settings p{line-height:1.55}.trust-add{display:flex;gap:8px;margin:20px 0 12px}.trust-add input{flex:1;padding:7px 10px;font:inherit;color:var(--fg);background:var(--bg-alt, transparent);border:1px solid var(--border);border-radius:6px}.trust-table{border-collapse:collapse;width:100%}.trust-table td{padding:6px 10px 6px 0;border-bottom:1px solid var(--border);vertical-align:baseline}.trust-table td:last-child{text-align:right}.trust-deny code{text-decoration:line-through}.chat-panel{border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin:24px 0}.chat-panel h2{margin:0}.chat-head{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:10px}.chat-log{display:flex;flex-direction:column;gap:14px;margin-bottom:14px}.chat-turn{border-left:3px solid var(--border);padding-left:12px}.chat-turn-user{border-left-color:var(--accent)}.chat-turn-who{font-size:12px;font-weight:600;color:var(--muted);margin-bottom:2px}.chat-turn-body p:first-child{margin-top:0}.chat-turn-body p:last-child{margin-bottom:0}.chat-revision{font-size:13px;color:var(--green);margin-top:4px}.chat-refusal{font-size:13px;color:var(--yellow);margin-top:4px}.chat-refs{display:flex;align-items:baseline;gap:6px;flex-wrap:wrap;margin-bottom:6px}.chat-ref{font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;padding:2px 6px;border:1px solid var(--border);border-radius:999px;color:var(--muted);background:transparent}.chat-refs-pending .chat-ref{cursor:pointer;color:var(--accent);border-color:var(--accent)}.chat-input{width:100%;box-sizing:border-box;padding:8px 10px;font:inherit;color:var(--fg);background:transparent;border:1px solid var(--border);border-radius:6px;resize:vertical}.chat-actions{display:flex;align-items:center;gap:10px;margin-top:8px}.chat-reset{margin-left:auto}.discuss-btn{margin:8px 0;font-size:13px}
|