SLOPSHOPPER

pr-review-board

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.

newpanecommandstatusmodelprocess
v0.1.1no licenseupdated 2026-10-10ekcm/agent-toolkit/mods/pr-review-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-review-board
│ ┃ PR reviews ✕ › fix the failing auth test and add an audit log call │ ┃ No reviews yet. Run /review-prs 19 22 inside │ ┃ a repo, or /review-prs ~/path/to/repo 19. ⏺ Read(src/auth.ts) │ ┃ [ close ] ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /review-prs │ ⎿ pr-review-board: PR reviews panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · PR reviews
No reviews yet. Run /review-prs 19 22 inside a repo, or /review-prs ~/path/to/repo 19. [ close ]
README

agent-toolkit

Shared skills, mods and plugins for coding agents — Claude Code, Codex, pi, and anything else that reads the Agent Skills format.

Install

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:

FolderRead by
~/.agents/skills/<name>Codex, pi, and other Agent Skills tools
~/.claude/skills/<name>Claude Code (it does not read ~/.agents/skills)

It also:

  • Backs up instead of deleting. Anything already at a target path is moved to ~/.agent-toolkit-backups/<timestamp>/.
  • Removes old copies from ~/.codex/skills. Codex still reads that legacy folder as well as ~/.agents/skills, so a copy left there makes each skill appear twice.
  • Links skill by skill, never replacing a whole folder, so skills installed by other tools (e.g. npx skills) are left alone.
  • Is safe to re-run. Links that are already correct are left as they are.

Don't also link skills into ~/.pi/agent/skills: pi already reads ~/.agents/skills and warns about duplicate names.

Updating

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.

Uninstalling

./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.

Skills

Each folder in skills/ is a standard SKILL.md skill: frontmatter with only name and description, and plain instructions with no agent-specific tools.

SkillWhat it does
code-review-uncommitted-changesStrict review of uncommitted changes
verify-reviewSecond-pass check that a prior review was implemented, plus a regression scan
review-pull-requestDeep review of a GitHub PR via gh
retrieve-pull-request-commentsFetch and summarise review comments on the current branch's PR
draft-pr-descriptionWrite PR.md from the branch's commits and changes
handover-session-notesSave session context to NOTES.md for the next session

Invoke a skill by name — /name in Claude Code, $name in Codex.

Claude Code mods

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.

pr-review-board

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.

The PR review panel reviewing two PRs: each reviewer's live activity, verification results and a debate in progress

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.

CommandWhat it does
/review-prs ~/path/to/repo 19 22Review PRs 19 and 22 of that repo (inside the repo, /review-prs 19 22 is enough)
/review-prsOpen the panel
/review-prs trust owner/repoLet PRs of a repo you don't own run their install, tests and build
/review-prs untrust owner/repoUndo that; /review-prs trust lists the approved repos

Using the panel:

  • Press ctrl+x tab to focus the panel, then a PR's number key to open its results (or click its title). Inside a PR, 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.
  • Each row has 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).
  • A PR number the repo doesn't have is reported in the command's reply and not added. Running a PR again after it finished starts a fresh review and overwrites its saved results.

What it touches:

  • Each PR is checked out as a detached worktree at ~/.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.
  • Both reviewers and the debate are read-only. Verification runs the PR's code, so it runs only for your own repos or ones you approved, and never for a branch from someone else's fork.
  • Reviews, logs and the combined result (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>

dev-statusline

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:

  • PR: the open PR for the current branch, from 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.
  • ctx: a gauge of the context window used, ▰ for used and ▱ for free, then the share used and tokens used out of the window (32% 64k/200k), straight from Claude Code.
  • claude 5h: how much of Claude's 5-hour usage window is left, and the time until it resets, straight from Claude Code. It shows — off a subscription.
  • codex 5h: how much of Codex's 5-hour window is left, and the time until it resets, live from your Codex account: the mod starts 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.

Source 8 files
hooks/register.tsx 641 lines
1import { 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}
641
hooks/debate.ts 163 lines
1// 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}
163
hooks/reconcile.ts 273 lines
1// 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}
273
hooks/review-spec.ts 86 lines
1// 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']
86
hooks/stream.ts 121 lines
1// 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}
121
hooks/verify.ts 73 lines
1// 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}
73
hooks/view.tsx 445 lines
1// 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}
445
types/index.d.ts 147 lines
1export 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