Claude Code mod: a read-only pane showing what the pr-review-loop skill works on (Copilot review state, CI, unresolved threads)

Transmuting raw AI capabilities into golden solutions
A plugin for Claude Code and GitHub Copilot CLI with custom agents and skills for AI-assisted development.
Claude Code:
claude plugin install aichemist
GitHub Copilot CLI:
copilot plugin install aichemist
See docs/installation.md for alternative installation methods and MCP server configuration.
Agents — Specialized agents for the Task tool:
Skills — Context-aware capabilities:
playwright-clim365 CLI) and iCloud/CalDAVMods — Optional Claude Code-only add-ons, installed separately; they add to the skills above and never replace them:
calendar.sh, for Microsoft 365 or iCloud (claude plugin install aichemist-calendar-status@aichemist)beads-db.sh (claude plugin install aichemist-beads-band@aichemist)/pr-pane shows the PR Review Loop's state, CI and unresolved threads in a live pane (claude plugin install aichemist-pr-review-pane@aichemist)See docs/ for detailed documentation.
# Review your current branch (skill auto-invoked by natural language)
# "Do a code review" or /code-review
# See your recent Jira tickets
# "Show my Jira tickets from last week" or /jira
# The agents work automatically via Task tool
# e.g., "Review this PR for DDD patterns" invokes the DDD agent
Like the alchemists of old who sought to transform base metals into gold, AIchemist aims to refine and combine AI building blocks into powerful, practical solutions. Each component is crafted to be:
See LICENSE for details.
hooks/register.tsx 228 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { PrSnapshot } from '../types'
5import {
6 PR_VIEW_FIELDS,
7 THREADS_QUERY,
8 describeGhError,
9 formatAgo,
10 parseSnapshot,
11 repoFromUrl,
12} from './github'
13
14const PANE = 'pr-review'
15const COMMAND = 'pr-pane'
16
17const snapshot = atom({ plugin: 'aichemist-pr-review-pane', key: 'snapshot' } as const, null as PrSnapshot | null)
18const error = atom({ plugin: 'aichemist-pr-review-pane', key: 'error' } as const, null as string | null)
19const isLoading = atom({ plugin: 'aichemist-pr-review-pane', key: 'isLoading' } as const, false)
20const prNumber = atom({ plugin: 'aichemist-pr-review-pane', key: 'prNumber' } as const, null as number | null)
21
22const STATE_COLOR = { WAITING: 'yellow', REVIEWING: 'cyan', DONE: 'green' } as const
23const STATE_TEXT = {
24 WAITING: 'waiting for Copilot to review the latest commit',
25 REVIEWING: 'Copilot has reviewed; threads to address',
26 DONE: 'Copilot has reviewed; nothing left open',
27} as const
28
29async function fetchSnapshot($: EngineInterface): Promise<void> {
30 if (await read($, isLoading)) {
31 return
32 }
33 await update($, isLoading, () => true)
34
35 try {
36 const pinned = await read($, prNumber)
37 const view = await $.process.run([
38 'gh',
39 'pr',
40 'view',
41 ...(pinned === null ? [] : [String(pinned)]),
42 '--json',
43 PR_VIEW_FIELDS,
44 ])
45 if (view.exitCode !== 0) {
46 await update($, error, () => describeGhError(view.stderr))
47 return
48 }
49
50 const pr = JSON.parse(view.stdout) as { number: number; url: string }
51 const repo = repoFromUrl(pr.url)
52 if (!repo) {
53 await update($, error, () => `Could not read the repository from ${pr.url}.`)
54 return
55 }
56
57 const threads = await $.process.run([
58 'gh',
59 'api',
60 'graphql',
61 '-f',
62 `query=${THREADS_QUERY}`,
63 '-F',
64 `owner=${repo.owner}`,
65 '-F',
66 `repo=${repo.repo}`,
67 '-F',
68 `pr=${pr.number}`,
69 ])
70 if (threads.exitCode !== 0) {
71 await update($, error, () => describeGhError(threads.stderr))
72 return
73 }
74
75 const next = parseSnapshot(view.stdout, threads.stdout, await $.clock.now())
76 await update($, snapshot, () => next)
77 await update($, error, () => null)
78 } catch (caught) {
79 // gh missing, timed out, or printed something that is not JSON.
80 const message = caught instanceof Error ? caught.message : String(caught)
81 await update($, error, () => `Could not run gh: ${message}`)
82 } finally {
83 await update($, isLoading, () => false)
84 }
85}
86
87async function isOpen($: EngineInterface): Promise<boolean> {
88 return (await $.ui.panes()).some(pane => pane.id === PANE)
89}
90
91async function openPane($: EngineInterface): Promise<void> {
92 await $.ui.open({ id: PANE, title: 'PR review' })
93 void fetchSnapshot($)
94}
95
96// A read-only view of what skills/pr-review-loop works on: the PR for the
97// current branch, Copilot's review state, CI and the unresolved threads. It
98// never writes to GitHub; fixing stays the skill's job.
99export const register: Register = (on, options) => {
100 const refreshMs = Math.max(15, Number(options.refreshSeconds ?? 60)) * 1000
101 let poll: Timer | undefined
102
103 on('session.start', async ($, e, next) => {
104 await $.command.register({
105 name: COMMAND,
106 description: 'Show the PR review state (Copilot review, CI, unresolved threads) in a pane',
107 argumentHint: '[pr-number]',
108 })
109 // State outlives a reload; a fetch cut off by one must not block the next.
110 await update($, isLoading, () => false)
111 // Polls only while the pane is open, so a closed pane costs nothing.
112 // session.start can fire again without a reload; don't stack timers.
113 poll?.cancel()
114 poll = $.clock.every(refreshMs, () => {
115 void isOpen($).then(open => (open ? fetchSnapshot($) : undefined))
116 })
117
118 return next(e)
119 })
120
121 on('command.run', { command: COMMAND }, async ($, e) => {
122 const arg = e.args.trim().replace(/^#/, '')
123 const pinned = /^\d+$/.test(arg) ? Number(arg) : null
124 if (arg !== '' && pinned === null) {
125 return { text: `Usage: /${COMMAND} [pr-number]` }
126 }
127
128 const previous = await read($, prNumber)
129 if (previous !== pinned) {
130 await update($, prNumber, () => pinned)
131 await update($, snapshot, () => null)
132 await update($, error, () => null)
133 }
134 await openPane($)
135
136 return { text: pinned === null ? 'PR review pane opened for the current branch.' : `PR review pane opened for #${pinned}.` }
137 })
138
139 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
140 const { Box, Text, Button } = $.ui.resolve(e)
141 const pr = await read($, snapshot)
142 const problem = await read($, error)
143 const loading = await read($, isLoading)
144 const now = await $.clock.now()
145 const width = Math.max(20, e.props.bodyColumns)
146
147 const buttons = (
148 <Box flexDirection="row" gap={1}>
149 <Button key="refresh" hotkey="r" label={loading ? 'Refreshing…' : 'Refresh'} onPress={() => fetchSnapshot($)} />
150 {pr !== null && pr.state === 'REVIEWING' && (
151 <Button
152 key="run-loop"
153 hotkey="l"
154 variant="primary"
155 label="Run review loop"
156 onPress={async () => {
157 await $.prompt.submit({ text: `Run review loop for PR #${pr.number} (the pr-review-loop skill).`, asUser: true })
158 }}
159 />
160 )}
161 <Button key="close" hotkey="q" role="dismiss" label="Close" onPress={() => $.ui.close({ id: PANE })} />
162 </Box>
163 )
164
165 if (pr === null) {
166 return (
167 <Box flexDirection="column">
168 <Text dimColor={problem === null} color={problem === null ? undefined : 'red'} wrap="wrap">
169 {problem ?? 'Loading the PR for this branch…'}
170 </Text>
171 {buttons}
172 </Box>
173 )
174 }
175
176 const { checks } = pr
177 const copilotThreads = pr.threads.filter(t => t.isCopilot).length
178 const otherThreads = pr.threads.length - copilotThreads
179
180 return (
181 <Box flexDirection="column">
182 <Text bold wrap="truncate-end">
183 #{pr.number} {pr.title}
184 {pr.isDraft ? ' (draft)' : ''}
185 </Text>
186 <Text dimColor wrap="truncate-middle">
187 {pr.branch} · {pr.url}
188 </Text>
189 <Text> </Text>
190 <Text wrap="wrap">
191 <Text bold color={STATE_COLOR[pr.state]}>
192 {pr.state}
193 </Text>{' '}
194 {STATE_TEXT[pr.state]}
195 </Text>
196 <Text dimColor wrap="wrap">
197 Copilot reviewed {formatAgo(pr.lastReviewMs, now)}
198 {pr.reviewedOid === undefined ? '' : ` on ${pr.reviewedOid.slice(0, 7)}`} · head {pr.headOid.slice(0, 7)}
199 </Text>
200 <Text wrap="wrap">
201 CI: <Text color="green">✓ {checks.passed}</Text> · <Text color={checks.failed > 0 ? 'red' : undefined}>✗ {checks.failed}</Text> ·{' '}
202 <Text color={checks.pending > 0 ? 'yellow' : undefined}>… {checks.pending}</Text>
203 {checks.failedNames.length > 0 ? ` failing: ${checks.failedNames.join(', ')}` : ''}
204 </Text>
205 <Text> </Text>
206 <Text bold>
207 Unresolved threads: {copilotThreads} from Copilot{otherThreads > 0 ? `, ${otherThreads} from people` : ''}
208 </Text>
209 {pr.threads.length === 0 && <Text dimColor>None.</Text>}
210 {pr.threads.map(thread => (
211 <Box flexDirection="column" width={width}>
212 <Text wrap="truncate-end">
213 <Text color={thread.isCopilot ? 'magenta' : 'blue'}>{thread.isCopilot ? 'Copilot' : thread.author}</Text>{' '}
214 <Text dimColor>{thread.location}</Text>
215 </Text>
216 <Text wrap="wrap"> {thread.summary}</Text>
217 </Box>
218 ))}
219 <Text> </Text>
220 <Text dimColor>
221 {problem !== null ? `Last refresh failed: ${problem} · ` : ''}Updated {formatAgo(pr.fetchedAtMs, now)}
222 </Text>
223 {buttons}
224 </Box>
225 )
226 })
227}
228hooks/github.ts 210 lines1// Pure helpers for the PR review pane: no `$`, so tests call them directly.
2// Inputs are what `gh pr view --json ...` and `gh api graphql` print, using
3// the same fields and the same Copilot login as skills/pr-review-loop.
4
5import type { CheckSummary, LoopState, PrSnapshot, Thread } from '../types'
6
7export const COPILOT_LOGIN = 'copilot-pull-request-reviewer'
8
9export const PR_VIEW_FIELDS = [
10 'number',
11 'title',
12 'url',
13 'headRefName',
14 'headRefOid',
15 'isDraft',
16 'reviews',
17 'statusCheckRollup',
18].join(',')
19
20// What pr-review-loop reads in Step 2: the review threads, first comment only.
21export const THREADS_QUERY = `
22query($owner: String!, $repo: String!, $pr: Int!) {
23 repository(owner: $owner, name: $repo) {
24 pullRequest(number: $pr) {
25 reviewThreads(first: 100) {
26 nodes {
27 isResolved
28 comments(first: 1) {
29 nodes { author { login } body path line url }
30 }
31 }
32 }
33 }
34 }
35}`
36
37type PrView = {
38 number: number
39 title: string
40 url: string
41 headRefName: string
42 headRefOid: string
43 isDraft?: boolean
44 reviews?: {
45 author?: { login?: string } | null
46 submittedAt?: string | null
47 commit?: { oid?: string } | null
48 }[]
49 statusCheckRollup?: RollupItem[] | null
50}
51
52type RollupItem = {
53 __typename?: string
54 name?: string
55 context?: string
56 status?: string
57 conclusion?: string | null
58 state?: string
59}
60
61type ThreadsResponse = {
62 data?: {
63 repository?: {
64 pullRequest?: {
65 reviewThreads?: {
66 nodes?: {
67 isResolved: boolean
68 comments?: {
69 nodes?: {
70 author?: { login?: string } | null
71 body?: string
72 path?: string
73 line?: number | null
74 url?: string
75 }[]
76 }
77 }[]
78 }
79 } | null
80 } | null
81 }
82}
83
84// owner/repo of the PR itself (the base repository), read off its URL so a
85// PR from a fork still queries the repository its threads live on.
86export function repoFromUrl(url: string): { owner: string; repo: string } | undefined {
87 const match = /^https:\/\/[^/]+\/([^/]+)\/([^/]+)\/pull\/\d+/.exec(url)
88
89 return match ? { owner: match[1]!, repo: match[2]! } : undefined
90}
91
92const FAILED = new Set(['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'ERROR'])
93const PENDING = new Set(['PENDING', 'EXPECTED', 'QUEUED', 'IN_PROGRESS', 'WAITING', 'REQUESTED'])
94
95export function summarizeChecks(rollup: readonly RollupItem[] | null | undefined): CheckSummary {
96 const summary: CheckSummary = { passed: 0, failed: 0, pending: 0, failedNames: [] }
97
98 for (const item of rollup ?? []) {
99 const name = item.name ?? item.context ?? 'check'
100 // CheckRun: status + conclusion. StatusContext: state.
101 const outcome =
102 item.__typename === 'StatusContext' || item.state !== undefined
103 ? item.state ?? 'PENDING'
104 : item.status !== 'COMPLETED'
105 ? item.status ?? 'PENDING'
106 : item.conclusion ?? 'PENDING'
107
108 if (FAILED.has(outcome)) {
109 summary.failed += 1
110 summary.failedNames.push(name)
111 } else if (PENDING.has(outcome)) {
112 summary.pending += 1
113 } else {
114 summary.passed += 1
115 }
116 }
117
118 return summary
119}
120
121// The pr-review-loop state machine (Step 3): Copilot has reviewed the latest
122// push when its latest review was made on the head commit.
123export function loopState(
124 reviewedOid: string | undefined,
125 headOid: string,
126 unresolvedCopilot: number,
127): LoopState {
128 if (reviewedOid === undefined || reviewedOid !== headOid) {
129 return 'WAITING'
130 }
131
132 return unresolvedCopilot === 0 ? 'DONE' : 'REVIEWING'
133}
134
135function firstLine(text: string, max = 120): string {
136 const line = text.trim().split('\n').find(l => l.trim() !== '')?.trim() ?? ''
137
138 return line.length <= max ? line : `${line.slice(0, max - 1)}…`
139}
140
141export function parseSnapshot(prViewJson: string, threadsJson: string, fetchedAtMs: number): PrSnapshot {
142 const pr = JSON.parse(prViewJson) as PrView
143 const graph = JSON.parse(threadsJson) as ThreadsResponse
144 const repository = graph.data?.repository
145
146 const latestCopilot = (pr.reviews ?? [])
147 .filter(r => r.author?.login === COPILOT_LOGIN && r.submittedAt && Number.isFinite(Date.parse(r.submittedAt)))
148 .sort((a, b) => Date.parse(a.submittedAt!) - Date.parse(b.submittedAt!))
149 .at(-1)
150 const lastReviewMs = latestCopilot ? Date.parse(latestCopilot.submittedAt!) : undefined
151 const reviewedOid = latestCopilot?.commit?.oid || undefined
152
153 const threads: Thread[] = (repository?.pullRequest?.reviewThreads?.nodes ?? [])
154 .filter(node => !node.isResolved)
155 .map(node => node.comments?.nodes?.[0])
156 .filter((c): c is NonNullable<typeof c> => c !== undefined)
157 .map(c => ({
158 author: c.author?.login ?? 'unknown',
159 isCopilot: c.author?.login === COPILOT_LOGIN,
160 location: c.path ? (c.line ? `${c.path}:${c.line}` : c.path) : '',
161 summary: firstLine(c.body ?? ''),
162 url: c.url ?? '',
163 }))
164
165 const unresolvedCopilot = threads.filter(t => t.isCopilot).length
166
167 return {
168 number: pr.number,
169 title: pr.title,
170 url: pr.url,
171 branch: pr.headRefName,
172 isDraft: pr.isDraft === true,
173 state: loopState(reviewedOid, pr.headRefOid, unresolvedCopilot),
174 lastReviewMs,
175 reviewedOid,
176 headOid: pr.headRefOid,
177 checks: summarizeChecks(pr.statusCheckRollup),
178 threads,
179 fetchedAtMs,
180 }
181}
182
183export function formatAgo(thenMs: number | undefined, nowMs: number): string {
184 if (thenMs === undefined) {
185 return 'never'
186 }
187
188 const minutes = Math.floor(Math.max(0, nowMs - thenMs) / 60_000)
189 if (minutes < 1) {
190 return 'just now'
191 }
192 if (minutes < 60) {
193 return `${minutes}m ago`
194 }
195
196 const hours = Math.floor(minutes / 60)
197
198 return hours < 48 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`
199}
200
201// First line of gh's stderr, for the pane's error row.
202export function describeGhError(stderr: string): string {
203 const line = firstLine(stderr, 160)
204 if (/no pull requests found/i.test(line)) {
205 return 'No open PR for this branch. Push it and open a PR, or run /pr-pane <number>.'
206 }
207
208 return line || 'gh failed with no message.'
209}
210types/index.d.ts 43 lines1export type LoopState = 'WAITING' | 'REVIEWING' | 'DONE'
2
3export type CheckSummary = {
4 passed: number
5 failed: number
6 pending: number
7 failedNames: string[]
8}
9
10export type Thread = {
11 author: string
12 isCopilot: boolean
13 location: string
14 summary: string
15 url: string
16}
17
18export type PrSnapshot = {
19 number: number
20 title: string
21 url: string
22 branch: string
23 isDraft: boolean
24 state: LoopState
25 lastReviewMs?: number
26 reviewedOid?: string
27 headOid: string
28 checks: CheckSummary
29 threads: Thread[]
30 fetchedAtMs: number
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'aichemist-pr-review-pane': {
36 snapshot: PrSnapshot | null
37 error: string | null
38 isLoading: boolean
39 prNumber: number | null
40 }
41 }
42}
43