Review several PRs at once with Claude and Codex side by side, in a live panel that shows each reviewer's progress and where they agree and disagree.

Shared skills, mods and plugins for coding agents — Claude Code, Codex, pi, and anything else that reads the Agent Skills format.
git clone https://github.com/ekcm/agent-toolkit.git ~/Documents/GitHub/agent-toolkit
cd ~/Documents/GitHub/agent-toolkit
./install.sh --dry-run # preview
./install.sh
Then restart any running agents so they pick up the skills.
install.sh symlinks each folder in skills/ into the user-level skill folder of every supported agent, so this repo stays the only copy:
| Folder | Read by |
|---|---|
~/.agents/skills/<name> | Codex, pi, and other Agent Skills tools |
~/.claude/skills/<name> | Claude Code (it does not read ~/.agents/skills) |
It also:
~/.agent-toolkit-backups/<timestamp>/.~/.codex/skills. Codex still reads that legacy folder as well as ~/.agents/skills, so a copy left there makes each skill appear twice.npx skills) are left alone.Don't also link skills into ~/.pi/agent/skills: pi already reads ~/.agents/skills and warns about duplicate names.
Edits to a skill take effect immediately, since the agents read it through the symlink — just restart the agent. After adding, removing or renaming a skill, run ./install.sh again. If the repo moves, run it again from the new location.
./install.sh --uninstall
This removes only the symlinks that point into this repo. Backups stay in ~/.agent-toolkit-backups/; move a folder back from there to restore it.
The target folders can be overridden with AGENTS_SKILLS_DIR, CLAUDE_SKILLS_DIR, CODEX_HOME and AGENT_TOOLKIT_BACKUP_DIR.
Each folder in skills/ is a standard SKILL.md skill: frontmatter with only name and description, and plain instructions with no agent-specific tools.
| Skill | What it does |
|---|---|
code-review-uncommitted-changes | Strict review of uncommitted changes |
verify-review | Second-pass check that a prior review was implemented, plus a regression scan |
review-pull-request | Deep review of a GitHub PR via gh |
retrieve-pull-request-comments | Fetch and summarise review comments on the current branch's PR |
draft-pr-description | Write PR.md from the branch's commits and changes |
handover-session-notes | Save session context to NOTES.md for the next session |
Invoke a skill by name — /name in Claude Code, $name in Codex.
Mods change Claude Code itself (panels, commands, hooks), so unlike the skills they work in Claude Code only. Each lives in mods/<name>/, and this repo is a plugin marketplace that lists them.
To install from your local clone instead of GitHub, add it as a marketplace once with claude plugin marketplace add ~/Documents/GitHub/agent-toolkit, then install each mod as <name>@agent-toolkit. An install runs from a cached copy of the mod, so editing the clone changes nothing until you bump version in the mod's plugin.json, run claude plugin marketplace update agent-toolkit and claude plugin update <name>@agent-toolkit, and restart Claude Code. While working on a mod, start Claude Code with claude --plugin-dir ~/Documents/GitHub/agent-toolkit/mods/<name> instead: it loads the clone directly, and /reload-plugins picks up edits.
Reviews several PRs at once with Claude and Codex side by side, in a live panel. For each PR it runs both reviewers independently, combines their findings, runs the PR's install, tests and build, then has the two agents debate whatever they disagree on, and shows what both agree on, what was confirmed, withdrawn or still disputed, colour-coded by severity and tagged by source.

Install it in Claude Code:
/plugin install pr-review-board --marketplace ekcm/agent-toolkit
Or install from your local clone:
claude plugin marketplace add ~/Documents/GitHub/agent-toolkit
/plugin install pr-review-board@agent-toolkit
It needs gh logged in, the claude and codex CLIs on your PATH, and the review-pull-request skill installed with ./install.sh.
| Command | What it does |
|---|---|
/review-prs ~/path/to/repo 19 22 | Review PRs 19 and 22 of that repo (inside the repo, /review-prs 19 22 is enough) |
/review-prs | Open the panel |
/review-prs trust owner/repo | Let PRs of a repo you don't own run their install, tests and build |
/review-prs untrust owner/repo | Undo that; /review-prs trust lists the approved repos |
Using the panel:
1 shows the combined result, 2 Claude's review and 3 Codex's; b goes back.x (or close) closes the panel. Reviews keep running while it is closed, and /review-prs opens it again.cancel and retry per reviewer, recombine to redo the combine, verify and debate from the saved reviews, and remove to drop a finished review from the panel (its files stay).What it touches:
~/.agent-toolkit/pr-reviews/<repo>/pr-<n>/worktree, fetched into refs/pr-review/<n> of your local clone. Your own checkout and uncommitted changes are left alone.reconciled.md) are saved in ~/.agent-toolkit/pr-reviews/<repo>/pr-<n>/, next to the worktree. To remove a review's worktree and ref:git -C <repo> worktree remove --force ~/.agent-toolkit/pr-reviews/<repo>/pr-<n>/worktree
git -C <repo> update-ref -d refs/pr-review/<n>
A two-line status line for working across Claude Code and Codex: the repo, branch and the branch's PR on top, and how much room each agent has left below.
agent-toolkit (feat/statusline) · ✓ checks · 4/7 resolved
ctx ▰▰▰▱▱▱▱▱▱▱ 32% 64k/200k · claude 5h 58% left (2h10m) · codex 5h 55% left (1h05m)
Install it in Claude Code:
/plugin install dev-statusline --marketplace ekcm/agent-toolkit
Then point Claude Code's status line at it, in ~/.claude/settings.json (this replaces any statusLine you already have):
"statusLine": {
"type": "command",
"command": "sh ~/.agent-toolkit/dev-statusline/statusline.sh"
}
It needs macOS or Linux (it runs sh, sed and find), gh logged in for the PR part, the codex CLI logged in for the Codex figure, and jq for the subagent rows (macOS includes it); a part whose tool is missing is left out or shows —.
The mod keeps the figures and writes the two lines to ~/.agent-toolkit/dev-statusline/<session id>.txt; that script, which the mod writes on start-up, prints the file for the session asking. Each session's file is removed when it ends, and leftovers older than a day are cleared on start-up. The lines show under the prompt, below Claude Code's own line with the permission mode, and update whenever Claude Code redraws its status line.
What each part shows:
gh (its number is left out, since Claude Code's own line under the prompt shows it): ✓ checks, ◷ 3 running or ✗ 2 failing, then resolved review threads out of all of them. It shows no PR when the branch has none, and drops the PR part when gh is missing, logged out or offline. It refreshes at the end of each turn and every minute.▰ for used and ▱ for free, then the share used and tokens used out of the window (32% 64k/200k), straight from Claude Code.— off a subscription.codex app-server for about a second, asks it for the account's rate limits (no tokens spent) and stops it. It needs the codex CLI on your PATH, logged in. If that fails, it falls back to the newest session log in ~/.codex/sessions (or $CODEX_HOME/sessions), which is only as fresh as your last interactive Codex turn. It shows — when there is no reading.Worktrees: inside a linked git worktree the first line still leads with the project, the main repository's folder name, followed by the worktree's folder, dim: Muse/DEV-67 (feat/DEV-67-wiki-link-resolver). A detached checkout shows (detached <commit>), and its PR is the open one containing that commit, so pr-review-board's review checkouts show their PR too.
Subagents: when Claude Code runs subagents, for example one per worktree, the mod also draws their rows in the agent panel under the prompt, as a tree:
├─ DEV-67 running · ✓ checks · 0/5 resolved · 48k tokens
└─ DEV-70 done · ✗ 1 failing · no threads · 112k tokens
Each row names the worktree the subagent works in (or the subagent, outside any worktree), its status, that worktree's PR and its tokens. The mod ships this as the plugin's subagentStatusLine setting, so it needs no settings entry; it runs ~/.agent-toolkit/dev-statusline/subagents.sh, which the mod writes on start-up. The PR states come from one gh call listing the repository's open PRs, made only when the repository has more than one worktree.
Several sessions share one Codex reading: the first to need it asks Codex and saves it to ~/.agent-toolkit/dev-statusline/codex.json, and the others reuse it for a minute.
Colours: checks and review threads are green, yellow or red, in your terminal's own colours. The repo name is bold in a darker Claude orange. The gauge's used segments, its percentage and claude 5h with its share left are Claude orange, and codex 5h with its share left is Codex lilac; a share left under 10% is also bold and underlined. left, the reset times, the free segments and the token count stay plain, and the separators, the branch's brackets and empty states (no PR, no checks, no threads, —) are dim. The brand colours need a terminal with 24-bit colour. The colours are set in one table, COLORS in hooks/format.ts.
hooks/register.tsx 641 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5 AgentName, AgentRun, AgentStatus, BoardView, Debate, DebateItem, Group, Review, ReviewOutput, Verification,
6} from '../types'
7import {
8 applyRoundOne, applyRoundTwo, closeOpen, DEBATE_SCHEMA, debateItems, other, readReplies, roundOnePrompt, roundTwoPrompt,
9} from './debate'
10import {
11 buildGroups, combinedMarkdown, conflictCandidates, linePairs, MATCH_SYSTEM, matchPrompt, parseMatchReply,
12} from './reconcile'
13import type { Conflict, Pair } from './reconcile'
14import { CLAUDE_ALLOWED_TOOLS, CLAUDE_DISALLOWED_TOOLS, FINDINGS_SCHEMA, INPUT_DIR, reviewPrompt } from './review-spec'
15import { isReviewOutput, lineSplitter, readClaudeEvent, readCodexEvent } from './stream'
16import { detectSteps, evidence, summarize, tailOf } from './verify'
17import { Board, Detail, notifyText } from './view'
18
19type $ = EngineInterface
20
21const PANE = 'pr-reviews'
22const AGENTS: AgentName[] = ['claude', 'codex']
23const MATCH_MODEL = 'sonnet'
24const STEP_TIMEOUT_MS = 600_000
25const TRUSTED_KEY = 'trusted-repos'
26
27const reviews = atom({ plugin: 'pr-review-board', key: 'reviews' } as const, [] as Review[])
28const view = atom({ plugin: 'pr-review-board', key: 'view' } as const, { selected: null, tab: 'combined' } as BoardView)
29const now = atom({ plugin: 'pr-review-board', key: 'now' } as const, 0)
30
31// Module-local: a reload drops these, and kills the children with them.
32const running = new Map<string, { stop: () => void }>()
33const stopped = new Set<string>()
34const pipelines = new Set<string>()
35const repoQueues = new Map<string, Promise<unknown>>()
36const verifyLogs = new Map<string, Record<string, string>>()
37let ticker: { cancel: () => void } | null = null
38let viewerLogin: string | null = null
39
40const ACTIVE: AgentStatus[] = ['queued', 'preparing', 'reviewing', 'responding']
41const isActive = (run: AgentRun) => ACTIVE.includes(run.status)
42const isBusy = (review: Review) => AGENTS.some(a => isActive(review.agents[a]))
43 || ['combining', 'verifying', 'debating'].includes(review.result.status)
44const message = (err: unknown) => (err instanceof Error ? err.message : String(err))
45const runId = (key: string, agent: AgentName) => `${key}:${agent}`
46
47const freshRun = (status: AgentStatus, startedAt: number | null): AgentRun => ({
48 status, startedAt, endedAt: null, activity: null, activityAt: null, error: null, output: null, usage: null,
49})
50const freshVerify = (): Verification => ({ status: 'pending', steps: [], note: null })
51const freshDebate = (): Debate => ({ status: 'pending', round: 0, turn: null, items: [], note: null })
52const freshResult = (): Review['result'] => ({ status: 'waiting', groups: [], isModelMatched: false, error: null })
53
54async function patchReview($: $, key: string, fn: (review: Review) => Review) {
55 await update($, reviews, list => list.map(review => (review.key === key ? fn(review) : review)))
56}
57
58async function patchAgent($: $, key: string, agent: AgentName, patch: Partial<AgentRun>) {
59 await patchReview($, key, review => ({ ...review, agents: { ...review.agents, [agent]: { ...review.agents[agent], ...patch } } }))
60}
61
62async function getReview($: $, key: string): Promise<Review | undefined> {
63 return (await read($, reviews)).find(review => review.key === key)
64}
65
66async function run($: $, argv: string[], cwd?: string, timeoutMs = 120_000): Promise<string> {
67 const result = await $.process.run(argv, { cwd, timeoutMs })
68 if (result.exitCode !== 0) {
69 const detail = (result.stderr || result.stdout).trim().split('\n').pop() ?? ''
70 throw new Error(`${argv.slice(0, 3).join(' ')} failed: ${detail}`)
71 }
72 return result.stdout.trim()
73}
74
75// Git worktree and fetch commands on one repo run one at a time.
76function inRepoQueue<T>(root: string, work: () => Promise<T>): Promise<T> {
77 const previous = repoQueues.get(root) ?? Promise.resolve()
78 const next = previous.catch(() => undefined).then(work)
79 repoQueues.set(root, next)
80 return next
81}
82
83async function trustedRepos($: $): Promise<string[]> {
84 const value = await $.store.get(TRUSTED_KEY)
85 return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
86}
87
88// Verification runs the PR's code, so it runs only when the repo is the
89// person's own or one they approved, and the PR's branch lives in that repo
90// (or in their own fork).
91async function decideTrust($: $, root: string, meta: { isCrossRepository?: boolean; headRepositoryOwner?: { login?: string } }) {
92 viewerLogin ??= await run($, ['gh', 'api', 'user', '--jq', '.login'], root)
93 const repo = JSON.parse(await run($, ['gh', 'repo', 'view', '--json', 'nameWithOwner,owner'], root)) as { nameWithOwner: string; owner: { login: string } }
94 const approved = await trustedRepos($)
95 const isRepoTrusted = repo.owner.login === viewerLogin || approved.includes(repo.nameWithOwner)
96 const isBranchTrusted = !meta.isCrossRepository || meta.headRepositoryOwner?.login === viewerLogin
97 return { nameWithOwner: repo.nameWithOwner, isTrusted: isRepoTrusted && isBranchTrusted }
98}
99
100// Fetches the PR into refs/pr-review/<n>, checks it out as a detached
101// worktree, saves the PR's metadata and diff for both reviewers, and answers
102// the prompt they are given.
103async function prepare($: $, review: Review): Promise<string> {
104 const { repoRoot: root, pr } = review
105 const meta = JSON.parse(await run($, ['gh', 'pr', 'view', String(pr), '--json',
106 'number,title,body,author,url,baseRefName,headRefName,headRefOid,additions,deletions,changedFiles,files,commits,isCrossRepository,headRepositoryOwner'], root))
107 const trust = await decideTrust($, root, meta)
108 await patchReview($, review.key, r => ({ ...r, ...trust, title: meta.title, additions: meta.additions, deletions: meta.deletions }))
109 const diff = await run($, ['gh', 'pr', 'diff', String(pr)], root)
110
111 await inRepoQueue(root, async () => {
112 await run($, ['git', '-C', root, 'fetch', '--quiet', 'origin',
113 `+refs/pull/${pr}/head:refs/pr-review/${pr}`,
114 `+refs/heads/${meta.baseRefName}:refs/remotes/origin/${meta.baseRefName}`], undefined, 300_000)
115 if (await $.fs.exists(review.worktree)) {
116 await $.process.run(['git', '-C', root, 'worktree', 'remove', '--force', review.worktree])
117 }
118 await $.process.run(['git', '-C', root, 'worktree', 'prune'])
119 await run($, ['git', '-C', root, 'worktree', 'add', '--detach', review.worktree, `refs/pr-review/${pr}`], undefined, 300_000)
120 })
121
122 const mergeBase = await run($, ['git', '-C', review.worktree, 'merge-base', 'HEAD', `origin/${meta.baseRefName}`])
123 await $.fs.write(`${review.worktree}/${INPUT_DIR}/pr.json`, JSON.stringify(meta, null, 2))
124 await $.fs.write(`${review.worktree}/${INPUT_DIR}/pr.diff`, diff)
125 return reviewPrompt({ pr, headSha: meta.headRefOid, baseRef: meta.baseRefName, mergeBase })
126}
127
128async function prepareAndReview($: $, key: string) {
129 const review = await getReview($, key)
130 if (!review) return
131 let prompt: string
132 try {
133 prompt = await prepare($, review)
134 } catch (err) {
135 const endedAt = await $.clock.now()
136 await patchReview($, key, r => (r.title === 'loading…' ? { ...r, title: 'setup failed' } : r))
137 for (const agent of AGENTS) {
138 if (!stopped.has(runId(key, agent))) await patchAgent($, key, agent, { status: 'failed', endedAt, error: `Setup failed: ${message(err)}` })
139 }
140 return
141 }
142 await patchReview($, key, r => ({ ...r, prompt }))
143 await Promise.all(AGENTS.filter(agent => !stopped.has(runId(key, agent))).map(agent => reviewRound($, key, agent, prompt)))
144}
145
146// ---------- running an agent ----------
147
148type AgentCall = { prompt: string; schema: object; phase: string }
149type AgentAnswer = { structured: unknown; error: string | null; usage: string | null; isCancelled: boolean }
150
151function agentArgv(agent: AgentName, review: Review, call: AgentCall): string[] {
152 if (agent === 'codex') {
153 return ['codex', 'exec', '--sandbox', 'read-only', '-C', review.worktree, '--ephemeral',
154 '--output-schema', `${review.outDir}/${call.phase}.schema.json`, '-o', `${review.outDir}/codex.${call.phase}.json`, '--json', call.prompt]
155 }
156 return ['claude', '-p', call.prompt, '--output-format', 'stream-json', '--verbose',
157 '--json-schema', JSON.stringify(call.schema),
158 '--allowedTools', ...CLAUDE_ALLOWED_TOOLS, '--disallowedTools', ...CLAUDE_DISALLOWED_TOOLS]
159}
160
161// Runs one agent to completion, streaming its activity into the panel.
162async function callAgent($: $, review: Review, agent: AgentName, call: AgentCall): Promise<AgentAnswer> {
163 const id = runId(review.key, agent)
164 await $.fs.write(`${review.outDir}/${call.phase}.schema.json`, JSON.stringify(call.schema, null, 2))
165 const readEvent = agent === 'codex' ? readCodexEvent : readClaudeEvent
166 const toLines = lineSplitter()
167 let structured: unknown = null
168 let error: string | null = null
169 let usage: string | null = null
170 let stderrTail = ''
171 let exitCode: number | null = null
172
173 const child = $.process.spawn({ argv: agentArgv(agent, review, call), cwd: review.worktree })
174 running.set(id, { stop: () => void child.return(undefined as never) })
175 try {
176 for await (const { stream, text } of child) {
177 if (stream === 'stderr') {
178 stderrTail = (stderrTail + text).slice(-2000)
179 continue
180 }
181 for (const line of toLines(text)) {
182 const found = readEvent(line)
183 if (found.usage) usage = found.usage
184 if (found.structured !== undefined) structured = found.structured
185 if (found.error) error = found.error
186 if (found.activity) await patchAgent($, review.key, agent, { activity: found.activity, activityAt: await $.clock.now() })
187 }
188 }
189 exitCode = (await child.result).code
190 } catch (err) {
191 error = error ?? `Could not run ${agent}: ${message(err)}`
192 } finally {
193 running.delete(id)
194 }
195
196 if (stopped.has(id)) return { structured: null, error: null, usage, isCancelled: true }
197 if (agent === 'codex' && structured === null && !error) {
198 try {
199 structured = JSON.parse(await $.fs.read(`${review.outDir}/codex.${call.phase}.json`))
200 } catch {
201 error = 'Codex finished without writing its answer'
202 }
203 }
204 if (structured === null && !error) {
205 const lastLine = stderrTail.trim().split('\n').pop() ?? ''
206 error = exitCode ? `Exited with code ${exitCode}${lastLine ? `: ${lastLine}` : ''}` : 'Finished without an answer'
207 }
208 return { structured, error, usage, isCancelled: false }
209}
210
211// Round 1: the independent review.
212async function reviewRound($: $, key: string, agent: AgentName, prompt: string) {
213 const review = await getReview($, key)
214 if (!review) return
215 stopped.delete(runId(key, agent))
216 const startedAt = review.agents[agent].startedAt ?? await $.clock.now()
217 await patchAgent($, key, agent, { ...freshRun('reviewing', startedAt), activity: 'starting', activityAt: await $.clock.now() })
218 startTicker($)
219
220 const answer = await callAgent($, review, agent, { prompt, schema: FINDINGS_SCHEMA, phase: 'review' })
221 const endedAt = await $.clock.now()
222 if (answer.isCancelled) {
223 await patchAgent($, key, agent, { status: 'cancelled', endedAt, activity: null })
224 return
225 }
226 if (!answer.error && isReviewOutput(answer.structured)) {
227 const output: ReviewOutput = answer.structured
228 await $.fs.write(`${review.outDir}/${agent}.json`, JSON.stringify(output, null, 2))
229 await patchAgent($, key, agent, { status: 'reviewed', output, usage: answer.usage, activity: null, error: null })
230 await runPipelineWhenReady($, key)
231 } else {
232 await patchAgent($, key, agent, {
233 status: 'failed', endedAt, usage: answer.usage,
234 error: answer.error ?? `${agent} answered, but not in the review format`,
235 })
236 }
237}
238
239// ---------- after round 1: combine, verify, debate ----------
240
241async function runPipelineWhenReady($: $, key: string, isForced = false) {
242 const review = await getReview($, key)
243 if (!review || pipelines.has(key)) return
244 if (!review.agents.claude.output || !review.agents.codex.output) return
245 if (!isForced && AGENTS.some(a => review.agents[a].status !== 'reviewed')) return
246
247 pipelines.add(key)
248 try {
249 if (review.nameWithOwner === null) {
250 // Saved by an earlier version of the mod: work out trust from the saved PR details.
251 const meta = JSON.parse(await $.fs.read(`${review.worktree}/${INPUT_DIR}/pr.json`))
252 const trust = await decideTrust($, review.repoRoot, meta)
253 await patchReview($, key, r => ({ ...r, ...trust }))
254 }
255 for (const agent of AGENTS) await patchAgent($, key, agent, { status: 'reviewed', error: null, endedAt: null })
256 await patchReview($, key, r => ({ ...r, result: { ...freshResult(), status: 'combining' }, verify: freshVerify(), debate: freshDebate() }))
257 startTicker($)
258 const groups = await combine($, key)
259 await verify($, key)
260 await debate($, key, groups)
261 await finish($, key)
262 } catch (err) {
263 await patchReview($, key, r => ({ ...r, result: { ...r.result, status: 'failed', error: message(err) } }))
264 } finally {
265 pipelines.delete(key)
266 }
267}
268
269async function combine($: $, key: string): Promise<Group[]> {
270 const review = (await getReview($, key))!
271 const claude = review.agents.claude.output!
272 const codex = review.agents.codex.output!
273 const proposed = linePairs(claude.findings, codex.findings)
274 const candidates = conflictCandidates(claude, codex)
275 let pairs: Pair[] = proposed
276 let conflicts: Conflict[] = []
277 let isModelMatched = false
278 if (claude.findings.length + codex.findings.length > 0) {
279 const reply = await $.model.complete({
280 model: MATCH_MODEL, system: MATCH_SYSTEM, maxTokens: 3000,
281 prompt: matchPrompt(claude.findings, codex.findings, proposed, candidates),
282 })
283 const matched = reply.isAnswered ? parseMatchReply(reply.text, claude.findings, codex.findings, candidates) : null
284 if (matched) {
285 pairs = matched.pairs
286 conflicts = matched.conflicts
287 isModelMatched = true
288 }
289 }
290 const groups = buildGroups(claude, codex, pairs, conflicts)
291 await $.fs.write(`${review.outDir}/matching.json`, JSON.stringify({ pairs, conflicts, candidates, isModelMatched }, null, 2))
292 await patchReview($, key, r => ({ ...r, result: { ...r.result, groups, isModelMatched } }))
293 return groups
294}
295
296async function verify($: $, key: string) {
297 const review = (await getReview($, key))!
298 const setVerify = (fn: (v: Verification) => Verification) => patchReview($, key, r => ({ ...r, verify: fn(r.verify) }))
299 if (!review.isTrusted) {
300 await setVerify(() => ({ status: 'skipped', steps: [], note: "skipped: not your repo, or the PR branch is in someone else's fork" }))
301 return
302 }
303 const files = (await $.fs.list(review.worktree)).map(entry => entry.name)
304 const packageJson = files.includes('package.json') ? await $.fs.read(`${review.worktree}/package.json`) : null
305 const steps = detectSteps(files, packageJson)
306 if (!steps.length) {
307 await setVerify(() => ({ status: 'skipped', steps: [], note: 'skipped: no install, test or build commands found' }))
308 return
309 }
310
311 await patchReview($, key, r => ({ ...r, result: { ...r.result, status: 'verifying' }, verify: { status: 'running', steps, note: null } }))
312 const logs: Record<string, string> = {}
313 let isFailed = false
314 for (const [index, step] of steps.entries()) {
315 if (isFailed) break
316 const startedAt = await $.clock.now()
317 await setVerify(v => ({ ...v, steps: v.steps.map((s, i) => (i === index ? { ...s, status: 'running', startedAt } : s)) }))
318 let exitCode: number
319 let output: string
320 try {
321 const result = await $.process.run(step.command, {
322 cwd: review.worktree, env: { CI: 'true', FORCE_COLOR: '0' }, timeoutMs: STEP_TIMEOUT_MS,
323 })
324 exitCode = result.exitCode
325 output = `${result.stdout}\n${result.stderr}`
326 } catch (err) {
327 exitCode = -1
328 output = `Did not finish: ${message(err)}`
329 }
330 const durationMs = (await $.clock.now()) - startedAt
331 logs[step.name] = tailOf(output)
332 await $.fs.write(`${review.worktree}/${INPUT_DIR}/verify/${step.name}.log`, output)
333 await $.fs.write(`${review.outDir}/verify-${step.name}.log`, output)
334 const status = exitCode === 0 ? 'passed' : 'failed'
335 if (status === 'failed') isFailed = true
336 await setVerify(v => ({
337 ...v,
338 steps: v.steps.map((s, i) => (i === index ? { ...s, status, durationMs, summary: summarize(step.name, exitCode, output) } : s)),
339 }))
340 }
341 verifyLogs.set(key, logs)
342 await setVerify(v => ({ ...v, status: isFailed ? 'failed' : 'passed', note: isFailed && v.steps.some(s => s.status === 'pending') ? 'later steps not run' : null }))
343}
344
345async function debate($: $, key: string, groups: Group[]) {
346 let items = debateItems(groups)
347 const setDebate = (fn: (d: Debate) => Debate) => patchReview($, key, r => ({ ...r, debate: fn(r.debate) }))
348 if (!items.length) {
349 await setDebate(() => ({ ...freshDebate(), status: 'skipped', note: 'nothing disputed' }))
350 return
351 }
352 const review = (await getReview($, key))!
353 const outputs = { claude: review.agents.claude.output!, codex: review.agents.codex.output! }
354 const verification = review.verify.status === 'skipped' || review.verify.status === 'pending'
355 ? `Not run (${review.verify.note ?? 'skipped'}).`
356 : evidence(review.verify.steps, verifyLogs.get(key) ?? {})
357
358 await patchReview($, key, r => ({ ...r, result: { ...r.result, status: 'debating' } }))
359
360 // Round 1: each agent answers the items the other one raised.
361 const roundOne = AGENTS.map(agent => ({ agent, mine: items.filter(i => i.author === other(agent)) })).filter(t => t.mine.length)
362 await setDebate(() => ({ status: 'running', round: 1, turn: roundOne.length === 2 ? 'both' : roundOne[0]!.agent, items, note: null }))
363 for (const agent of AGENTS) {
364 if (!roundOne.some(t => t.agent === agent)) await patchAgent($, key, agent, { status: 'waiting', activity: null })
365 }
366 const firstReplies = await Promise.all(roundOne.map(({ agent, mine }) =>
367 debateTurn($, key, agent, roundOnePrompt(agent, review.pr, mine, outputs, verification), mine, ['agree', 'disagree', 'unsure'], 'debate-1')))
368 firstReplies.forEach(replies => { if (replies) items = applyRoundOne(items, replies) })
369 await setDebate(d => ({ ...d, items }))
370
371 // Round 2: each author answers the disagreements with its own items.
372 const roundTwo = AGENTS.map(agent => ({ agent, mine: items.filter(i => i.author === agent && i.resolution === 'pending') })).filter(t => t.mine.length)
373 if (roundTwo.length) {
374 await setDebate(d => ({ ...d, round: 2, turn: roundTwo.length === 2 ? 'both' : roundTwo[0]!.agent }))
375 const secondReplies = await Promise.all(roundTwo.map(({ agent, mine }) =>
376 debateTurn($, key, agent, roundTwoPrompt(agent, review.pr, mine, outputs, verification), mine, ['maintain', 'withdraw'], 'debate-2')))
377 secondReplies.forEach(replies => { if (replies) items = applyRoundTwo(items, replies) })
378 }
379 items = closeOpen(items)
380 await setDebate(d => ({ ...d, status: 'done', turn: null, items }))
381}
382
383async function debateTurn(
384 $: $, key: string, agent: AgentName, prompt: string, mine: DebateItem[], allowed: Parameters<typeof readReplies>[2], phase: string,
385) {
386 const review = (await getReview($, key))!
387 stopped.delete(runId(key, agent))
388 const author = other(agent) === 'claude' ? 'Claude' : 'Codex'
389 const what = phase === 'debate-1' ? `answering ${mine.length} of ${author}'s findings` : `answering ${author}'s objections to ${mine.length} findings`
390 await patchAgent($, key, agent, { status: 'responding', activity: what, activityAt: await $.clock.now() })
391 const answer = await callAgent($, review, agent, { prompt, schema: DEBATE_SCHEMA, phase })
392 const usage = review.agents[agent].usage
393 const totalUsage = answer.usage ? `${usage ?? ''}${usage ? ' + ' : ''}${answer.usage}` : usage
394 if (answer.isCancelled) {
395 await patchAgent($, key, agent, { status: 'cancelled', activity: null, usage: totalUsage })
396 return null
397 }
398 const replies = answer.error ? null : readReplies(answer.structured, mine.map(i => i.id), allowed)
399 await $.fs.write(`${review.outDir}/${agent}.${phase}.json`, JSON.stringify(answer.structured ?? { error: answer.error }, null, 2))
400 await patchAgent($, key, agent, {
401 status: 'waiting', activity: null, usage: totalUsage,
402 error: replies ? null : `Debate answer unusable: ${answer.error ?? 'not in the expected format'}`,
403 })
404 return replies
405}
406
407async function finish($: $, key: string) {
408 const endedAt = await $.clock.now()
409 for (const agent of AGENTS) {
410 const status = (await getReview($, key))!.agents[agent].status
411 if (status !== 'cancelled') await patchAgent($, key, agent, { status: 'done', endedAt, activity: null })
412 }
413 await patchReview($, key, r => ({ ...r, result: { ...r.result, status: 'ready' } }))
414 const review = (await getReview($, key))!
415 const claude = review.agents.claude.output!
416 const codex = review.agents.codex.output!
417 await $.fs.write(`${review.outDir}/reconciled.md`,
418 `# ${review.repoName} #${review.pr}: ${review.title}\n\n${combinedMarkdown(claude, codex, review.result.groups)}\n`)
419 await $.fs.write(`${review.outDir}/reconciled.json`,
420 JSON.stringify({ groups: review.result.groups, verify: review.verify, debate: review.debate }, null, 2))
421 await $.ui.notify(notifyText(review), { title: `Review ready: ${review.repoName} #${review.pr}` })
422}
423
424// ---------- controls ----------
425
426function startTicker($: $) {
427 if (ticker) return
428 ticker = $.clock.every(1000, () => void tick($))
429}
430
431async function tick($: $) {
432 const at = await $.clock.now()
433 await update($, now, () => at)
434 const list = await read($, reviews)
435 const busy = list.filter(isBusy).length
436 const ready = list.filter(review => review.result.status === 'ready').length
437 $.ui.status(busy ? `PR reviews: ${busy} running · ${ready} ready` : undefined)
438 if (!busy) {
439 ticker?.cancel()
440 ticker = null
441 }
442}
443
444async function cancelAgent($: $, key: string, agent: AgentName) {
445 const id = runId(key, agent)
446 stopped.add(id)
447 const child = running.get(id)
448 if (child) child.stop()
449 else await patchAgent($, key, agent, { status: 'cancelled', endedAt: await $.clock.now(), activity: null })
450}
451
452async function retryAgent($: $, key: string, agent: AgentName) {
453 const review = await getReview($, key)
454 if (!review || pipelines.has(key)) return
455 await patchReview($, key, r => ({ ...r, result: freshResult(), verify: freshVerify(), debate: freshDebate() }))
456 if (review.agents[agent].output) {
457 await runPipelineWhenReady($, key, true)
458 return
459 }
460 if (review.prompt) {
461 await patchAgent($, key, agent, freshRun('queued', null))
462 void reviewRound($, key, agent, review.prompt)
463 return
464 }
465 // Setup never finished, so redo it for every reviewer that is not running.
466 const startedAt = await $.clock.now()
467 for (const each of AGENTS) {
468 if (!isActive(review.agents[each])) {
469 stopped.delete(runId(key, each))
470 await patchAgent($, key, each, freshRun('preparing', startedAt))
471 }
472 }
473 startTicker($)
474 void prepareAndReview($, key)
475}
476
477// Drops a review from the panel; its saved files and worktree stay.
478async function removeReview($: $, key: string) {
479 const review = await getReview($, key)
480 if (!review || isBusy(review) || pipelines.has(key)) return
481 await update($, reviews, list => list.filter(r => r.key !== key))
482 await update($, view, v => (v.selected === key ? { ...v, selected: null } : v))
483}
484
485async function recombine($: $, key: string) {
486 if (pipelines.has(key)) return
487 await patchReview($, key, r => ({ ...r, result: freshResult(), verify: freshVerify(), debate: freshDebate() }))
488 await runPipelineWhenReady($, key, true)
489}
490
491function parseArgs(args: string): { dir: string | null; prs: number[] } {
492 const tokens = args.split(/\s+/).filter(Boolean)
493 const prs: number[] = []
494 let dir: string | null = null
495 for (const token of tokens) {
496 const number = token.match(/^#?(\d+)$/)?.[1] ?? token.match(/\/pull\/(\d+)/)?.[1]
497 if (number) prs.push(Number(number))
498 else dir = token
499 }
500 return { dir, prs: [...new Set(prs)] }
501}
502
503async function changeTrust($: $, words: string[]): Promise<string> {
504 const [verb, repo] = words
505 const list = await trustedRepos($)
506 if (!repo) return `Approved for verification besides your own repos: ${list.length ? list.join(', ') : 'none'}.`
507 if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) return 'Name the repo as owner/name, such as ekcm/Muse.'
508 const next = verb === 'trust' ? [...new Set([...list, repo])] : list.filter(r => r !== repo)
509 await $.store.set(TRUSTED_KEY, next)
510 return verb === 'trust'
511 ? `${repo} approved: its PRs' installs, tests and builds will run on this machine.`
512 : `${repo} removed: its PRs will be reviewed without running installs, tests or builds.`
513}
514
515async function startReviews($: $, args: string): Promise<string> {
516 const words = args.split(/\s+/).filter(Boolean)
517 if (words[0] === 'trust' || words[0] === 'untrust') return changeTrust($, words)
518 const { dir, prs } = parseArgs(args)
519 if (!prs.length) return 'Name at least one PR number: /review-prs [repo path] 19 22'
520 const home = (await $.env.get('HOME')) ?? ''
521 const target = dir ? dir.replace(/^~(?=\/|$)/, home) : (await $.session.repo())?.root ?? await $.session.cwd()
522 const root = await run($, ['git', '-C', target, 'rev-parse', '--show-toplevel'])
523 const repoName = root.split('/').pop() ?? root
524 const startedAt = await $.clock.now()
525
526 const started: number[] = []
527 const skipped: number[] = []
528 const missing: string[] = []
529 for (const pr of prs) {
530 const key = `${repoName}#${pr}`
531 const existing = await getReview($, key)
532 if (existing && isBusy(existing)) {
533 skipped.push(pr)
534 continue
535 }
536 const found = await $.process.run(['gh', 'pr', 'view', String(pr), '--json', 'number'], { cwd: root, timeoutMs: 60_000 })
537 if (found.exitCode !== 0) {
538 missing.push(`#${pr} (${(found.stderr || found.stdout).trim().split('\n').pop() ?? ''})`)
539 continue
540 }
541 const outDir = `${home}/.agent-toolkit/pr-reviews/${repoName}/pr-${pr}`
542 const review: Review = {
543 key, repoRoot: root, repoName, nameWithOwner: null, isTrusted: false, pr, title: 'loading…', additions: 0, deletions: 0,
544 worktree: `${outDir}/worktree`, outDir, prompt: null,
545 agents: { claude: freshRun('preparing', startedAt), codex: freshRun('preparing', startedAt) },
546 result: freshResult(), verify: freshVerify(), debate: freshDebate(),
547 }
548 AGENTS.forEach(agent => stopped.delete(runId(key, agent)))
549 await update($, reviews, list => [...list.filter(r => r.key !== key), review])
550 started.push(pr)
551 void prepareAndReview($, key)
552 }
553 if (started.length) startTicker($)
554
555 const parts = []
556 if (started.length) parts.push(`Reviewing ${started.map(n => `#${n}`).join(', ')} in ${repoName} with Claude and Codex.`)
557 if (skipped.length) parts.push(`Already running: ${skipped.map(n => `#${n}`).join(', ')}.`)
558 if (missing.length) parts.push(`Not found in ${repoName}: ${missing.join(', ')}.`)
559 return parts.join(' ')
560}
561
562// Reviews saved by earlier versions of the mod lack the later fields.
563function withDefaults(review: Review): Review {
564 const saved = review as Partial<Review>
565 return {
566 ...review,
567 nameWithOwner: saved.nameWithOwner ?? null,
568 isTrusted: saved.isTrusted ?? false,
569 verify: saved.verify ?? freshVerify(),
570 debate: saved.debate ?? freshDebate(),
571 }
572}
573
574// A reload kills the agent processes and the pipeline; say so instead of
575// showing them as running. Reviews that finished round 1 can be recombined.
576function markInterrupted(review: Review, endedAt: number): Review {
577 const note = 'Interrupted by a reload or restart'
578 const agents = Object.fromEntries(AGENTS.map(agent => {
579 const agentRun = review.agents[agent]
580 // Reviews saved by the first version of the mod ended round 1 as 'done'.
581 if (agentRun.status === 'done' && review.result.status !== 'ready') return [agent, { ...agentRun, status: 'reviewed' }]
582 if (isActive(agentRun) && !agentRun.output) return [agent, { ...agentRun, status: 'failed', endedAt, error: `${note}; retry to run it again` }]
583 if (isActive(agentRun) || agentRun.status === 'waiting') return [agent, { ...agentRun, status: 'reviewed', activity: null }]
584 return [agent, agentRun]
585 })) as Record<AgentName, AgentRun>
586 const isPipelineCut = ['combining', 'verifying', 'debating'].includes(review.result.status)
587 return {
588 ...review,
589 agents,
590 result: isPipelineCut ? { ...review.result, status: 'failed', error: `${note}; press recombine` } : review.result,
591 }
592}
593
594export const register: Register = on => {
595 on('session.start', async ($, e, next) => {
596 await $.command.register({
597 name: 'review-prs',
598 description: 'Review PRs with Claude and Codex side by side: /review-prs [repo path] <PR numbers>, or /review-prs trust owner/repo',
599 })
600 const endedAt = await $.clock.now()
601 await update($, reviews, list => list.map(review => markInterrupted(withDefaults(review), endedAt)))
602 return next(e)
603 })
604
605 on('command.run', { command: 'review-prs' }, async ($, e) => {
606 const opened = await $.ui.open({ id: PANE, title: 'PR reviews' })
607 const hint = opened.isPlaced ? '' : ' The panel opens once the terminal is wide enough.'
608 if (!e.args.trim()) return { text: `PR reviews panel opened.${hint}` }
609 try {
610 return { text: (await startReviews($, e.args)) + hint }
611 } catch (err) {
612 return { text: `Could not start the review: ${message(err)}` }
613 }
614 })
615
616 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
617 const elements = $.ui.resolve(e)
618 const list = await read($, reviews)
619 const current = await read($, view)
620 const at = await read($, now)
621 const selected = current.selected ? list.find(review => review.key === current.selected) : undefined
622 if (selected) {
623 return Detail({
624 elements, review: selected, tab: current.tab,
625 onBack: () => update($, view, v => ({ ...v, selected: null })),
626 onTab: tab => update($, view, v => ({ ...v, tab })),
627 onClose: () => $.ui.close({ id: PANE }),
628 })
629 }
630 return Board({
631 elements, reviews: list, at, isBusy, isActive,
632 onOpen: key => update($, view, (): BoardView => ({ selected: key, tab: 'combined' })),
633 onCancel: (key, agent) => cancelAgent($, key, agent),
634 onRetry: (key, agent) => retryAgent($, key, agent),
635 onRecombine: key => recombine($, key),
636 onRemove: key => removeReview($, key),
637 onClose: () => $.ui.close({ id: PANE }),
638 })
639 })
640}
641hooks/debate.ts 163 lines1// The debate after round 1, on disputed items only: conflicts, and major or
2// critical findings only one agent raised.
3//
4// Round 1: each agent answers the other's items (agree, disagree, unsure).
5// Round 2: an author answers each disagreement on its own items (maintain or
6// withdraw). Agreement confirms an item; withdrawal drops it; anything else
7// stays disputed with both sides' evidence.
8
9import type { AgentName, DebateItem, DebateReply, Finding, Group, ReviewOutput, Stance } from '../types'
10
11export const AGENT_NAMES: Record<AgentName, string> = { claude: 'Claude', codex: 'Codex' }
12export const other = (agent: AgentName): AgentName => (agent === 'claude' ? 'codex' : 'claude')
13
14export const DEBATE_SCHEMA = {
15 type: 'object',
16 additionalProperties: false,
17 required: ['responses'],
18 properties: {
19 responses: {
20 type: 'array',
21 items: {
22 type: 'object',
23 additionalProperties: false,
24 required: ['item', 'stance', 'evidence'],
25 properties: {
26 item: { type: 'string', description: 'The item id, such as D1.' },
27 stance: { type: 'string', enum: ['agree', 'disagree', 'unsure', 'maintain', 'withdraw'] },
28 evidence: { type: 'string', description: 'file:line, quoted code, or verification output that supports the stance.' },
29 },
30 },
31 },
32 },
33} as const
34
35export function debateItems(groups: Group[]): DebateItem[] {
36 const items: DebateItem[] = []
37 for (const group of groups) {
38 if (group.kind === 'agree') continue
39 if (group.kind !== 'conflict' && group.severity === 'minor') continue
40 const author: AgentName = group.claude.length ? 'claude' : 'codex'
41 items.push({
42 id: `D${items.length + 1}`,
43 author,
44 findingIds: author === 'claude' ? group.claude : group.codex,
45 isConflict: group.kind === 'conflict',
46 conflictNote: group.conflictNote,
47 response: null,
48 rebuttal: null,
49 resolution: 'pending',
50 })
51 }
52 return items
53}
54
55function findingsOf(item: DebateItem, outputs: Record<AgentName, ReviewOutput>): Finding[] {
56 return item.findingIds.map(id => outputs[item.author].findings.find(f => f.id === id)).filter((f): f is Finding => Boolean(f))
57}
58
59function where(f: Finding): string {
60 if (f.line === null) return f.file
61 return `${f.file}:${f.line}${f.end_line && f.end_line !== f.line ? `-${f.end_line}` : ''}`
62}
63
64function describe(item: DebateItem, outputs: Record<AgentName, ReviewOutput>): string {
65 return findingsOf(item, outputs).map(f =>
66 ` ${AGENT_NAMES[item.author]} ${f.id} (${f.severity}, ${where(f)}): ${f.title}\n ${f.explanation}${f.suggestion ? `\n Suggested fix: ${f.suggestion}` : ''}`,
67 ).join('\n')
68}
69
70const RULES = `Do not agree because another reviewer said so, and do not disagree without evidence. Evidence is a file:line with the code quoted, or a line from the verification output.
71
72This is read-only. Do not create, modify or delete files, and do not run installs, tests or builds: the verification results above already ran them. Reading files and running git, grep and similar read-only commands is fine.
73
74Return only JSON matching the provided schema, one response per item.`
75
76export function roundOnePrompt(
77 agent: AgentName, pr: number, items: DebateItem[], outputs: Record<AgentName, ReviewOutput>, verification: string,
78): string {
79 const author = AGENT_NAMES[other(agent)]
80 const blocks = items.map(item => {
81 const conflict = item.isConflict && item.conflictNote ? `\n You checked this area earlier and judged it fine: "${item.conflictNote}"` : ''
82 return `${item.id}:\n${describe(item, outputs)}${conflict}`
83 })
84 return `Earlier you reviewed pull request #${pr} in this repository, independently of another reviewer (${author}). ${author} raised the findings below, which you either did not raise or judged fine. Check each one against the code and the verification results, then respond.
85
86Verification results (run by the review tool in this worktree; full logs in .review-input/verify/):
87${verification}
88
89Items:
90${blocks.join('\n\n')}
91
92For each item, answer with one stance:
93- agree: the problem is real.
94- disagree: it is not a problem, and your evidence shows why.
95- unsure: the code and verification results do not settle it.
96
97${RULES}`
98}
99
100export function roundTwoPrompt(
101 agent: AgentName, pr: number, items: DebateItem[], outputs: Record<AgentName, ReviewOutput>, verification: string,
102): string {
103 const responder = AGENT_NAMES[other(agent)]
104 const blocks = items.map(item =>
105 `${item.id}:\n${describe(item, outputs)}\n ${responder} disagreed: ${item.response?.evidence ?? ''}`,
106 )
107 return `Earlier you reviewed pull request #${pr} in this repository. Another reviewer (${responder}) checked the findings of yours below and disagreed. Read its evidence, re-check the code and the verification results, and decide.
108
109Verification results (run by the review tool in this worktree; full logs in .review-input/verify/):
110${verification}
111
112Items:
113${blocks.join('\n\n')}
114
115For each item, answer with one stance:
116- maintain: it is still a real problem, and your evidence shows why theirs does not hold.
117- withdraw: their evidence shows it is not a problem.
118
119${RULES}`
120}
121
122export function readReplies(value: unknown, ids: string[], allowed: Stance[]): Map<string, DebateReply> | null {
123 const responses = (value as { responses?: unknown } | null)?.responses
124 if (!Array.isArray(responses)) return null
125 const replies = new Map<string, DebateReply>()
126 for (const r of responses) {
127 if (typeof r?.item !== 'string' || !ids.includes(r.item)) continue
128 if (!allowed.includes(r.stance)) continue
129 replies.set(r.item, { stance: r.stance, evidence: String(r.evidence ?? '') })
130 }
131 return replies
132}
133
134export function applyRoundOne(items: DebateItem[], replies: Map<string, DebateReply>): DebateItem[] {
135 return items.map(item => {
136 const response = replies.get(item.id) ?? null
137 if (!response) return item
138 const resolution = response.stance === 'agree' ? 'confirmed' : response.stance === 'unsure' ? 'disputed' : 'pending'
139 return { ...item, response, resolution }
140 })
141}
142
143export function applyRoundTwo(items: DebateItem[], replies: Map<string, DebateReply>): DebateItem[] {
144 return items.map(item => {
145 if (item.resolution !== 'pending') return item
146 const rebuttal = replies.get(item.id) ?? null
147 return { ...item, rebuttal, resolution: rebuttal?.stance === 'withdraw' ? 'withdrawn' : 'disputed' }
148 })
149}
150
151// Items still open when a round could not run end up disputed.
152export function closeOpen(items: DebateItem[]): DebateItem[] {
153 return items.map(item => (item.resolution === 'pending' ? { ...item, resolution: 'disputed' } : item))
154}
155
156export function debateCounts(items: DebateItem[]): { confirmed: number; withdrawn: number; disputed: number } {
157 return {
158 confirmed: items.filter(i => i.resolution === 'confirmed').length,
159 withdrawn: items.filter(i => i.resolution === 'withdrawn').length,
160 disputed: items.filter(i => i.resolution === 'disputed').length,
161 }
162}
163hooks/reconcile.ts 273 lines1// Combines two reviews into groups: both agree, one reviewer only, or a
2// conflict (one flags what the other checked and judged fine).
3//
4// Pass 1 proposes pairs by line overlap. Pass 2 (a model call, made by the
5// caller with matchPrompt) confirms or rejects them and adds pairs that sit on
6// different lines, including one finding matching several. Without pass 2 the
7// line pairs stand as they are.
8
9import type { CheckedOk, Finding, Group, ReviewOutput, Severity } from '../types'
10
11export type Pair = { claude: string; codex: string }
12
13const SEVERITY_RANK: Record<Severity, number> = { critical: 3, major: 2, minor: 1 }
14// A range wider than this is a file-level remark, not a place; leave it to pass 2.
15const MAX_SPAN = 40
16const CONFLICT_SLACK = 3
17
18function normalizePath(path: string): string {
19 return path.replace(/^\.\//, '')
20}
21
22function range(finding: { line: number | null; end_line?: number | null }): [number, number] | null {
23 if (finding.line === null) return null
24 const end = finding.end_line ?? finding.line
25 return [Math.min(finding.line, end), Math.max(finding.line, end)]
26}
27
28// How much of the smaller range the two share (0 to 1), or 0 when either is
29// missing, too wide, or in another file.
30export function lineOverlap(a: Finding, b: Finding): number {
31 if (normalizePath(a.file) !== normalizePath(b.file)) return 0
32 const ra = range(a)
33 const rb = range(b)
34 if (!ra || !rb) return 0
35 if (ra[1] - ra[0] > MAX_SPAN || rb[1] - rb[0] > MAX_SPAN) return 0
36 const shared = Math.min(ra[1], rb[1]) - Math.max(ra[0], rb[0]) + 1
37 if (shared <= 0) return 0
38 const smaller = Math.min(ra[1] - ra[0], rb[1] - rb[0]) + 1
39 return shared / smaller
40}
41
42// One-to-one pairs by line, best overlap first; ties go to the tighter pair.
43export function linePairs(claude: Finding[], codex: Finding[]): Pair[] {
44 const candidates: Array<{ pair: Pair; score: number; span: number }> = []
45 for (const a of claude) {
46 for (const b of codex) {
47 const score = lineOverlap(a, b)
48 if (score < 0.5) continue
49 const span = (range(a)![1] - range(a)![0]) + (range(b)![1] - range(b)![0])
50 candidates.push({ pair: { claude: a.id, codex: b.id }, score, span })
51 }
52 }
53 candidates.sort((x, y) => y.score - x.score || x.span - y.span)
54 const usedClaude = new Set<string>()
55 const usedCodex = new Set<string>()
56 const pairs: Pair[] = []
57 for (const { pair } of candidates) {
58 if (usedClaude.has(pair.claude) || usedCodex.has(pair.codex)) continue
59 usedClaude.add(pair.claude)
60 usedCodex.add(pair.codex)
61 pairs.push(pair)
62 }
63 return pairs
64}
65
66function brief(finding: Finding): string {
67 return `${finding.id} [${finding.severity}] ${where(finding)} — ${finding.title}\n ${finding.explanation.slice(0, 400).replace(/\s+/g, ' ')}`
68}
69
70// A finding next to a place the other reviewer examined and judged correct.
71// Only the model decides whether that check covered the same concern.
72export type ConflictCandidate = { id: string; side: 'claude' | 'codex'; finding: string; check: CheckedOk }
73export type Conflict = { side: 'claude' | 'codex'; finding: string; note: string }
74export type Match = { pairs: Pair[]; conflicts: Conflict[] }
75
76function isNear(finding: Finding, check: CheckedOk): boolean {
77 const r = range(finding)
78 if (!r || r[1] - r[0] > MAX_SPAN || check.line === null) return false
79 return normalizePath(check.file) === normalizePath(finding.file)
80 && check.line >= r[0] - CONFLICT_SLACK
81 && check.line <= r[1] + CONFLICT_SLACK
82}
83
84export function conflictCandidates(claude: ReviewOutput, codex: ReviewOutput): ConflictCandidate[] {
85 const candidates: ConflictCandidate[] = []
86 const add = (side: 'claude' | 'codex', findings: Finding[], checks: CheckedOk[]) => {
87 for (const finding of findings) {
88 for (const check of checks) {
89 if (isNear(finding, check)) candidates.push({ id: `K${candidates.length + 1}`, side, finding: finding.id, check })
90 }
91 }
92 }
93 add('claude', claude.findings, codex.checked_ok)
94 add('codex', codex.findings, claude.checked_ok)
95 return candidates
96}
97
98export const MATCH_SYSTEM = 'You match code review findings from two reviewers. Answer with JSON only.'
99
100export function matchPrompt(claude: Finding[], codex: Finding[], proposed: Pair[], candidates: ConflictCandidate[]): string {
101 const letter = (side: 'claude' | 'codex') => (side === 'claude' ? 'C' : 'X')
102 const other = (side: 'claude' | 'codex') => (side === 'claude' ? 'X' : 'C')
103 return `Two reviewers reviewed the same pull request. Decide which of their findings describe the same underlying problem, and where they truly disagree.
104
105Reviewer C (Claude) findings:
106${claude.map(brief).join('\n')}
107
108Reviewer X (Codex) findings:
109${codex.map(brief).join('\n')}
110
111Pairs proposed because they cite overlapping lines (they may still be different problems):
112${proposed.length ? proposed.map(p => `${p.claude} ~ ${p.codex}`).join('\n') : '(none)'}
113
114Possible disagreements: a finding next to a place the other reviewer examined and judged correct:
115${candidates.length ? candidates.map(k => `${k.id}: ${letter(k.side)} ${k.finding} vs ${other(k.side)} checked "${k.check.area}": ${k.check.note}`).join('\n') : '(none)'}
116
117Rules:
118- Pair two findings only when fixing one would fix the other: the same root problem, even if cited on different lines or worded differently.
119- One finding may pair with several from the other reviewer when it covers all of them.
120- Reject a proposed pair that is about different problems.
121- List a possible disagreement only when the check examined the same specific concern the finding raises and concluded it is fine. A check of a different aspect of the same code is not a disagreement.
122
123Answer with only this JSON: {"pairs": [{"claude": "<C id>", "codex": "<X id>"}], "conflicts": ["<K id>"]}`
124}
125
126export function parseMatchReply(text: string, claude: Finding[], codex: Finding[], candidates: ConflictCandidate[] = []): Match | null {
127 const start = text.indexOf('{')
128 const end = text.lastIndexOf('}')
129 if (start < 0 || end < start) return null
130 let parsed: unknown
131 try {
132 parsed = JSON.parse(text.slice(start, end + 1))
133 } catch {
134 return null
135 }
136 const reply = parsed as { pairs?: unknown; conflicts?: unknown }
137 if (!Array.isArray(reply.pairs)) return null
138 const claudeIds = new Set(claude.map(f => f.id))
139 const codexIds = new Set(codex.map(f => f.id))
140 const pairs = reply.pairs
141 .filter((p): p is Pair => typeof p?.claude === 'string' && typeof p?.codex === 'string')
142 .filter(p => claudeIds.has(p.claude) && codexIds.has(p.codex))
143 const chosen = new Set(Array.isArray(reply.conflicts) ? reply.conflicts.filter(k => typeof k === 'string') : [])
144 const conflicts = candidates
145 .filter(k => chosen.has(k.id))
146 .map(k => ({ side: k.side, finding: k.finding, note: `${k.check.area}: ${k.check.note}` }))
147 return { pairs, conflicts }
148}
149
150function maxSeverity(findings: Finding[]): Severity {
151 return findings.reduce<Severity>((top, f) => (SEVERITY_RANK[f.severity] > SEVERITY_RANK[top] ? f.severity : top), 'minor')
152}
153
154// Connected components over the pairs, so A~X and B~X land in one group. A
155// one-sided group is a conflict when a confirmed conflict names one of its findings.
156export function buildGroups(claude: ReviewOutput, codex: ReviewOutput, pairs: Pair[], conflicts: Conflict[] = []): Group[] {
157 const parent = new Map<string, string>()
158 const find = (id: string): string => {
159 const up = parent.get(id) ?? id
160 if (up === id) return id
161 const root = find(up)
162 parent.set(id, root)
163 return root
164 }
165 const nodes = [...claude.findings.map(f => `c:${f.id}`), ...codex.findings.map(f => `x:${f.id}`)]
166 nodes.forEach(n => parent.set(n, n))
167 for (const p of pairs) parent.set(find(`c:${p.claude}`), find(`x:${p.codex}`))
168
169 const components = new Map<string, string[]>()
170 for (const n of nodes) components.set(find(n), [...(components.get(find(n)) ?? []), n])
171
172 const byId = (list: Finding[], id: string) => list.find(f => f.id === id)!
173 const groups: Group[] = []
174 for (const members of components.values()) {
175 const claudeIds = members.filter(m => m.startsWith('c:')).map(m => m.slice(2))
176 const codexIds = members.filter(m => m.startsWith('x:')).map(m => m.slice(2))
177 const findings = [...claudeIds.map(id => byId(claude.findings, id)), ...codexIds.map(id => byId(codex.findings, id))]
178 let kind: Group['kind'] = claudeIds.length && codexIds.length ? 'agree' : claudeIds.length ? 'claude' : 'codex'
179 let conflictNote: string | null = null
180 if (kind !== 'agree') {
181 const ids = kind === 'claude' ? claudeIds : codexIds
182 const conflict = conflicts.find(c => c.side === kind && ids.includes(c.finding))
183 if (conflict) {
184 kind = 'conflict'
185 conflictNote = conflict.note
186 }
187 }
188 groups.push({ kind, severity: maxSeverity(findings), claude: claudeIds, codex: codexIds, conflictNote })
189 }
190
191 const kindOrder = { conflict: 0, agree: 1, claude: 2, codex: 3 }
192 return groups.sort((a, b) => kindOrder[a.kind] - kindOrder[b.kind] || SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity])
193}
194
195export function countGroups(groups: Group[]): Record<Group['kind'], number> {
196 const counts = { agree: 0, claude: 0, codex: 0, conflict: 0 }
197 for (const g of groups) counts[g.kind] += 1
198 return counts
199}
200
201function where(f: Finding): string {
202 if (f.line === null) return `\`${f.file}\``
203 return `\`${f.file}:${f.line}${f.end_line && f.end_line !== f.line ? `-${f.end_line}` : ''}\``
204}
205
206function findingBlock(f: Finding, who: string): string {
207 const fix = f.suggestion ? `\n *Fix:* ${f.suggestion}` : ''
208 return `- **${who}** (${f.severity}, ${where(f)}): ${f.explanation}${fix}`
209}
210
211const HEADINGS: Record<Group['kind'], string> = {
212 conflict: '✗ Conflicts',
213 agree: '⇄ Both agree',
214 claude: '◐ Claude only',
215 codex: '◑ Codex only',
216}
217
218export function combinedMarkdown(claude: ReviewOutput, codex: ReviewOutput, groups: Group[]): string {
219 const lookup = (list: Finding[], ids: string[]) => ids.map(id => list.find(f => f.id === id)!).filter(Boolean)
220 const important = groups.filter(g => g.severity !== 'minor')
221 const minor = groups.filter(g => g.severity === 'minor')
222 const out: string[] = [
223 `**Claude:** ${claude.verdict.replace('_', ' ')} · ${claude.findings.length} findings `,
224 `**Codex:** ${codex.verdict.replace('_', ' ')} · ${codex.findings.length} findings`,
225 ]
226
227 for (const kind of ['conflict', 'agree', 'claude', 'codex'] as const) {
228 const section = important.filter(g => g.kind === kind)
229 if (!section.length) continue
230 out.push('', `### ${HEADINGS[kind]} (${section.length})`)
231 for (const g of section) {
232 const cs = lookup(claude.findings, g.claude)
233 const xs = lookup(codex.findings, g.codex)
234 const title = (cs[0] ?? xs[0])?.title ?? ''
235 out.push('', `**${title}**`)
236 cs.forEach(f => out.push(findingBlock(f, `Claude ${f.id}`)))
237 xs.forEach(f => out.push(findingBlock(f, `Codex ${f.id}`)))
238 if (g.conflictNote) out.push(`- **${cs.length ? 'Codex' : 'Claude'} checked this and found it fine:** ${g.conflictNote}`)
239 }
240 }
241
242 if (minor.length) {
243 out.push('', `### Minor (${minor.length})`)
244 for (const g of minor) {
245 const cs = lookup(claude.findings, g.claude)
246 const xs = lookup(codex.findings, g.codex)
247 const f = cs[0] ?? xs[0]
248 if (!f) continue
249 const who = [cs.length ? `Claude ${g.claude.join(', ')}` : '', xs.length ? `Codex ${g.codex.join(', ')}` : ''].filter(Boolean).join(' + ')
250 out.push(`- ${f.title} — ${where(f)} *(${who}${g.kind === 'conflict' ? '; the other found it fine' : ''})*`)
251 }
252 }
253 return out.join('\n')
254}
255
256export function reviewMarkdown(output: ReviewOutput): string {
257 const out: string[] = [`**Verdict:** ${output.verdict.replace('_', ' ')}`, '', output.summary]
258 for (const severity of ['critical', 'major', 'minor'] as const) {
259 const list = output.findings.filter(f => f.severity === severity)
260 if (!list.length) continue
261 out.push('', `### ${severity.charAt(0).toUpperCase()}${severity.slice(1)} (${list.length})`)
262 for (const f of list) {
263 out.push('', `**${f.id}. ${f.title}** — ${where(f)} · ${f.category}`, '', f.explanation)
264 if (f.suggestion) out.push('', `*Fix:* ${f.suggestion}`)
265 }
266 }
267 if (output.checked_ok.length) {
268 out.push('', `### Checked and found fine (${output.checked_ok.length})`)
269 output.checked_ok.forEach(c => out.push(`- \`${c.file}${c.line === null ? '' : `:${c.line}`}\` ${c.area}: ${c.note}`))
270 }
271 return out.join('\n')
272}
273hooks/review-spec.ts 86 lines1// What both reviewers are given: the same prompt, the same output schema, and
2// read-only access to the PR's worktree.
3
4const nullable = (type: string) => ({ type: [type, 'null'] })
5
6export const FINDINGS_SCHEMA = {
7 type: 'object',
8 additionalProperties: false,
9 required: ['summary', 'verdict', 'findings', 'checked_ok'],
10 properties: {
11 summary: { type: 'string', description: 'Two or three sentences on what the PR does and its overall quality.' },
12 verdict: { type: 'string', enum: ['approve', 'request_changes', 'comment'] },
13 findings: {
14 type: 'array',
15 items: {
16 type: 'object',
17 additionalProperties: false,
18 required: ['id', 'file', 'line', 'end_line', 'severity', 'category', 'title', 'explanation', 'suggestion'],
19 properties: {
20 id: { type: 'string', description: 'F1, F2, ...' },
21 file: { type: 'string', description: 'Repo-relative path.' },
22 line: { ...nullable('integer'), description: 'First line in the PR head version of the file; null if not tied to a line.' },
23 end_line: nullable('integer'),
24 severity: { type: 'string', enum: ['critical', 'major', 'minor'] },
25 category: {
26 type: 'string',
27 enum: ['correctness', 'code_smell', 'naming', 'duplication', 'architecture', 'api_design', 'error_handling', 'testing', 'performance', 'security', 'documentation', 'out_of_scope'],
28 },
29 title: { type: 'string' },
30 explanation: { type: 'string', description: 'What is wrong and why, with evidence.' },
31 suggestion: nullable('string'),
32 },
33 },
34 },
35 checked_ok: {
36 type: 'array',
37 description: 'Specific places examined closely and judged correct.',
38 items: {
39 type: 'object',
40 additionalProperties: false,
41 required: ['file', 'line', 'area', 'note'],
42 properties: {
43 file: { type: 'string' },
44 line: nullable('integer'),
45 area: { type: 'string', description: 'Function, block or concern that was checked.' },
46 note: { type: 'string', description: 'Why it is correct.' },
47 },
48 },
49 },
50 },
51} as const
52
53export const INPUT_DIR = '.review-input'
54
55export type PromptFacts = {
56 pr: number
57 headSha: string
58 baseRef: string
59 mergeBase: string
60}
61
62export function reviewPrompt(facts: PromptFacts): string {
63 return `Review pull request #${facts.pr} in this repository. It is checked out at the PR head (commit ${facts.headSha.slice(0, 7)}); the base branch is origin/${facts.baseRef}, merge-base ${facts.mergeBase.slice(0, 7)}.
64
65Use the review-pull-request skill and apply its full review: every phase and all 11 review dimensions, including the duplication search across the repo.
66
67The PR metadata and diff are already saved in ${INPUT_DIR}/pr.json and ${INPUT_DIR}/pr.diff. Use those instead of gh or any network access. Use git and file reads for everything else. The ${INPUT_DIR}/ folder is review input, not part of the PR.
68
69This is a read-only review. Do not create, modify or delete any files, and do not install dependencies or run tests or builds.
70
71Ignore the skill's terminal report format. Return your final answer only as JSON matching the provided schema:
72- findings: every issue you would put in the report (critical, major, minor, duplication, out-of-scope), with line numbers from the PR head version of the file.
73- checked_ok: specific places you examined closely and judged correct, so your review can be compared with another reviewer's.`
74}
75
76// Claude runs as `claude -p`, where any tool not listed here is refused.
77export const CLAUDE_ALLOWED_TOOLS = [
78 'Skill', 'Read', 'Grep', 'Glob',
79 'Bash(git log:*)', 'Bash(git diff:*)', 'Bash(git show:*)', 'Bash(git status:*)', 'Bash(git rev-parse:*)',
80 'Bash(git branch:*)', 'Bash(git ls-files:*)', 'Bash(git merge-base:*)', 'Bash(git blame:*)', 'Bash(git grep:*)',
81 'Bash(cat:*)', 'Bash(ls:*)', 'Bash(grep:*)', 'Bash(rg:*)', 'Bash(head:*)', 'Bash(tail:*)', 'Bash(wc:*)',
82 'Bash(tree:*)', 'Bash(sed -n:*)',
83]
84
85export const CLAUDE_DISALLOWED_TOOLS = ['Edit', 'Write', 'NotebookEdit', 'Agent', 'WebFetch', 'WebSearch']
86hooks/stream.ts 121 lines1// Turns each reviewer's JSONL stream into what the panel shows: the latest
2// activity, the usage once it finishes, and the final output or error.
3
4import type { ReviewOutput } from '../types'
5
6export type StreamUpdate = {
7 activity?: string
8 usage?: string
9 // The final structured answer, unchecked: the caller knows which schema it asked for.
10 structured?: unknown
11 error?: string
12}
13
14// Splits streamed text into whole lines, holding back a trailing partial one.
15export function lineSplitter(): (text: string) => string[] {
16 let pending = ''
17 return text => {
18 pending += text
19 const lines = pending.split('\n')
20 pending = lines.pop() ?? ''
21 return lines.filter(line => line.trim() !== '')
22 }
23}
24
25function parseJson(line: string): Record<string, unknown> | null {
26 try {
27 const value: unknown = JSON.parse(line)
28 return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
29 } catch {
30 return null
31 }
32}
33
34function oneLine(text: string, max = 120): string {
35 const flat = text.replace(/\s+/g, ' ').trim()
36 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
37}
38
39// Codex wraps each command as `/bin/zsh -lc "<command>"`.
40export function unwrapShell(command: string): string {
41 const match = command.match(/^\S*(?:zsh|bash|sh) -lc ["'](.*)["']$/s)
42 return match?.[1] ?? command
43}
44
45function shortPath(path: string): string {
46 const parts = path.split('/')
47 return parts.length > 3 ? `…/${parts.slice(-3).join('/')}` : path
48}
49
50export function isReviewOutput(value: unknown): value is ReviewOutput {
51 if (typeof value !== 'object' || value === null) return false
52 const candidate = value as Record<string, unknown>
53 return typeof candidate.summary === 'string'
54 && typeof candidate.verdict === 'string'
55 && Array.isArray(candidate.findings)
56 && Array.isArray(candidate.checked_ok)
57}
58
59export function readCodexEvent(line: string): StreamUpdate {
60 const event = parseJson(line)
61 if (!event) return {}
62 const item = event.item as Record<string, unknown> | undefined
63
64 if (event.type === 'item.started' && item?.type === 'command_execution') {
65 return { activity: `$ ${oneLine(unwrapShell(String(item.command ?? '')))}` }
66 }
67 if (event.type === 'item.completed' && item?.type === 'agent_message') {
68 return { activity: oneLine(String(item.text ?? 'writing')) }
69 }
70 if (event.type === 'turn.completed') {
71 const usage = event.usage as Record<string, number> | undefined
72 if (!usage) return {}
73 const thousands = (n: number | undefined) => `${Math.round((n ?? 0) / 1000)}k`
74 return {
75 usage: `${thousands(usage.input_tokens)} in (${thousands(usage.cached_input_tokens)} cached) · ${thousands(usage.output_tokens)} out`,
76 }
77 }
78 if (event.type === 'turn.failed' || event.type === 'error') {
79 const error = event.error as Record<string, unknown> | undefined
80 return { error: oneLine(String(error?.message ?? event.message ?? 'Codex reported an error'), 200) }
81 }
82 return {}
83}
84
85function describeToolUse(name: string, input: Record<string, unknown>): string {
86 switch (name) {
87 case 'Bash': return `$ ${oneLine(String(input.command ?? ''))}`
88 case 'Read': return `read ${shortPath(String(input.file_path ?? ''))}`
89 case 'Grep': return `grep ${oneLine(String(input.pattern ?? ''), 80)}`
90 case 'Glob': return `glob ${String(input.pattern ?? '')}`
91 case 'Skill': return `skill ${String(input.skill ?? '')}`
92 default: return name
93 }
94}
95
96export function readClaudeEvent(line: string): StreamUpdate {
97 const event = parseJson(line)
98 if (!event) return {}
99
100 if (event.type === 'assistant') {
101 const message = event.message as { content?: Array<Record<string, unknown>> } | undefined
102 const toolUses = (message?.content ?? []).filter(block => block.type === 'tool_use')
103 const last = toolUses[toolUses.length - 1]
104 if (last) return { activity: describeToolUse(String(last.name), (last.input ?? {}) as Record<string, unknown>) }
105 return { activity: 'thinking' }
106 }
107 if (event.type === 'result') {
108 const cost = typeof event.total_cost_usd === 'number' ? `$${event.total_cost_usd.toFixed(2)} equiv` : null
109 const turns = typeof event.num_turns === 'number' ? `${event.num_turns} turns` : null
110 const usage = [cost, turns].filter(Boolean).join(' · ') || undefined
111 if (event.is_error || event.subtype !== 'success') {
112 return { usage, error: oneLine(String(event.result ?? event.subtype ?? 'Claude reported an error'), 200) }
113 }
114 if (event.structured_output === undefined || event.structured_output === null) {
115 return { usage, error: 'Claude finished without structured output' }
116 }
117 return { usage, structured: event.structured_output }
118 }
119 return {}
120}
121hooks/verify.ts 73 lines1// Picks the commands that verify a PR (install, test, build) from the repo's
2// own files, and summarises what each run printed. The mod runs these, not the
3// agents, so both agents argue from the same evidence.
4
5import type { VerifyStep } from '../types'
6
7const NPM_DEFAULT_TEST = 'no test specified'
8
9type PackageJson = { scripts?: Record<string, string>; packageManager?: string }
10
11function installFor(files: string[], manifest: PackageJson): string[] {
12 const pinned = manifest.packageManager?.split('@')[0]
13 if (pinned === 'pnpm' || files.includes('pnpm-lock.yaml')) return ['pnpm', 'install', '--frozen-lockfile']
14 if (pinned === 'yarn' || files.includes('yarn.lock')) return ['yarn', 'install', '--frozen-lockfile']
15 if (pinned === 'bun' || files.includes('bun.lock') || files.includes('bun.lockb')) return ['bun', 'install', '--frozen-lockfile']
16 if (files.includes('package-lock.json')) return ['npm', 'ci']
17 return ['npm', 'install']
18}
19
20function runner(install: string[]): string {
21 return install[0] === 'npm' ? 'npm' : install[0]!
22}
23
24const step = (name: string, command: string[]): VerifyStep => ({
25 name, command, status: 'pending', startedAt: null, durationMs: null, summary: null,
26})
27
28// `files` is the repo root's file names; `packageJsonText` its package.json, if any.
29export function detectSteps(files: string[], packageJsonText: string | null): VerifyStep[] {
30 if (packageJsonText === null) return []
31 let manifest: PackageJson
32 try {
33 manifest = JSON.parse(packageJsonText) as PackageJson
34 } catch {
35 return []
36 }
37 const scripts = manifest.scripts ?? {}
38 const install = installFor(files, manifest)
39 const run = runner(install)
40 const steps = [step('install', install)]
41 if (scripts.test && !scripts.test.includes(NPM_DEFAULT_TEST)) steps.push(step('test', [run, 'run', 'test']))
42 if (scripts.build) steps.push(step('build', [run, 'run', 'build']))
43 return steps.length > 1 ? steps : []
44}
45
46// Strips colour codes and keeps the last lines, which is where test runners
47// and builds print their result.
48export function tailOf(output: string, lines = 40): string {
49 // eslint-disable-next-line no-control-regex
50 const plain = output.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '')
51 return plain.trimEnd().split('\n').slice(-lines).join('\n')
52}
53
54// A one-line result for the panel: the test count when the runner printed one.
55export function summarize(name: string, exitCode: number, output: string): string {
56 const lines = tailOf(output, 80).split('\n').reverse()
57 // Prefer the runner's "Tests" line over its "Test Files" or "Suites" lines.
58 const testsLine = lines.map(l => l.match(/^\s*Tests?:?\s+(\d+\s+(?:passed|failed).*)$/i)?.[1]).find(Boolean)
59 const counts = testsLine ?? lines.map(l => l.match(/(\d+ (?:passed|failed).*)$/i)?.[1]).find(Boolean)
60 const detail = counts ? counts.replace(/\s+/g, ' ').trim().slice(0, 60) : null
61 if (exitCode === 0) return detail ?? 'passed'
62 return detail ? `failed: ${detail}` : `failed (exit ${exitCode})`
63}
64
65export function evidence(steps: VerifyStep[], logs: Record<string, string>): string {
66 if (!steps.length) return 'Not run.'
67 return steps.map(s => {
68 const head = `- ${s.name} (\`${s.command.join(' ')}\`): ${s.status}${s.durationMs === null ? '' : ` in ${Math.round(s.durationMs / 1000)}s`}${s.summary ? ` — ${s.summary}` : ''}`
69 const log = logs[s.name]
70 return s.status === 'failed' && log ? `${head}\n Last lines:\n${log.split('\n').map(l => ` ${l}`).join('\n')}` : head
71 }).join('\n')
72}
73hooks/view.tsx 445 lines1// Drawing: the board of PRs, and one PR's results with severity colours and
2// a tag saying which agent each finding came from.
3
4import type { EngineInterface, RenderChildren } from 'claude-code'
5
6import type {
7 AgentName, AgentRun, AgentStatus, BoardTab, DebateItem, Finding, Group, Review, ReviewOutput, Severity, VerifyStep,
8} from '../types'
9import { AGENT_NAMES, debateCounts, other } from './debate'
10
11type Elements = ReturnType<EngineInterface['ui']['resolve']>
12type Tone = 'success' | 'error' | 'warning' | 'claude' | 'suggestion' | 'subtle'
13type Piece = { text: string; color?: Tone; bold?: boolean; dim?: boolean }
14
15const AGENTS: AgentName[] = ['claude', 'codex']
16const STALL_MS = 120_000
17
18const SEVERITY_COLOR: Record<Severity, Tone> = { critical: 'error', major: 'warning', minor: 'subtle' }
19const SOURCE_COLOR: Record<string, Tone> = { Claude: 'claude', Codex: 'suggestion', Both: 'success' }
20const GLYPH: Record<AgentStatus, string> = {
21 queued: '○', preparing: '◌', reviewing: '●', reviewed: '✓', responding: '⇄', waiting: '…', done: '✓', failed: '✗', cancelled: '–',
22}
23const STATUS_COLOR: Record<AgentStatus, Tone> = {
24 queued: 'subtle', preparing: 'subtle', reviewing: 'claude', reviewed: 'success', responding: 'suggestion',
25 waiting: 'subtle', done: 'success', failed: 'error', cancelled: 'subtle',
26}
27const STEP_GLYPH = { pending: '○', running: '●', passed: '✓', failed: '✗' } as const
28const STEP_COLOR: Record<VerifyStep['status'], Tone> = { pending: 'subtle', running: 'suggestion', passed: 'success', failed: 'error' }
29
30function clock(ms: number): string {
31 const seconds = Math.max(0, Math.floor(ms / 1000))
32 return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`
33}
34
35function where(f: Finding): string {
36 if (f.line === null) return f.file
37 return `${f.file}:${f.line}${f.end_line && f.end_line !== f.line ? `-${f.end_line}` : ''}`
38}
39
40function severityPieces(output: ReviewOutput): Piece[] {
41 const pieces: Piece[] = [{ text: `${output.findings.length} findings` }]
42 for (const severity of ['critical', 'major', 'minor'] as const) {
43 const n = output.findings.filter(f => f.severity === severity).length
44 if (n) pieces.push({ text: ` · ${n} ${severity}`, color: SEVERITY_COLOR[severity] })
45 }
46 return pieces
47}
48
49const verdictText = (output: ReviewOutput) => output.verdict.replace('_', ' ')
50
51function agentPieces(review: Review, agent: AgentName, at: number): Piece[] {
52 const run = review.agents[agent]
53 const output = run.output
54 switch (run.status) {
55 case 'queued': return [{ text: 'waiting to start', dim: true }]
56 case 'preparing': return [{ text: 'setting up the worktree and fetching the PR', dim: true }]
57 case 'reviewing':
58 case 'responding': {
59 const quiet = run.activityAt === null ? 0 : at - run.activityAt
60 if (quiet > STALL_MS) return [{ text: `⚠ no activity for ${clock(quiet)} · ${run.activity ?? ''}`, color: 'warning' }]
61 return [{ text: run.activity ?? '', dim: true }]
62 }
63 case 'reviewed':
64 return output ? [{ text: `round 1 done · ${verdictText(output)} · `, dim: true }, ...severityPieces(output)] : []
65 case 'waiting': {
66 const note: Piece[] = run.error ? [{ text: `${run.error} · `, color: 'warning' }] : []
67 return [...note, { text: `waiting for ${AGENT_NAMES[other(agent)]}`, dim: true }]
68 }
69 case 'done': {
70 if (!output) return []
71 const warn: Piece[] = run.error ? [{ text: ` · ${run.error}`, color: 'warning' }] : []
72 return [{ text: `${verdictText(output)} · `, dim: true }, ...severityPieces(output), { text: run.usage ? ` · ${run.usage}` : '', dim: true }, ...warn]
73 }
74 case 'failed': return [{ text: run.error ?? 'failed', color: 'error' }]
75 case 'cancelled': return [{ text: 'cancelled', dim: true }]
76 }
77}
78
79function elapsed(run: AgentRun, at: number): string {
80 if (run.startedAt === null) return '—'
81 return clock((run.endedAt ?? Math.max(at, run.startedAt)) - run.startedAt)
82}
83
84function verifyPieces(review: Review, at: number): Piece[] {
85 const { verify } = review
86 if (verify.status === 'skipped') return [{ text: verify.note ?? 'skipped', dim: true }]
87 const pieces: Piece[] = []
88 verify.steps.forEach((step, index) => {
89 const time = step.status === 'running' && step.startedAt !== null ? ` ${clock(at - step.startedAt)}`
90 : step.durationMs !== null ? ` ${clock(step.durationMs)}` : ''
91 const summary = step.summary && step.status !== 'running' ? ` (${step.summary})` : ''
92 pieces.push({ text: `${index ? ' · ' : ''}${STEP_GLYPH[step.status]} ${step.name}${time}${summary}`, color: STEP_COLOR[step.status] })
93 })
94 if (verify.note) pieces.push({ text: ` · ${verify.note}`, dim: true })
95 return pieces
96}
97
98// Groups that were not debated and have one author.
99function oneSidedCounts(review: Review): { claude: number; codex: number; conflict: number } {
100 const debated = new Set(review.debate.items.map(i => `${i.author}:${i.findingIds[0]}`))
101 const counts = { claude: 0, codex: 0, conflict: 0 }
102 for (const g of review.result.groups) {
103 if (g.kind === 'agree') continue
104 const author = g.claude.length ? 'claude' : 'codex'
105 if (debated.has(`${author}:${(author === 'claude' ? g.claude : g.codex)[0]}`)) continue
106 if (g.kind === 'conflict') counts.conflict += 1
107 else counts[author] += 1
108 }
109 return counts
110}
111
112function resultPieces(review: Review): Piece[] {
113 const { result, debate } = review
114 switch (result.status) {
115 case 'combining': return [{ text: '● combining the two reviews', color: 'suggestion' }]
116 case 'verifying': {
117 const step = review.verify.steps.find(s => s.status === 'running')
118 return [{ text: `🔧 verifying${step ? ` · ${step.name} (${step.command.join(' ')})` : ''}`, color: 'suggestion' }]
119 }
120 case 'debating': {
121 const arrow = debate.turn === 'both' || debate.turn === null ? 'Claude ⇄ Codex' : `${AGENT_NAMES[other(debate.turn)]} → ${AGENT_NAMES[debate.turn]}`
122 return [{ text: `⇄ debating · round ${debate.round}/2 · ${arrow} · ${debate.items.length} disputed items`, color: 'suggestion' }]
123 }
124 case 'failed': return [{ text: `✗ ${result.error ?? 'could not finish'}`, color: 'error' }]
125 case 'waiting': {
126 const stuck = AGENTS.filter(a => ['failed', 'cancelled'].includes(review.agents[a].status) && !review.agents[a].output)
127 if (stuck.length) return [{ text: `needs both reviews · retry ${stuck.map(a => AGENT_NAMES[a]).join(' and ')}`, dim: true }]
128 const done = AGENTS.filter(a => review.agents[a].output)
129 return [{ text: done.length ? 'waiting for the other reviewer' : 'waiting for both reviewers', dim: true }]
130 }
131 case 'ready': {
132 const agree = result.groups.filter(g => g.kind === 'agree').length
133 const loose = oneSidedCounts(review)
134 const pieces: Piece[] = [{ text: '✓ ready ', color: 'success', bold: true }, { text: `⇄ ${agree} agree` }]
135 if (debate.status === 'done') {
136 const d = debateCounts(debate.items)
137 pieces.push(
138 { text: ` · ✓ ${d.confirmed} confirmed`, color: 'success' },
139 { text: ` · ⚔ ${d.disputed} disputed`, color: d.disputed ? 'error' : 'subtle' },
140 { text: ` · ↩ ${d.withdrawn} withdrawn`, color: 'subtle' },
141 )
142 }
143 if (loose.conflict) pieces.push({ text: ` · ✗ ${loose.conflict} conflicts`, color: 'error' })
144 pieces.push({ text: ` · ◐ ${loose.claude} Claude only`, color: 'claude' }, { text: ` · ◑ ${loose.codex} Codex only`, color: 'suggestion' })
145 return pieces
146 }
147 }
148}
149
150export function notifyText(review: Review): string {
151 return resultPieces(review).map(p => p.text).join('').replace(/^✓ ready\s+/, '')
152}
153
154// ---------- the board ----------
155
156// Result stages that are still running, so the review cannot be removed yet.
157const PIPELINE_RUNNING = ['combining', 'verifying', 'debating']
158
159type BoardProps = {
160 elements: Elements
161 reviews: Review[]
162 at: number
163 isBusy: (review: Review) => boolean
164 isActive: (run: AgentRun) => boolean
165 onOpen: (key: string) => unknown
166 onCancel: (key: string, agent: AgentName) => unknown
167 onRetry: (key: string, agent: AgentName) => unknown
168 onRecombine: (key: string) => unknown
169 onRemove: (key: string) => unknown
170 onClose: () => unknown
171}
172
173function Pieces({ elements, pieces }: { elements: Elements; pieces: Piece[] }) {
174 const { Text } = elements
175 return (
176 <Text wrap="truncate-end">
177 {pieces.map(p => <Text color={p.color} bold={p.bold} dimColor={p.dim}>{p.text}</Text>)}
178 </Text>
179 )
180}
181
182function Row({ elements, label, children }: { elements: Elements; label: string; children: RenderChildren }) {
183 const { Box, Text } = elements
184 return (
185 <Box flexDirection="row" width="100%" gap={1} paddingLeft={2}>
186 <Box width={6} flexShrink={0}><Text>{label}</Text></Box>
187 {children}
188 </Box>
189 )
190}
191
192export function Board(props: BoardProps) {
193 const { elements, reviews, at } = props
194 const { Box, Text, Button } = elements
195 if (!reviews.length) {
196 return (
197 <Box flexDirection="column">
198 <Text dimColor>No reviews yet. Run /review-prs 19 22 inside a repo, or /review-prs ~/path/to/repo 19.</Text>
199 <Button key="close" hotkey="x" role="dismiss" onPress={props.onClose}>close</Button>
200 </Box>
201 )
202 }
203 return (
204 <Box flexDirection="column">
205 {reviews.map((review, index) => {
206 const isIdle = !props.isBusy(review) && !PIPELINE_RUNNING.includes(review.result.status)
207 const canRecombine = !props.isBusy(review) && Boolean(review.agents.claude.output && review.agents.codex.output)
208 return (
209 <Box key={review.key} flexDirection="column" marginBottom={1}>
210 <Button key={`open-${review.key}`} plain hotkey={index < 9 ? String(index + 1) : undefined} onPress={() => props.onOpen(review.key)}>
211 {`#${review.pr} `}<Text bold>{review.title}</Text>
212 <Text dimColor>{` ${review.nameWithOwner ?? review.repoName} +${review.additions} −${review.deletions}`}</Text>
213 </Button>
214 {AGENTS.map(agent => {
215 const run = review.agents[agent]
216 return (
217 <Box key={`${review.key}-${agent}`} flexDirection="column">
218 <Row elements={elements} label={AGENT_NAMES[agent]}>
219 <Box width={13} flexShrink={0}>
220 <Text color={STATUS_COLOR[run.status]}>{`${GLYPH[run.status]} ${run.status}`}</Text>
221 </Box>
222 <Box width={7} flexShrink={0} justifyContent="flex-end"><Text>{elapsed(run, at)}</Text></Box>
223 <Box flexGrow={1} flexShrink={1} minWidth={0}><Pieces elements={elements} pieces={agentPieces(review, agent, at)} /></Box>
224 {props.isActive(run)
225 ? <Button key={`cancel-${review.key}-${agent}`} dimColor onPress={() => props.onCancel(review.key, agent)}>cancel</Button>
226 : (run.status === 'failed' || run.status === 'cancelled') && !run.output
227 ? <Button key={`retry-${review.key}-${agent}`} onPress={() => props.onRetry(review.key, agent)}>retry</Button>
228 : null}
229 </Row>
230 </Box>
231 )
232 })}
233 {review.verify.status !== 'pending' && (
234 <Row elements={elements} label="Verify">
235 <Box flexGrow={1} flexShrink={1} minWidth={0}><Pieces elements={elements} pieces={verifyPieces(review, at)} /></Box>
236 </Row>
237 )}
238 <Row elements={elements} label="Result">
239 <Box flexGrow={1} flexShrink={1} minWidth={0}><Pieces elements={elements} pieces={resultPieces(review)} /></Box>
240 {canRecombine && (
241 <Button key={`recombine-${review.key}`} dimColor onPress={() => props.onRecombine(review.key)}>recombine</Button>
242 )}
243 {isIdle && (
244 <Button key={`remove-${review.key}`} dimColor onPress={() => props.onRemove(review.key)}>remove</Button>
245 )}
246 </Row>
247 </Box>
248 )
249 })}
250 <Text dimColor>Open a PR's results: click its title, or ctrl+x tab to focus this panel and press its number. Saved in ~/.agent-toolkit/pr-reviews.</Text>
251 <Button key="close" hotkey="x" role="dismiss" onPress={props.onClose}>close</Button>
252 </Box>
253 )
254}
255
256// ---------- one PR's results ----------
257
258type Section = 'disputed' | 'conflict' | 'agree' | 'one-sided' | 'withdrawn'
259
260type Entry = {
261 section: Section
262 severity: Severity
263 source: 'Both' | 'Claude' | 'Codex'
264 note: string | null
265 title: string
266 place: string
267 lines: Array<{ who: string; whoColor: Tone; text: string }>
268}
269
270const SECTION_TITLE: Record<Section, { text: string; color: Tone }> = {
271 disputed: { text: '⚔ Still disputed after the debate', color: 'error' },
272 conflict: { text: '✗ Conflicts', color: 'error' },
273 agree: { text: '⇄ Both agree', color: 'success' },
274 'one-sided': { text: 'One reviewer only', color: 'warning' },
275 withdrawn: { text: '↩ Withdrawn in the debate', color: 'subtle' },
276}
277const SECTION_ORDER: Section[] = ['disputed', 'conflict', 'agree', 'one-sided', 'withdrawn']
278const RANK: Record<Severity, number> = { critical: 3, major: 2, minor: 1 }
279
280function itemFor(group: Group, items: DebateItem[]): DebateItem | undefined {
281 return items.find(item => (item.author === 'claude' ? group.claude : group.codex).includes(item.findingIds[0] ?? ''))
282}
283
284function entries(review: Review): Entry[] {
285 const outputs = { claude: review.agents.claude.output!, codex: review.agents.codex.output! }
286 const list: Entry[] = []
287 for (const group of review.result.groups) {
288 const found = [
289 ...group.claude.map(id => ({ agent: 'claude' as const, f: outputs.claude.findings.find(x => x.id === id) })),
290 ...group.codex.map(id => ({ agent: 'codex' as const, f: outputs.codex.findings.find(x => x.id === id) })),
291 ].filter((x): x is { agent: AgentName; f: Finding } => Boolean(x.f))
292 const first = found[0]
293 if (!first) continue
294 const item = itemFor(group, review.debate.items)
295 const author: AgentName = group.claude.length ? 'claude' : 'codex'
296 let section: Section = group.kind === 'agree' ? 'agree' : group.kind === 'conflict' ? 'conflict' : 'one-sided'
297 let note: string | null = null
298 if (item?.resolution === 'confirmed') {
299 section = 'agree'
300 note = `confirmed by ${AGENT_NAMES[other(author)]} in the debate`
301 } else if (item?.resolution === 'disputed') section = 'disputed'
302 else if (item?.resolution === 'withdrawn') section = 'withdrawn'
303
304 const lines: Entry['lines'] = found.map(({ agent, f }) => ({
305 who: `${AGENT_NAMES[agent]} ${f.id}`, whoColor: SOURCE_COLOR[AGENT_NAMES[agent]]!,
306 text: `${f.explanation}${f.suggestion ? ` Fix: ${f.suggestion}` : ''}`,
307 }))
308 if (group.conflictNote) {
309 lines.push({ who: `${AGENT_NAMES[other(author)]} judged fine`, whoColor: SOURCE_COLOR[AGENT_NAMES[other(author)]]!, text: group.conflictNote })
310 }
311 if (item?.response) {
312 const responder = AGENT_NAMES[other(item.author)]
313 lines.push({ who: `${responder}, debate 1`, whoColor: SOURCE_COLOR[responder]!, text: `${item.response.stance}: ${item.response.evidence}` })
314 }
315 if (item?.rebuttal) {
316 const writer = AGENT_NAMES[item.author]
317 lines.push({ who: `${writer}, debate 2`, whoColor: SOURCE_COLOR[writer]!, text: `${item.rebuttal.stance}: ${item.rebuttal.evidence}` })
318 }
319 list.push({
320 section, severity: group.severity, note,
321 source: group.kind === 'agree' || item?.resolution === 'confirmed' ? 'Both' : AGENT_NAMES[author] as 'Claude' | 'Codex',
322 title: first.f.title, place: where(first.f), lines,
323 })
324 }
325 return list.sort((a, b) => SECTION_ORDER.indexOf(a.section) - SECTION_ORDER.indexOf(b.section) || RANK[b.severity] - RANK[a.severity])
326}
327
328function EntryView({ elements, entry }: { elements: Elements; entry: Entry }) {
329 const { Box, Text } = elements
330 const isDim = entry.section === 'withdrawn'
331 return (
332 <Box flexDirection="column" marginBottom={1}>
333 <Text wrap="wrap">
334 <Text color={SEVERITY_COLOR[entry.severity]} bold>{entry.severity.toUpperCase().padEnd(9)}</Text>
335 <Text color={SOURCE_COLOR[entry.source]} bold>{entry.source.padEnd(7)}</Text>
336 <Text bold={!isDim} dimColor={isDim} strikethrough={isDim}>{entry.title}</Text>
337 <Text dimColor>{` ${entry.place}`}</Text>
338 {entry.note && <Text color="success">{` (${entry.note})`}</Text>}
339 </Text>
340 {entry.lines.map(line => (
341 <Box paddingLeft={2}>
342 <Text wrap="wrap" dimColor={isDim}><Text color={line.whoColor}>{`${line.who}: `}</Text>{line.text}</Text>
343 </Box>
344 ))}
345 </Box>
346 )
347}
348
349function CombinedView({ elements, review }: { elements: Elements; review: Review }) {
350 const { Box, Text } = elements
351 if (review.result.status !== 'ready' || !review.agents.claude.output || !review.agents.codex.output) {
352 return <Pieces elements={elements} pieces={[{ text: 'Not ready yet: ', dim: true }, ...resultPieces(review)]} />
353 }
354 const all = entries(review)
355 return (
356 <Box flexDirection="column">
357 <Text>
358 <Text color="claude" bold>Claude </Text><Text>{`${verdictText(review.agents.claude.output)} · ${review.agents.claude.output.findings.length} findings `}</Text>
359 <Text color="suggestion" bold>Codex </Text><Text>{`${verdictText(review.agents.codex.output)} · ${review.agents.codex.output.findings.length} findings`}</Text>
360 </Text>
361 {review.verify.status !== 'pending' && <Pieces elements={elements} pieces={[{ text: 'Verify ', bold: true }, ...verifyPieces(review, 0)]} />}
362 {review.debate.status === 'skipped' && <Text dimColor>{`Debate: ${review.debate.note ?? 'skipped'}`}</Text>}
363 {!review.result.isModelMatched && <Text color="warning">Matched by line only, with no conflict check: the matching step did not answer. Press recombine on the board to try again.</Text>}
364 {SECTION_ORDER.map(section => {
365 const inSection = all.filter(e => e.section === section)
366 if (!inSection.length) return null
367 return (
368 <Box key={section} flexDirection="column" marginTop={1}>
369 <Text color={SECTION_TITLE[section].color} bold>{`${SECTION_TITLE[section].text} (${inSection.length})`}</Text>
370 {inSection.map(entry => <EntryView elements={elements} entry={entry} />)}
371 </Box>
372 )
373 })}
374 </Box>
375 )
376}
377
378function AgentView({ elements, review, agent }: { elements: Elements; review: Review; agent: AgentName }) {
379 const { Box, Text } = elements
380 const run = review.agents[agent]
381 const output = run.output
382 if (!output) return <Text dimColor>{`${AGENT_NAMES[agent]}: ${run.status}${run.error ? `: ${run.error}` : ''}`}</Text>
383 const findings = [...output.findings].sort((a, b) => RANK[b.severity] - RANK[a.severity])
384 return (
385 <Box flexDirection="column">
386 <Pieces elements={elements} pieces={[{ text: `${verdictText(output)} · `, bold: true }, ...severityPieces(output)]} />
387 <Text wrap="wrap">{output.summary}</Text>
388 {findings.map(f => (
389 <Box flexDirection="column" marginTop={1}>
390 <Text wrap="wrap">
391 <Text color={SEVERITY_COLOR[f.severity]} bold>{f.severity.toUpperCase().padEnd(9)}</Text>
392 <Text bold>{`${f.id}. ${f.title}`}</Text><Text dimColor>{` ${where(f)} · ${f.category}`}</Text>
393 </Text>
394 <Box paddingLeft={2}><Text wrap="wrap">{f.explanation}</Text></Box>
395 {f.suggestion && <Box paddingLeft={2}><Text wrap="wrap" dimColor>{`Fix: ${f.suggestion}`}</Text></Box>}
396 </Box>
397 ))}
398 {output.checked_ok.length > 0 && (
399 <Box flexDirection="column" marginTop={1}>
400 <Text color="success" bold>{`Checked and found fine (${output.checked_ok.length})`}</Text>
401 {output.checked_ok.map(c => (
402 <Box paddingLeft={2}><Text wrap="wrap" dimColor>{`${c.file}${c.line === null ? '' : `:${c.line}`} ${c.area}: ${c.note}`}</Text></Box>
403 ))}
404 </Box>
405 )}
406 </Box>
407 )
408}
409
410type DetailProps = {
411 elements: Elements
412 review: Review
413 tab: BoardTab
414 onBack: () => unknown
415 onTab: (tab: BoardTab) => unknown
416 onClose: () => unknown
417}
418
419const TABS: BoardTab[] = ['combined', 'claude', 'codex']
420const TAB_LABEL: Record<BoardTab, string> = { combined: 'Combined', claude: 'Claude', codex: 'Codex' }
421
422export function Detail({ elements, review, tab, onBack, onTab, onClose }: DetailProps) {
423 const { Box, Text, Button } = elements
424 return (
425 <Box flexDirection="column">
426 <Box flexDirection="row" gap={1}>
427 <Button key="back" hotkey="b" onPress={onBack}>back</Button>
428 {TABS.map((each, index) => (
429 <Button key={`tab-${each}`} hotkey={String(index + 1)} variant={tab === each ? 'primary' : 'secondary'} onPress={() => onTab(each)}>
430 {TAB_LABEL[each]}
431 </Button>
432 ))}
433 <Button key="close" hotkey="x" role="dismiss" onPress={onClose}>close</Button>
434 </Box>
435 <Text bold>{`${review.nameWithOwner ?? review.repoName} #${review.pr}: ${review.title}`}</Text>
436 <Text dimColor>1 Combined · 2 Claude · 3 Codex · b back · x close (ctrl+x tab focuses the panel first)</Text>
437 <Box marginTop={1} flexDirection="column">
438 {tab === 'combined'
439 ? <CombinedView elements={elements} review={review} />
440 : <AgentView elements={elements} review={review} agent={tab} />}
441 </Box>
442 </Box>
443 )
444}
445types/index.d.ts 147 lines1export type AgentName = 'claude' | 'codex'
2
3// reviewing: round 1, independent. reviewed: round 1 finished.
4// responding / waiting: the debate, answering the other agent or waiting on it.
5export type AgentStatus = 'queued' | 'preparing' | 'reviewing' | 'reviewed' | 'responding' | 'waiting' | 'done' | 'failed' | 'cancelled'
6
7export type Severity = 'critical' | 'major' | 'minor'
8
9export type Finding = {
10 id: string
11 file: string
12 line: number | null
13 end_line: number | null
14 severity: Severity
15 category: string
16 title: string
17 explanation: string
18 suggestion: string | null
19}
20
21export type CheckedOk = {
22 file: string
23 line: number | null
24 area: string
25 note: string
26}
27
28export type ReviewOutput = {
29 summary: string
30 verdict: 'approve' | 'request_changes' | 'comment'
31 findings: Finding[]
32 checked_ok: CheckedOk[]
33}
34
35export type AgentRun = {
36 status: AgentStatus
37 startedAt: number | null
38 endedAt: number | null
39 activity: string | null
40 activityAt: number | null
41 error: string | null
42 output: ReviewOutput | null
43 usage: string | null
44}
45
46export type GroupKind = 'agree' | 'claude' | 'codex' | 'conflict'
47
48export type Group = {
49 kind: GroupKind
50 severity: Severity
51 claude: string[]
52 codex: string[]
53 conflictNote: string | null
54}
55
56export type VerifyStepStatus = 'pending' | 'running' | 'passed' | 'failed'
57
58export type VerifyStep = {
59 name: string
60 command: string[]
61 status: VerifyStepStatus
62 startedAt: number | null
63 durationMs: number | null
64 summary: string | null
65}
66
67export type VerifyStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
68
69export type Verification = {
70 status: VerifyStatus
71 steps: VerifyStep[]
72 note: string | null
73}
74
75export type Stance = 'agree' | 'disagree' | 'unsure' | 'maintain' | 'withdraw'
76
77export type DebateReply = { stance: Stance; evidence: string }
78
79export type Resolution = 'pending' | 'confirmed' | 'withdrawn' | 'disputed'
80
81// One disputed group: a conflict, or a major/critical finding only one agent raised.
82export type DebateItem = {
83 id: string
84 author: AgentName
85 findingIds: string[]
86 isConflict: boolean
87 conflictNote: string | null
88 response: DebateReply | null
89 rebuttal: DebateReply | null
90 resolution: Resolution
91}
92
93export type DebateStatus = 'pending' | 'running' | 'done' | 'skipped' | 'failed'
94
95export type Debate = {
96 status: DebateStatus
97 round: number
98 turn: AgentName | 'both' | null
99 items: DebateItem[]
100 note: string | null
101}
102
103export type ResultStatus = 'waiting' | 'combining' | 'verifying' | 'debating' | 'ready' | 'failed'
104
105export type ReviewResult = {
106 status: ResultStatus
107 groups: Group[]
108 isModelMatched: boolean
109 error: string | null
110}
111
112export type Review = {
113 key: string
114 repoRoot: string
115 repoName: string
116 nameWithOwner: string | null
117 isTrusted: boolean
118 pr: number
119 title: string
120 additions: number
121 deletions: number
122 worktree: string
123 outDir: string
124 prompt: string | null
125 agents: Record<AgentName, AgentRun>
126 result: ReviewResult
127 verify: Verification
128 debate: Debate
129}
130
131export type BoardTab = 'combined' | 'claude' | 'codex'
132
133export type BoardView = {
134 selected: string | null
135 tab: BoardTab
136}
137
138declare module 'claude-code' {
139 interface PluginState {
140 'pr-review-board': {
141 reviews: Review[]
142 view: BoardView
143 now: number
144 }
145 }
146}
147