SLOPSHOPPER

aichemist-pr-review-pane

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

newpanecommandprocesstimer
★ 1v3.10.0MITupdated 2026-10-05Anras573/AIchemist/mods/pr-review-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · aichemist-pr-review-pane
│ ┃ PR review ✕ › fix the failing auth test and add an audit log call │ ┃ Loading the PR for this branch… │ ┃ [ Refreshing… ] [ Close ] ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ › /pr-pane │ ⎿ aichemist-pr-review-pane: PR review pane opened for the current │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · PR review
Loading the PR for this branch… [ Refreshing… ] [ Close ]
README

AIchemist

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.

Installation

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.

What's Included

Agents — Specialized agents for the Task tool:

  • Code Review — Parallel review with Jira integration and confidence scoring
  • TypeScript/React — Full-stack TypeScript, React, Next.js, Node.js
  • .NET — C#/.NET with async patterns, SOLID, DDD, testing
  • DDD — Domain-Driven Design guidance (language-agnostic)

Skills — Context-aware capabilities:

  • Ticket Flow — End-to-end workflow for taking a Jira ticket from definition to reviewed implementation
  • Brainstorming — Structured design dialogue with a hard gate before any implementation
  • Beads — AI-native task tracking with automatic sidecar storage outside your repo
  • Code Review — Parallel review agents with confidence scoring and Jira integration
  • Jira — Issue management with confirmation gates for write operations
  • Mermaid — Generate diagrams (flowcharts, sequence, ER, C4, and more) as markdown
  • PostgreSQL — Safe database querying with blocked writes by default
  • Daily Note — Interact with your Obsidian daily note for journaling and task tracking
  • Capture — Quick capture of thoughts and code snippets to Obsidian
  • Research — Search your Obsidian vault for context during coding sessions
  • Playwright — Browser automation and web testing via playwright-cli
  • Calendar — Calendar queries and meeting prep briefings for Microsoft 365 (via m365 CLI) and iCloud/CalDAV
  • Markitdown — Convert remote URLs and local files to clean markdown
  • MemPalace — Persistent local memory (vector + knowledge graph) across sessions
  • Tool Preferences — Consistent tool selection patterns

Mods — Optional Claude Code-only add-ons, installed separately; they add to the skills above and never replace them:

  • Calendar Status — Current and next meeting in the status line, via the Calendar skill's calendar.sh, for Microsoft 365 or iCloud (claude plugin install aichemist-calendar-status@aichemist)
  • Beads Band — In-progress and ready beads tasks above the prompt, via the Beads skill's beads-db.sh (claude plugin install aichemist-beads-band@aichemist)
  • PR Review Pane — /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.

Quick Start

# 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

Philosophy

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:

  • Composable — Works well independently and in combination
  • Documented — Clear purpose and usage instructions
  • Practical — Solves real problems in daily workflows

License

See LICENSE for details.

Source 3 files
hooks/register.tsx 228 lines
1import { 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}
228
hooks/github.ts 210 lines
1// 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}
210
types/index.d.ts 43 lines
1export 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