Commit422a2d4unknown_key
feat(ai-review-trio): parallel security + correctness + style reviewers with disagreement detection
feat(ai-review-trio): parallel security + correctness + style reviewers with disagreement detection Adds a three-Claude parallel PR review path. Each persona (security, correctness, style) gets a distinct system prompt and runs concurrently via Promise.all, then the trio writes one prComment per reviewer plus a top-level summary comment that highlights disagreements — cases where one persona flags a file/line as fail while another would have said pass. Wired into the existing AI review trigger behind AI_TRIO_REVIEW_ENABLED=1 (default off). When the flag is on, triggerAiReview() delegates to runTrioReview() instead of the single-Claude reviewDiff path. Outcomes are recorded via the audit() helper as ai.review.trio. Surfaces a polished 3-column card grid on the PR detail page (scoped .trio-* CSS) with red/amber/blue fail accents per persona and a yellow callout strip when reviewers disagree. https://claude.ai/code/session_01KdpgsaGpmEExJAz3yhFLvx
4 files changed+1442−2422a2d44b08844ae0096b94e9c77e10087ff0f03
4 changed files+1442−2
Addedsrc/__tests__/ai-review-trio.test.ts+565−0View fileUnifiedSplit
@@ -0,0 +1,565 @@
1/**
2 * Tests for src/lib/ai-review-trio.ts — the parallel security /
3 * correctness / style reviewer pipeline.
4 *
5 * Layered:
6 *
7 * 1. Pure helpers — disagreement detection, finding normalisation,
8 * comment-body rendering. No DB, no network. Always run.
9 *
10 * 2. Persona-runner test seam — verify all three personas are invoked
11 * in parallel with their own system prompts. Always run (uses the
12 * `__setPersonaRunnerForTests` override).
13 *
14 * 3. DB-backed pipeline — gated on HAS_DB. Spins up a real user,
15 * repository, and pull request row, then asserts that all four
16 * prComments (3 personas + 1 summary) land with the correct
17 * markers.
18 */
19
20import {
21 afterEach,
22 beforeEach,
23 describe,
24 expect,
25 it,
26} from "bun:test";
27import { randomBytes } from "crypto";
28
29import {
30 __setPersonaRunnerForTests,
31 __test,
32 TRIO_COMMENT_MARKER,
33 TRIO_SUMMARY_MARKER,
34 alreadyTrioReviewed,
35 computeDisagreements,
36 isTrioReviewEnabled,
37 runTrioReview,
38 type PersonaRunner,
39 type TrioPersona,
40 type TrioVerdict,
41} from "../lib/ai-review-trio";
42
43const HAS_DB = Boolean(process.env.DATABASE_URL);
44
45// ---------------------------------------------------------------------------
46// Reset the persona runner between every test so leaks across files
47// (or across `it` blocks) can't silently mask real bugs.
48// ---------------------------------------------------------------------------
49
50beforeEach(() => {
51 __setPersonaRunnerForTests(null);
52});
53
54afterEach(() => {
55 __setPersonaRunnerForTests(null);
56});
57
58// ---------------------------------------------------------------------------
59// 1. isTrioReviewEnabled — flag plumbing.
60// ---------------------------------------------------------------------------
61
62describe("isTrioReviewEnabled", () => {
63 it("returns false when AI_TRIO_REVIEW_ENABLED is unset", () => {
64 const prev = process.env.AI_TRIO_REVIEW_ENABLED;
65 delete process.env.AI_TRIO_REVIEW_ENABLED;
66 try {
67 expect(isTrioReviewEnabled()).toBe(false);
68 } finally {
69 if (prev !== undefined) process.env.AI_TRIO_REVIEW_ENABLED = prev;
70 }
71 });
72
73 it("returns true when AI_TRIO_REVIEW_ENABLED=1", () => {
74 const prev = process.env.AI_TRIO_REVIEW_ENABLED;
75 process.env.AI_TRIO_REVIEW_ENABLED = "1";
76 try {
77 expect(isTrioReviewEnabled()).toBe(true);
78 } finally {
79 if (prev === undefined) delete process.env.AI_TRIO_REVIEW_ENABLED;
80 else process.env.AI_TRIO_REVIEW_ENABLED = prev;
81 }
82 });
83
84 it("returns false for AI_TRIO_REVIEW_ENABLED=0 or other values", () => {
85 const prev = process.env.AI_TRIO_REVIEW_ENABLED;
86 process.env.AI_TRIO_REVIEW_ENABLED = "0";
87 try {
88 expect(isTrioReviewEnabled()).toBe(false);
89 } finally {
90 if (prev === undefined) delete process.env.AI_TRIO_REVIEW_ENABLED;
91 else process.env.AI_TRIO_REVIEW_ENABLED = prev;
92 }
93 });
94});
95
96// ---------------------------------------------------------------------------
97// 2. computeDisagreements — pure helper.
98// ---------------------------------------------------------------------------
99
100function mkVerdict(
101 persona: TrioPersona,
102 findings: Array<{ file: string | null; line: number | null; issue: string }>,
103 verdict: "pass" | "fail" = "fail"
104): TrioVerdict {
105 return {
106 persona,
107 verdict,
108 findings: findings.map((f) => ({
109 severity: "medium",
110 file: f.file,
111 line: f.line,
112 issue: f.issue,
113 fix: "",
114 })),
115 rawText: "",
116 latencyMs: 0,
117 failed: false,
118 };
119}
120
121describe("computeDisagreements", () => {
122 it("returns [] when no findings exist", () => {
123 const out = computeDisagreements({
124 securityVerdict: mkVerdict("security", [], "pass"),
125 correctnessVerdict: mkVerdict("correctness", [], "pass"),
126 styleVerdict: mkVerdict("style", [], "pass"),
127 });
128 expect(out).toEqual([]);
129 });
130
131 it("returns [] when all three flag the same file:line (unanimous)", () => {
132 const findings = [{ file: "src/a.ts", line: 10, issue: "x" }];
133 const out = computeDisagreements({
134 securityVerdict: mkVerdict("security", findings),
135 correctnessVerdict: mkVerdict("correctness", findings),
136 styleVerdict: mkVerdict("style", findings),
137 });
138 expect(out).toEqual([]);
139 });
140
141 it("detects disagreement when only one persona flags a location", () => {
142 const out = computeDisagreements({
143 securityVerdict: mkVerdict("security", [
144 { file: "src/a.ts", line: 10, issue: "sql injection" },
145 ]),
146 correctnessVerdict: mkVerdict("correctness", [], "pass"),
147 styleVerdict: mkVerdict("style", [], "pass"),
148 });
149 expect(out.length).toBe(1);
150 expect(out[0].file).toBe("src/a.ts");
151 expect(out[0].line).toBe(10);
152 expect(out[0].failingPersonas).toEqual(["security"]);
153 expect(out[0].passingPersonas.sort()).toEqual(["correctness", "style"]);
154 });
155
156 it("detects disagreement when two personas flag and one is silent", () => {
157 const out = computeDisagreements({
158 securityVerdict: mkVerdict("security", [
159 { file: "src/a.ts", line: 10, issue: "x" },
160 ]),
161 correctnessVerdict: mkVerdict("correctness", [
162 { file: "src/a.ts", line: 10, issue: "y" },
163 ]),
164 styleVerdict: mkVerdict("style", [], "pass"),
165 });
166 expect(out.length).toBe(1);
167 expect(out[0].failingPersonas.sort()).toEqual([
168 "correctness",
169 "security",
170 ]);
171 expect(out[0].passingPersonas).toEqual(["style"]);
172 });
173
174 it("ignores findings without a file (can't attribute)", () => {
175 const out = computeDisagreements({
176 securityVerdict: mkVerdict("security", [
177 { file: null, line: null, issue: "ambient" },
178 ]),
179 correctnessVerdict: mkVerdict("correctness", [], "pass"),
180 styleVerdict: mkVerdict("style", [], "pass"),
181 });
182 expect(out).toEqual([]);
183 });
184
185 it("sorts disagreements by file then line for stable rendering", () => {
186 const out = computeDisagreements({
187 securityVerdict: mkVerdict("security", [
188 { file: "src/z.ts", line: 1, issue: "z1" },
189 { file: "src/a.ts", line: 20, issue: "a20" },
190 { file: "src/a.ts", line: 5, issue: "a5" },
191 ]),
192 correctnessVerdict: mkVerdict("correctness", [], "pass"),
193 styleVerdict: mkVerdict("style", [], "pass"),
194 });
195 expect(out.map((d) => `${d.file}:${d.line}`)).toEqual([
196 "src/a.ts:5",
197 "src/a.ts:20",
198 "src/z.ts:1",
199 ]);
200 });
201});
202
203// ---------------------------------------------------------------------------
204// 3. normaliseFinding — pure helper.
205// ---------------------------------------------------------------------------
206
207describe("__test.normaliseFinding", () => {
208 it("accepts a fully-formed finding", () => {
209 const out = __test.normaliseFinding({
210 severity: "high",
211 file: "src/a.ts",
212 line: 42,
213 issue: "boom",
214 fix: "do x",
215 });
216 expect(out).not.toBeNull();
217 expect(out?.severity).toBe("high");
218 expect(out?.file).toBe("src/a.ts");
219 expect(out?.line).toBe(42);
220 });
221
222 it("defaults severity to medium when missing", () => {
223 const out = __test.normaliseFinding({ issue: "x" });
224 expect(out?.severity).toBe("medium");
225 });
226
227 it("rejects findings with no issue text", () => {
228 expect(__test.normaliseFinding({ severity: "high" })).toBeNull();
229 expect(__test.normaliseFinding(null)).toBeNull();
230 expect(__test.normaliseFinding("not an object")).toBeNull();
231 });
232
233 it("falls back to `description` when `issue` is absent", () => {
234 const out = __test.normaliseFinding({ description: "from desc" });
235 expect(out?.issue).toBe("from desc");
236 });
237
238 it("nulls out non-integer or negative line numbers", () => {
239 expect(__test.normaliseFinding({ issue: "x", line: 1.5 })?.line).toBeNull();
240 expect(__test.normaliseFinding({ issue: "x", line: -3 })?.line).toBeNull();
241 expect(__test.normaliseFinding({ issue: "x", line: 0 })?.line).toBeNull();
242 });
243});
244
245// ---------------------------------------------------------------------------
246// 4. Persona-runner — three calls, distinct prompts, parallel.
247// ---------------------------------------------------------------------------
248
249describe("runTrioReview — persona runner", () => {
250 it("invokes all three personas with their own system prompts", async () => {
251 const seen: TrioPersona[] = [];
252 const runner: PersonaRunner = async ({ persona }) => {
253 seen.push(persona);
254 return {
255 text: JSON.stringify({ verdict: "pass", findings: [] }),
256 inputTokens: 0,
257 outputTokens: 0,
258 };
259 };
260 __setPersonaRunnerForTests(runner);
261
262 // No DB → persistence will silently fail when the PR lookup
263 // returns nothing; that's the documented behaviour. We only
264 // assert the runner side here.
265 const result = await runTrioReview({
266 pullRequestId: "00000000-0000-0000-0000-000000000000",
267 headSha: "abc123",
268 diff: "diff --git a b\n+pass",
269 });
270
271 expect(seen.sort()).toEqual(["correctness", "security", "style"]);
272 expect(result.securityVerdict.verdict).toBe("pass");
273 expect(result.correctnessVerdict.verdict).toBe("pass");
274 expect(result.styleVerdict.verdict).toBe("pass");
275 });
276
277 it("returns canned per-persona verdicts and computes disagreements", async () => {
278 const runner: PersonaRunner = async ({ persona }) => {
279 if (persona === "security") {
280 return {
281 text: JSON.stringify({
282 verdict: "fail",
283 findings: [
284 {
285 severity: "high",
286 file: "src/auth.ts",
287 line: 42,
288 issue: "SQL injection",
289 fix: "Use parameterised queries",
290 },
291 ],
292 }),
293 inputTokens: 100,
294 outputTokens: 50,
295 };
296 }
297 if (persona === "correctness") {
298 return {
299 text: JSON.stringify({
300 verdict: "fail",
301 findings: [
302 {
303 severity: "medium",
304 file: "src/auth.ts",
305 line: 42,
306 issue: "Missing await",
307 fix: "Add await",
308 },
309 ],
310 }),
311 inputTokens: 100,
312 outputTokens: 50,
313 };
314 }
315 // style — passes
316 return {
317 text: JSON.stringify({ verdict: "pass", findings: [] }),
318 inputTokens: 80,
319 outputTokens: 20,
320 };
321 };
322 __setPersonaRunnerForTests(runner);
323
324 const result = await runTrioReview({
325 pullRequestId: "00000000-0000-0000-0000-000000000000",
326 headSha: "abc123",
327 diff: "diff --git a b\n+oops",
328 });
329
330 expect(result.securityVerdict.verdict).toBe("fail");
331 expect(result.correctnessVerdict.verdict).toBe("fail");
332 expect(result.styleVerdict.verdict).toBe("pass");
333 expect(result.disagreements.length).toBe(1);
334 expect(result.disagreements[0].file).toBe("src/auth.ts");
335 expect(result.disagreements[0].failingPersonas.sort()).toEqual([
336 "correctness",
337 "security",
338 ]);
339 expect(result.disagreements[0].passingPersonas).toEqual(["style"]);
340 });
341
342 it("fail-closes when the runner throws", async () => {
343 const runner: PersonaRunner = async () => {
344 throw new Error("boom");
345 };
346 __setPersonaRunnerForTests(runner);
347
348 const result = await runTrioReview({
349 pullRequestId: "00000000-0000-0000-0000-000000000000",
350 headSha: "abc123",
351 diff: "",
352 });
353
354 expect(result.securityVerdict.failed).toBe(true);
355 expect(result.securityVerdict.verdict).toBe("fail");
356 expect(result.correctnessVerdict.failed).toBe(true);
357 expect(result.styleVerdict.failed).toBe(true);
358 });
359
360 it("fail-closes when the runner returns unparseable JSON", async () => {
361 const runner: PersonaRunner = async () => ({
362 text: "totally not json {{{",
363 inputTokens: 0,
364 outputTokens: 0,
365 });
366 __setPersonaRunnerForTests(runner);
367
368 const result = await runTrioReview({
369 pullRequestId: "00000000-0000-0000-0000-000000000000",
370 headSha: "abc123",
371 diff: "",
372 });
373
374 expect(result.securityVerdict.failed).toBe(true);
375 expect(result.securityVerdict.verdict).toBe("fail");
376 });
377});
378
379// ---------------------------------------------------------------------------
380// 5. Comment-body rendering — marker presence + verdict word.
381// ---------------------------------------------------------------------------
382
383describe("__test.renderPersonaCommentBody", () => {
384 it("embeds the security marker + Pass word for a pass verdict", () => {
385 const body = __test.renderPersonaCommentBody(
386 mkVerdict("security", [], "pass")
387 );
388 expect(body).toContain(TRIO_COMMENT_MARKER.security);
389 expect(body).toContain("Pass");
390 });
391
392 it("renders findings as a bulleted list", () => {
393 const body = __test.renderPersonaCommentBody(
394 mkVerdict("security", [
395 { file: "src/a.ts", line: 1, issue: "boom" },
396 ])
397 );
398 expect(body).toContain("src/a.ts:1");
399 expect(body).toContain("boom");
400 });
401
402 it("notes when the call failed", () => {
403 const body = __test.renderPersonaCommentBody({
404 ...mkVerdict("style", [], "fail"),
405 failed: true,
406 });
407 expect(body.toLowerCase()).toContain("fail-closed");
408 });
409});
410
411describe("__test.renderSummaryCommentBody", () => {
412 it("embeds the summary marker and lists all three verdicts", () => {
413 const body = __test.renderSummaryCommentBody({
414 securityVerdict: mkVerdict("security", [], "pass"),
415 correctnessVerdict: mkVerdict("correctness", [], "pass"),
416 styleVerdict: mkVerdict("style", [], "pass"),
417 disagreements: [],
418 });
419 expect(body).toContain(TRIO_SUMMARY_MARKER);
420 expect(body).toContain("security");
421 expect(body).toContain("correctness");
422 expect(body).toContain("style");
423 expect(body).toContain("All three reviewers agree");
424 });
425
426 it("formats disagreements clearly", () => {
427 const body = __test.renderSummaryCommentBody({
428 securityVerdict: mkVerdict("security", [
429 { file: "src/a.ts", line: 10, issue: "x" },
430 ]),
431 correctnessVerdict: mkVerdict("correctness", [], "pass"),
432 styleVerdict: mkVerdict("style", [], "pass"),
433 disagreements: [
434 {
435 file: "src/a.ts",
436 line: 10,
437 failingPersonas: ["security"],
438 passingPersonas: ["correctness", "style"],
439 },
440 ],
441 });
442 expect(body).toContain("src/a.ts:10");
443 expect(body).toContain("security");
444 expect(body).toContain("say ✗");
445 expect(body).toContain("say ✓");
446 });
447});
448
449// ---------------------------------------------------------------------------
450// 6. DB-backed — full pipeline inserts 4 prComments with correct markers.
451// ---------------------------------------------------------------------------
452
453describe.skipIf(!HAS_DB)("runTrioReview — DB persistence", () => {
454 it.skipIf(!HAS_DB)(
455 "inserts 3 persona comments + 1 summary, all isAiReview=true, with correct markers",
456 async () => {
457 const { db } = await import("../db");
458 const { users, repositories, pullRequests, prComments } = await import(
459 "../db/schema"
460 );
461 const { eq } = await import("drizzle-orm");
462
463 const stamp = randomBytes(4).toString("hex");
464 const username = `trio-${stamp}`;
465 const reponame = `trio-${stamp}`;
466
467 const [u] = await db
468 .insert(users)
469 .values({
470 username,
471 email: `${username}@test.local`,
472 passwordHash: "x",
473 })
474 .returning();
475 if (!u) return;
476
477 const [r] = await db
478 .insert(repositories)
479 .values({
480 name: reponame,
481 ownerId: u.id,
482 diskPath: `/tmp/${reponame}.git`,
483 defaultBranch: "main",
484 })
485 .returning();
486 if (!r) return;
487
488 const [pr] = await db
489 .insert(pullRequests)
490 .values({
491 repositoryId: r.id,
492 authorId: u.id,
493 title: "Test trio PR",
494 body: "Testing the trio review pipeline.",
495 baseBranch: "main",
496 headBranch: "feature",
497 })
498 .returning();
499 if (!pr) return;
500
501 // Canned runner: security fails on src/a.ts:10, others pass.
502 const runner: PersonaRunner = async ({ persona }) => {
503 if (persona === "security") {
504 return {
505 text: JSON.stringify({
506 verdict: "fail",
507 findings: [
508 {
509 severity: "high",
510 file: "src/a.ts",
511 line: 10,
512 issue: "Hard-coded secret",
513 fix: "Move to env var",
514 },
515 ],
516 }),
517 inputTokens: 0,
518 outputTokens: 0,
519 };
520 }
521 return {
522 text: JSON.stringify({ verdict: "pass", findings: [] }),
523 inputTokens: 0,
524 outputTokens: 0,
525 };
526 };
527 __setPersonaRunnerForTests(runner);
528
529 const result = await runTrioReview({
530 pullRequestId: pr.id,
531 headSha: "abc123",
532 diff: "diff --git a b",
533 repositoryId: r.id,
534 });
535
536 expect(result.securityVerdict.verdict).toBe("fail");
537 expect(result.correctnessVerdict.verdict).toBe("pass");
538 expect(result.styleVerdict.verdict).toBe("pass");
539 expect(result.disagreements.length).toBe(1);
540
541 // Fetch persisted comments — expect exactly 4 (3 personas + summary).
542 const comments = await db
543 .select()
544 .from(prComments)
545 .where(eq(prComments.pullRequestId, pr.id));
546
547 expect(comments.length).toBe(4);
548 // Every comment must be flagged as AI.
549 expect(comments.every((c) => c.isAiReview === true)).toBe(true);
550
551 // Each persona marker should appear exactly once across the 4.
552 const bodies = comments.map((c) => c.body);
553 const hasMarker = (m: string) =>
554 bodies.filter((b) => b.includes(m)).length;
555 expect(hasMarker(TRIO_COMMENT_MARKER.security)).toBe(1);
556 expect(hasMarker(TRIO_COMMENT_MARKER.correctness)).toBe(1);
557 expect(hasMarker(TRIO_COMMENT_MARKER.style)).toBe(1);
558 expect(hasMarker(TRIO_SUMMARY_MARKER)).toBe(1);
559
560 // alreadyTrioReviewed should now return true.
561 const seen = await alreadyTrioReviewed(pr.id);
562 expect(seen).toBe(true);
563 }
564 );
565});
Addedsrc/lib/ai-review-trio.ts+626−0View fileUnifiedSplit
@@ -0,0 +1,626 @@
1/**
2 * Three-Claude parallel PR review — security / correctness / style.
3 *
4 * Where `ai-review.ts` runs a single Claude pass with a generalist
5 * prompt, this module fans out three concurrent calls — each with a
6 * narrow persona and a stricter remit. The three verdicts are inserted
7 * as separate PR comments (one per reviewer) plus a top-level summary
8 * comment that highlights disagreements.
9 *
10 * When the personas disagree on the same file/line (one says fail, one
11 * says pass), that's a SIGNAL for a human reviewer — surfaced both in
12 * the summary comment and as a yellow callout strip in `pulls.tsx`.
13 *
14 * Hard rules (mirrors `ai-review.ts`):
15 * - Never throws at the boundary. Anthropic, JSON parse, and DB
16 * failures all fail-closed: the reviewer in question lands a
17 * verdict of `fail` with an empty findings list so a human still
18 * sees the attempt.
19 * - All DB writes are best-effort with breadcrumb logging.
20 * - Uses the shared Anthropic client (`getAnthropic` from `ai-client`)
21 * and the shared `audit()` helper from `notify`.
22 *
23 * Wiring: `ai-review.ts`'s `triggerAiReview()` consults
24 * `isTrioReviewEnabled()` (env `AI_TRIO_REVIEW_ENABLED=1`). When on,
25 * it delegates the whole AI review to `runTrioReview()` instead of the
26 * single-Claude path.
27 */
28
29import { eq, and, like } from "drizzle-orm";
30import { db } from "../db";
31import { pullRequests, prComments } from "../db/schema";
32import { getAnthropic, MODEL_SONNET, parseJsonResponse } from "./ai-client";
33import { audit } from "./notify";
34import { recordAiCost, extractUsage } from "./ai-cost-tracker";
35
36// ---------------------------------------------------------------------------
37// Public types
38// ---------------------------------------------------------------------------
39
40export type TrioPersona = "security" | "correctness" | "style";
41
42export type Verdict = "pass" | "fail";
43
44export interface TrioFinding {
45 severity: "low" | "medium" | "high" | "critical" | string;
46 file: string | null;
47 line: number | null;
48 issue: string;
49 fix: string;
50}
51
52export interface TrioVerdict {
53 persona: TrioPersona;
54 verdict: Verdict;
55 findings: TrioFinding[];
56 /** Raw text from Claude — kept for the comment body + debugging. */
57 rawText: string;
58 /** Anthropic latency in ms, observational only. */
59 latencyMs: number;
60 /**
61 * True when the call/parse failed and we synthesised a fail-closed
62 * verdict. The summary comment marks these so humans aren't misled.
63 */
64 failed: boolean;
65}
66
67export interface TrioDisagreement {
68 file: string;
69 line: number | null;
70 /** Personas that returned `fail` for this file/line. */
71 failingPersonas: TrioPersona[];
72 /** Personas that returned `pass` (i.e. had no finding here). */
73 passingPersonas: TrioPersona[];
74}
75
76export interface TrioReviewResult {
77 securityVerdict: TrioVerdict;
78 correctnessVerdict: TrioVerdict;
79 styleVerdict: TrioVerdict;
80 disagreements: TrioDisagreement[];
81}
82
83export interface RunTrioReviewOpts {
84 pullRequestId: string;
85 /** Resolved SHA of the PR head — recorded in the audit metadata. */
86 headSha: string;
87 /** Unified diff text. Will be truncated to `DIFF_BYTE_CAP`. */
88 diff: string;
89 /** Optional repository id for audit attribution. */
90 repositoryId?: string | null;
91 /** Optional override of the model id (tests may want haiku). */
92 model?: string;
93}
94
95// ---------------------------------------------------------------------------
96// Marker constants
97// ---------------------------------------------------------------------------
98
99/**
100 * Per-reviewer marker embedded in each persona's PR comment body.
101 * Used by `pulls.tsx` to render the three cards as a single grid.
102 */
103export const TRIO_COMMENT_MARKER: Record<TrioPersona, string> = {
104 security: "<!-- ai-trio:security -->",
105 correctness: "<!-- ai-trio:correctness -->",
106 style: "<!-- ai-trio:style -->",
107};
108
109/** Marker embedded in the trio summary comment. */
110export const TRIO_SUMMARY_MARKER = "<!-- ai-trio:summary -->";
111
112/** Cap on diff size we feed each reviewer — matches `ai-review.ts`. */
113const DIFF_BYTE_CAP = 100_000;
114
115/** Per-call max tokens. Findings are short JSON — 3k is plenty. */
116const MAX_TOKENS = 3072;
117
118// ---------------------------------------------------------------------------
119// Persona prompts — kept terse so each Claude stays in lane.
120// ---------------------------------------------------------------------------
121
122const SYSTEM_PROMPT_BASE = `You are a focused code reviewer on a pull request. Respond with ONLY valid JSON matching this exact shape:
123
124{
125 "verdict": "pass" | "fail",
126 "findings": [
127 {
128 "severity": "low" | "medium" | "high" | "critical",
129 "file": "path/to/file.ts",
130 "line": 42,
131 "issue": "short description of the problem",
132 "fix": "concrete suggested fix"
133 }
134 ]
135}
136
137Rules:
138- Return "verdict": "fail" if you found ANY finding worth flagging at your remit. Otherwise "pass" with an empty findings array.
139- "line" is the line number in the NEW file (right side of the diff), or null when you can't pin it.
140- Stay strictly inside your remit — do not flag issues that belong to another reviewer.
141- No prose outside the JSON. No code fences.`;
142
143const PERSONA_PROMPT: Record<TrioPersona, string> = {
144 security: `You are the SECURITY reviewer. Be paranoid. Find security issues:
145- SQL/NoSQL injection, command injection, path traversal
146- XSS (reflected, stored, DOM-based) and HTML-escaping gaps
147- Broken auth: missing session/token checks, IDOR, privilege escalation
148- Secret leaks (API keys, tokens, passwords committed to source)
149- Unsafe deserialization (eval, Function, untrusted JSON.parse into prototypes)
150- Crypto misuse (weak hashes, missing HMAC verification, IV reuse)
151- CSRF / SSRF / open redirects
152
153Do NOT flag style, naming, or non-security bugs.
154
155${SYSTEM_PROMPT_BASE}`,
156
157 correctness: `You are the CORRECTNESS reviewer. Find logic bugs:
158- Null/undefined dereference risks where input may not be guaranteed
159- Race conditions, missing await, unhandled promise rejection
160- Off-by-one errors in loops, slices, range checks
161- Missing error handling at system boundaries (fs, network, DB)
162- Wrong operator (== vs ===, & vs &&), inverted conditions
163- Resource leaks (unclosed handles, missing cleanup on error)
164- Type coercion bugs, NaN propagation, integer overflow
165
166Do NOT flag style, naming, security, or readability.
167
168${SYSTEM_PROMPT_BASE}`,
169
170 style: `You are the STYLE reviewer. Find readability + maintainability issues:
171- Inconsistent or unclear naming (vars, functions, types)
172- Missing JSDoc / docstring on newly-added public APIs
173- Functions over ~80 lines or cyclomatic complexity hotspots
174- Magic numbers without named constants
175- Duplicated code blocks that should be extracted
176- Deeply nested conditionals that hurt readability
177
178Do NOT flag security, correctness bugs, or trivial formatting (let the linter do that).
179
180${SYSTEM_PROMPT_BASE}`,
181};
182
183// ---------------------------------------------------------------------------
184// Test seam — let tests inject canned persona outputs instead of calling
185// the real Anthropic API. Pass `null` to reset.
186// ---------------------------------------------------------------------------
187
188export type PersonaRunner = (args: {
189 persona: TrioPersona;
190 diff: string;
191 model: string;
192}) => Promise<{ text: string; inputTokens: number; outputTokens: number }>;
193
194let _runnerOverride: PersonaRunner | null = null;
195
196export function __setPersonaRunnerForTests(fn: PersonaRunner | null): void {
197 _runnerOverride = fn;
198}
199
200// ---------------------------------------------------------------------------
201// Enablement
202// ---------------------------------------------------------------------------
203
204/**
205 * Whether the trio review path is on. Off by default; flip with
206 * `AI_TRIO_REVIEW_ENABLED=1`. Independent from `ANTHROPIC_API_KEY` —
207 * callers still need a key for the actual API calls, but tests can
208 * toggle the flag without a real key via the runner override.
209 */
210export function isTrioReviewEnabled(): boolean {
211 return process.env.AI_TRIO_REVIEW_ENABLED === "1";
212}
213
214/**
215 * Has trio already run on this PR? Detected by a prior summary marker.
216 */
217export async function alreadyTrioReviewed(prId: string): Promise<boolean> {
218 try {
219 const [row] = await db
220 .select({ id: prComments.id })
221 .from(prComments)
222 .where(
223 and(
224 eq(prComments.pullRequestId, prId),
225 eq(prComments.isAiReview, true),
226 like(prComments.body, `%${TRIO_SUMMARY_MARKER}%`)
227 )
228 )
229 .limit(1);
230 return !!row;
231 } catch {
232 return false;
233 }
234}
235
236// ---------------------------------------------------------------------------
237// Core runner
238// ---------------------------------------------------------------------------
239
240/**
241 * Run all three personas in parallel against the same diff, compute
242 * disagreements, persist four prComments (3 verdicts + 1 summary), and
243 * audit the outcome.
244 *
245 * Returns the structured trio result. Never throws.
246 */
247export async function runTrioReview(
248 opts: RunTrioReviewOpts
249): Promise<TrioReviewResult> {
250 const model = opts.model || MODEL_SONNET;
251 const diff =
252 opts.diff.length > DIFF_BYTE_CAP
253 ? opts.diff.slice(0, DIFF_BYTE_CAP)
254 : opts.diff;
255
256 // 1. Fan out the three persona calls.
257 const personas: TrioPersona[] = ["security", "correctness", "style"];
258 const [securityVerdict, correctnessVerdict, styleVerdict] = await Promise.all(
259 personas.map((p) => runOnePersona({ persona: p, diff, model }))
260 );
261
262 // 2. Compute disagreements.
263 const disagreements = computeDisagreements({
264 securityVerdict,
265 correctnessVerdict,
266 styleVerdict,
267 });
268
269 const result: TrioReviewResult = {
270 securityVerdict,
271 correctnessVerdict,
272 styleVerdict,
273 disagreements,
274 };
275
276 // 3. Persist comments (best-effort).
277 await persistTrioComments({
278 pullRequestId: opts.pullRequestId,
279 result,
280 });
281
282 // 4. Audit.
283 try {
284 await audit({
285 action: "ai.review.trio",
286 targetType: "pull_request",
287 targetId: opts.pullRequestId,
288 repositoryId: opts.repositoryId ?? null,
289 metadata: {
290 headSha: opts.headSha,
291 security: securityVerdict.verdict,
292 correctness: correctnessVerdict.verdict,
293 style: styleVerdict.verdict,
294 disagreements: disagreements.length,
295 failed: [
296 securityVerdict.failed ? "security" : null,
297 correctnessVerdict.failed ? "correctness" : null,
298 styleVerdict.failed ? "style" : null,
299 ].filter(Boolean),
300 },
301 });
302 } catch {
303 /* audit is observational only */
304 }
305
306 return result;
307}
308
309// ---------------------------------------------------------------------------
310// One persona — single Anthropic call + fail-closed JSON parse.
311// ---------------------------------------------------------------------------
312
313async function runOnePersona(args: {
314 persona: TrioPersona;
315 diff: string;
316 model: string;
317}): Promise<TrioVerdict> {
318 const t0 = Date.now();
319 let text = "";
320 let inputTokens = 0;
321 let outputTokens = 0;
322 let failed = false;
323
324 try {
325 if (_runnerOverride) {
326 const out = await _runnerOverride({
327 persona: args.persona,
328 diff: args.diff,
329 model: args.model,
330 });
331 text = out.text || "";
332 inputTokens = out.inputTokens || 0;
333 outputTokens = out.outputTokens || 0;
334 } else {
335 const client = getAnthropic();
336 const message = await client.messages.create({
337 model: args.model,
338 max_tokens: MAX_TOKENS,
339 system: PERSONA_PROMPT[args.persona],
340 messages: [
341 {
342 role: "user",
343 content: `Review this diff at your remit (${args.persona}). Return JSON only.\n\n\`\`\`diff\n${args.diff}\n\`\`\``,
344 },
345 ],
346 });
347 text =
348 message.content[0]?.type === "text" ? message.content[0].text : "";
349 const usage = extractUsage(message);
350 inputTokens = usage.input;
351 outputTokens = usage.output;
352 }
353 } catch (err) {
354 failed = true;
355 text = `__error__:${err instanceof Error ? err.message : String(err)}`;
356 }
357
358 // Best-effort cost capture (skipped when call failed and we have no usage).
359 if (!failed && (inputTokens || outputTokens)) {
360 try {
361 await recordAiCost({
362 model: args.model,
363 inputTokens,
364 outputTokens,
365 category: "ai_review",
366 sourceKind: "pull_request",
367 });
368 } catch {
369 /* observational */
370 }
371 }
372
373 const parsed = parseJsonResponse<{
374 verdict?: unknown;
375 findings?: unknown;
376 }>(text);
377
378 let verdict: Verdict = "fail"; // fail-closed default
379 let findings: TrioFinding[] = [];
380
381 if (parsed && typeof parsed === "object") {
382 if (parsed.verdict === "pass") verdict = "pass";
383 else if (parsed.verdict === "fail") verdict = "fail";
384 if (Array.isArray(parsed.findings)) {
385 findings = parsed.findings
386 .map((f) => normaliseFinding(f))
387 .filter((f): f is TrioFinding => !!f);
388 }
389 } else if (!failed) {
390 // Call succeeded but JSON parse failed → fail-closed.
391 failed = true;
392 }
393
394 return {
395 persona: args.persona,
396 verdict,
397 findings,
398 rawText: text,
399 latencyMs: Date.now() - t0,
400 failed,
401 };
402}
403
404function normaliseFinding(raw: unknown): TrioFinding | null {
405 if (!raw || typeof raw !== "object") return null;
406 const r = raw as Record<string, unknown>;
407 const issue =
408 typeof r.issue === "string"
409 ? r.issue
410 : typeof r.description === "string"
411 ? r.description
412 : "";
413 if (!issue) return null;
414 return {
415 severity:
416 typeof r.severity === "string" && r.severity.length > 0
417 ? r.severity
418 : "medium",
419 file: typeof r.file === "string" && r.file.length > 0 ? r.file : null,
420 line:
421 typeof r.line === "number" && Number.isInteger(r.line) && r.line > 0
422 ? r.line
423 : null,
424 issue,
425 fix: typeof r.fix === "string" ? r.fix : "",
426 };
427}
428
429// ---------------------------------------------------------------------------
430// Disagreement detection — file/line pairs where one persona flags `fail`
431// and another would have said `pass` (i.e. no finding at that location).
432// ---------------------------------------------------------------------------
433
434export function computeDisagreements(args: {
435 securityVerdict: TrioVerdict;
436 correctnessVerdict: TrioVerdict;
437 styleVerdict: TrioVerdict;
438}): TrioDisagreement[] {
439 const verdicts: TrioVerdict[] = [
440 args.securityVerdict,
441 args.correctnessVerdict,
442 args.styleVerdict,
443 ];
444
445 // Group findings by file (+ optional line) and record which personas
446 // hit each location.
447 const byKey = new Map<
448 string,
449 {
450 file: string;
451 line: number | null;
452 failingPersonas: Set<TrioPersona>;
453 }
454 >();
455
456 for (const v of verdicts) {
457 for (const f of v.findings) {
458 const file = f.file;
459 if (!file) continue; // can't disagree about an unattributable finding
460 const key = `${file}::${f.line ?? ""}`;
461 let bucket = byKey.get(key);
462 if (!bucket) {
463 bucket = { file, line: f.line, failingPersonas: new Set() };
464 byKey.set(key, bucket);
465 }
466 bucket.failingPersonas.add(v.persona);
467 }
468 }
469
470 const allPersonas: TrioPersona[] = ["security", "correctness", "style"];
471 const disagreements: TrioDisagreement[] = [];
472
473 for (const bucket of byKey.values()) {
474 // Disagreement = at least one persona flagged AND at least one
475 // persona did not. (All three flagging the same location is
476 // unanimous agreement, not a disagreement.)
477 if (
478 bucket.failingPersonas.size === 0 ||
479 bucket.failingPersonas.size === allPersonas.length
480 ) {
481 continue;
482 }
483 disagreements.push({
484 file: bucket.file,
485 line: bucket.line,
486 failingPersonas: Array.from(bucket.failingPersonas).sort() as TrioPersona[],
487 passingPersonas: allPersonas.filter(
488 (p) => !bucket.failingPersonas.has(p)
489 ),
490 });
491 }
492
493 // Stable sort: file then line then severity.
494 disagreements.sort((a, b) => {
495 if (a.file !== b.file) return a.file < b.file ? -1 : 1;
496 return (a.line ?? 0) - (b.line ?? 0);
497 });
498
499 return disagreements;
500}
501
502// ---------------------------------------------------------------------------
503// Comment persistence — 3 per-reviewer + 1 summary.
504// ---------------------------------------------------------------------------
505
506async function persistTrioComments(args: {
507 pullRequestId: string;
508 result: TrioReviewResult;
509}): Promise<void> {
510 // Need the PR's author id to satisfy `prComments.authorId NOT NULL`.
511 // (`ai-review.ts` uses the same pattern.)
512 let authorId: string | null = null;
513 try {
514 const [pr] = await db
515 .select({ authorId: pullRequests.authorId })
516 .from(pullRequests)
517 .where(eq(pullRequests.id, args.pullRequestId))
518 .limit(1);
519 if (pr) authorId = pr.authorId;
520 } catch {
521 /* tolerate */
522 }
523 if (!authorId) return; // can't post comments without an author id
524
525 const verdicts: TrioVerdict[] = [
526 args.result.securityVerdict,
527 args.result.correctnessVerdict,
528 args.result.styleVerdict,
529 ];
530
531 for (const v of verdicts) {
532 const body = renderPersonaCommentBody(v);
533 try {
534 await db.insert(prComments).values({
535 pullRequestId: args.pullRequestId,
536 authorId,
537 isAiReview: true,
538 body,
539 });
540 } catch (err) {
541 console.error(
542 `[ai-review-trio] persona ${v.persona} comment insert failed for PR ${args.pullRequestId}:`,
543 err instanceof Error ? err.message : err
544 );
545 }
546 }
547
548 // Top-level summary.
549 try {
550 await db.insert(prComments).values({
551 pullRequestId: args.pullRequestId,
552 authorId,
553 isAiReview: true,
554 body: renderSummaryCommentBody(args.result),
555 });
556 } catch (err) {
557 console.error(
558 `[ai-review-trio] summary insert failed for PR ${args.pullRequestId}:`,
559 err instanceof Error ? err.message : err
560 );
561 }
562}
563
564// ---------------------------------------------------------------------------
565// Comment body rendering — markdown that's also re-parseable.
566// ---------------------------------------------------------------------------
567
568function renderPersonaCommentBody(v: TrioVerdict): string {
569 const marker = TRIO_COMMENT_MARKER[v.persona];
570 const heading = `${marker}\n## AI ${v.persona[0].toUpperCase() + v.persona.slice(1)} Review — ${v.verdict === "pass" ? "Pass" : "Fail"}`;
571 if (v.failed) {
572 return `${heading}\n\n_AI review call failed; treating as fail-closed. A human reviewer should look at this PR._`;
573 }
574 if (v.findings.length === 0) {
575 return `${heading}\n\nNo ${v.persona} issues detected.`;
576 }
577 const lines = v.findings.map((f) => {
578 const loc = f.file
579 ? `\`${f.file}${f.line ? `:${f.line}` : ""}\``
580 : "_(unattributed)_";
581 return `- **${f.severity}** ${loc} — ${f.issue}${f.fix ? ` _Fix: ${f.fix}_` : ""}`;
582 });
583 return `${heading}\n\n${lines.join("\n")}`;
584}
585
586function renderSummaryCommentBody(r: TrioReviewResult): string {
587 const verdictLine = (v: TrioVerdict): string =>
588 `- **${v.persona}**: ${v.verdict === "pass" ? "✓ pass" : "✗ fail"}${v.failed ? " _(call failed)_" : ""} — ${v.findings.length} finding(s)`;
589
590 const disagreementLines =
591 r.disagreements.length === 0
592 ? "_All three reviewers agree on every flagged location._"
593 : r.disagreements
594 .map((d) => {
595 const loc = `\`${d.file}${d.line ? `:${d.line}` : ""}\``;
596 return `- ${loc} — ${d.failingPersonas.join(", ")} say ✗, ${d.passingPersonas.join(", ")} say ✓`;
597 })
598 .join("\n");
599
600 return [
601 TRIO_SUMMARY_MARKER,
602 "## AI Trio Review",
603 "",
604 "Three independent reviewers ran in parallel — security, correctness, style.",
605 "",
606 "### Verdicts",
607 verdictLine(r.securityVerdict),
608 verdictLine(r.correctnessVerdict),
609 verdictLine(r.styleVerdict),
610 "",
611 "### Disagreements",
612 disagreementLines,
613 ].join("\n");
614}
615
616// ---------------------------------------------------------------------------
617// Test-only exports.
618// ---------------------------------------------------------------------------
619
620export const __test = {
621 PERSONA_PROMPT,
622 normaliseFinding,
623 renderPersonaCommentBody,
624 renderSummaryCommentBody,
625 DIFF_BYTE_CAP,
626};
Modifiedsrc/lib/ai-review.ts+62−2View fileUnifiedSplit
@@ -12,6 +12,11 @@ import { pullRequests, prComments } from "../db/schema";
1212import { getRepoPath } from "../git/repository";
1313import { config } from "./config";
1414import { recordAiCost, extractUsage } from "./ai-cost-tracker";
15import {
16 isTrioReviewEnabled,
17 alreadyTrioReviewed,
18 runTrioReview,
19} from "./ai-review-trio";
1520
1621interface ReviewComment {
1722 filePath: string;
@@ -199,6 +204,31 @@ async function diffBetweenBranches(
199204 }
200205}
201206
207/**
208 * Resolve a branch name to its current commit SHA via `git rev-parse`.
209 * Returns "" on any error — callers should treat that as "unknown" and
210 * carry on; this is only used for audit metadata.
211 */
212async function resolveHeadSha(
213 ownerName: string,
214 repoName: string,
215 branch: string
216): Promise<string> {
217 try {
218 const cwd = getRepoPath(ownerName, repoName);
219 const proc = Bun.spawn(["git", "rev-parse", branch], {
220 cwd,
221 stdout: "pipe",
222 stderr: "pipe",
223 });
224 const text = await new Response(proc.stdout).text();
225 await proc.exited;
226 return text.trim();
227 } catch {
228 return "";
229 }
230}
231
202232/**
203233 * Has this PR already been reviewed by the AI? Detected by an existing
204234 * PR comment carrying our summary marker. Cheap LIKE query — if it
@@ -254,10 +284,21 @@ export async function triggerAiReview(
254284): Promise<void> {
255285 try {
256286 if (!isAiReviewEnabled()) return;
257 if (!options.force && (await alreadyReviewed(prId))) return;
287 const useTrio = isTrioReviewEnabled();
288 if (
289 !options.force &&
290 (useTrio
291 ? await alreadyTrioReviewed(prId)
292 : await alreadyReviewed(prId))
293 )
294 return;
258295
259296 const [pr] = await db
260 .select({ id: pullRequests.id, authorId: pullRequests.authorId })
297 .select({
298 id: pullRequests.id,
299 authorId: pullRequests.authorId,
300 repositoryId: pullRequests.repositoryId,
301 })
261302 .from(pullRequests)
262303 .where(eq(pullRequests.id, prId))
263304 .limit(1);
@@ -274,6 +315,25 @@ export async function triggerAiReview(
274315 diffText = diffText.slice(0, DIFF_BYTE_CAP);
275316 }
276317
318 // Trio path — replaces the single-Claude review when enabled. The
319 // trio helper owns its own persistence + audit; we bail after it.
320 if (useTrio) {
321 try {
322 const headSha = await resolveHeadSha(ownerName, repoName, headBranch);
323 await runTrioReview({
324 pullRequestId: prId,
325 headSha,
326 diff: diffText,
327 repositoryId: pr.repositoryId,
328 });
329 } catch (err) {
330 if (process.env.DEBUG_AI_REVIEW === "1") {
331 console.error("[ai-review] trio crashed:", err);
332 }
333 }
334 return;
335 }
336
277337 let result: ReviewResult;
278338 try {
279339 result = await reviewDiff(
Modifiedsrc/routes/pulls.tsx+189−0View fileUnifiedSplit
@@ -1455,6 +1455,184 @@ function riskBandLabel(band: PrRiskScore["band"]): string {
14551455 }
14561456}
14571457
1458// ---------------------------------------------------------------------------
1459// AI Trio Review — 3-column card grid + disagreement callout.
1460//
1461// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
1462// per run: one per persona (security/correctness/style) plus a top-level
1463// summary. We surface them here as a single grid above the normal
1464// comment stream so reviewers see the verdicts at a glance.
1465// ---------------------------------------------------------------------------
1466
1467const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
1468
1469interface TrioCommentLike {
1470 body: string;
1471}
1472
1473function isTrioComment(body: string | null | undefined): boolean {
1474 if (!body) return false;
1475 return (
1476 body.includes(TRIO_SUMMARY_MARKER) ||
1477 body.includes(TRIO_COMMENT_MARKER.security) ||
1478 body.includes(TRIO_COMMENT_MARKER.correctness) ||
1479 body.includes(TRIO_COMMENT_MARKER.style)
1480 );
1481}
1482
1483function trioPersonaOfComment(body: string): TrioPersona | null {
1484 for (const p of TRIO_PERSONAS) {
1485 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
1486 }
1487 return null;
1488}
1489
1490/**
1491 * Best-effort verdict parse from a persona comment body. The body shape
1492 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
1493 * we only need the "Pass" / "Fail" word from the H2 heading.
1494 */
1495function trioVerdictOfBody(body: string): "pass" | "fail" | null {
1496 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
1497 if (!m) return null;
1498 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
1499}
1500
1501/**
1502 * Parse the disagreement bullet list out of the summary comment so we
1503 * can render it as a polished callout strip. Returns [] when nothing
1504 * matches — the comment author may have edited the marker out.
1505 */
1506function parseDisagreements(summaryBody: string): Array<{
1507 file: string;
1508 failing: string;
1509 passing: string;
1510}> {
1511 const out: Array<{ file: string; failing: string; passing: string }> = [];
1512 // Each disagreement line looks like:
1513 // - `path:42` — security, style say ✗, correctness say ✓
1514 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
1515 let m: RegExpExecArray | null;
1516 while ((m = re.exec(summaryBody)) !== null) {
1517 out.push({
1518 file: m[1].trim(),
1519 failing: m[2].trim().replace(/[,\s]+$/g, ""),
1520 passing: m[3].trim().replace(/[,\s]+$/g, ""),
1521 });
1522 }
1523 return out;
1524}
1525
1526function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
1527 // Find the most recent persona comments + summary. We iterate from
1528 // the end so re-reviews (multiple runs on the same PR) display the
1529 // freshest verdict.
1530 const latest: Partial<Record<TrioPersona, string>> = {};
1531 let summaryBody: string | null = null;
1532 for (let i = comments.length - 1; i >= 0; i--) {
1533 const body = comments[i].body || "";
1534 if (!isTrioComment(body)) continue;
1535 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
1536 summaryBody = body;
1537 continue;
1538 }
1539 const persona = trioPersonaOfComment(body);
1540 if (persona && !latest[persona]) latest[persona] = body;
1541 }
1542 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
1543 if (!anyPersona && !summaryBody) return null;
1544
1545 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
1546
1547 return (
1548 <div class="trio-wrap">
1549 <div class="trio-header">
1550 <span class="trio-header-dot" aria-hidden="true"></span>
1551 <strong>AI Trio Review</strong>
1552 <span class="trio-header-sub">
1553 Three independent reviewers ran in parallel.
1554 </span>
1555 </div>
1556 <div class="trio-grid">
1557 {TRIO_PERSONAS.map((persona) => {
1558 const body = latest[persona];
1559 const verdict = body ? trioVerdictOfBody(body) : null;
1560 const stateClass =
1561 verdict === "fail"
1562 ? "is-fail"
1563 : verdict === "pass"
1564 ? "is-pass"
1565 : "is-pending";
1566 return (
1567 <div class={`trio-card trio-${persona} ${stateClass}`}>
1568 <div class="trio-card-head">
1569 <span class="trio-card-icon" aria-hidden="true">
1570 {persona === "security"
1571 ? "🛡"
1572 : persona === "correctness"
1573 ? "✓"
1574 : "✎"}
1575 </span>
1576 <strong class="trio-card-title">
1577 {persona[0].toUpperCase() + persona.slice(1)}
1578 </strong>
1579 <span class="trio-card-verdict">
1580 {verdict === "pass"
1581 ? "Pass"
1582 : verdict === "fail"
1583 ? "Fail"
1584 : "Pending"}
1585 </span>
1586 </div>
1587 <div class="trio-card-body">
1588 {body ? (
1589 <MarkdownContent
1590 html={renderMarkdown(stripTrioHeading(body))}
1591 />
1592 ) : (
1593 <span class="trio-card-empty">
1594 Awaiting reviewer output.
1595 </span>
1596 )}
1597 </div>
1598 </div>
1599 );
1600 })}
1601 </div>
1602 {disagreements.length > 0 && (
1603 <div class="trio-disagreement-strip" role="note">
1604 <span class="trio-disagreement-icon" aria-hidden="true">
1605 ⚠
1606 </span>
1607 <div class="trio-disagreement-body">
1608 <strong>Reviewers disagree — review carefully.</strong>
1609 <ul class="trio-disagreement-list">
1610 {disagreements.map((d) => (
1611 <li>
1612 <code>{d.file}</code> — {d.failing} says ✗,{" "}
1613 {d.passing} says ✓
1614 </li>
1615 ))}
1616 </ul>
1617 </div>
1618 </div>
1619 )}
1620 </div>
1621 );
1622}
1623
1624/**
1625 * Strip the marker comment + first H2 heading from a persona body so
1626 * the card body shows just the findings list (verdict is already in
1627 * the card head). Best-effort — malformed bodies render whole.
1628 */
1629function stripTrioHeading(body: string): string {
1630 return body
1631 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
1632 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
1633 .trim();
1634}
1635
14581636// List PRs
14591637pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
14601638 const { owner: ownerName, repo: repoName } = c.req.param();
@@ -2268,7 +2446,18 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
22682446 />
22692447 )}
22702448
2449 {/* Block H — AI trio review (security/correctness/style). When
2450 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
2451 hoisted into a 3-column card grid above the normal comment
2452 stream so reviewers see verdicts at a glance. Disagreements
2453 are surfaced as a yellow callout. */}
2454 <TrioReviewGrid
2455 comments={comments.map(({ comment }) => comment)}
2456 />
2457
22712458 {comments.map(({ comment, author: commentAuthor }) => {
2459 // Skip trio comments — already rendered in TrioReviewGrid above.
2460 if (isTrioComment(comment.body)) return null;
22722461 const slashCmd = detectSlashCmdComment(comment.body);
22732462 if (slashCmd) {
22742463 const visible = stripSlashCmdMarker(comment.body);
22752464