/prs opens a pane of your open pull requests in this repo, drawn as stacks with CI and review state, refreshed while it is open.

Building the new Frontier.
skills/ holds my agent skills for Claude Code and Codex.
| Skill | What it does |
|---|---|
learn | /learn: saves lessons from a session into my agent guides, after I approve each change. |
audit-memory | /audit-memory: re-checks those guides against current code, fills gaps, and reports what it saw. |
pr-self-review, pr-groom, pr-address-feedback, pr-rebase | For my own PRs. They chain, and share pr-common.md. |
pr-peer-review | For colleagues' PRs: keeps only real issues, and posts the review when I say so. |
pr-stack | /prs: a Claude Code mod, not a skill. A pane of my open PRs as stacks, with CI and review state. |
socratic-learning | Socratic tutoring and debate practice. |
xurl | Read-only X research: in-app browser by default, xurl for explicit API requests. |
./install.sh # link every skill into ~/.claude/skills and ~/.agents/skills
./install.sh --check # only report
Re-run it after adding a skill or moving this repo. ~/.agents/skills is Codex's user skills directory.
The skills are written for my own setup. They refer to each other and to my agent guides, which live in a private repo, by absolute path, because agents load them through symlinks.
skills/<name>/SKILL.md, then run ./install.sh. For a skill that should run only when invoked, set disable-model-invocation: true for Claude and add agents/openai.yaml with policy: allow_implicit_invocation: false for Codex. Codex doesn't substitute $ARGUMENTS, so say where to find the arguments when it's left as is..claude-plugin/plugin.json and hooks/) in skills/<name>/ and run ./install.sh; Claude Code loads it from ~/.claude/skills. Gitignore the .claude-plugin/types/ and tsconfig.json it writes there.hooks/register.tsx 139 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Board } from '../types'
5import { FIELDS, ciColor, ciLine, toRows } from './prs'
6
7const PANE = 'pr-stack'
8const EVERY_MS = 60_000
9// How long GitHub takes to show a push or a PR change.
10const SETTLE_MS = 5_000
11const PR_CHANGE = /\bgit\s+push\b|\bgh\s+pr\s+(create|merge|ready|edit|close|reopen)\b/
12
13const board = atom({ plugin: 'pr-stack', key: 'board' } as const, null)
14
15async function refresh($: EngineInterface): Promise<void> {
16 const fetchedAt = await $.clock.now()
17 let next: Board
18 try {
19 const [listed, branch] = await Promise.all([
20 $.process.run(
21 ['gh', 'pr', 'list', '--author', '@me', '--state', 'open', '--limit', '50', '--json', FIELDS],
22 { timeoutMs: 20_000 },
23 ),
24 $.process.run(['git', 'branch', '--show-current']),
25 ])
26 next =
27 listed.exitCode === 0
28 ? { prs: toRows(listed.stdout), branch: branch.stdout.trim(), fetchedAt }
29 : { prs: [], branch: '', fetchedAt, error: listed.stderr.trim() || 'gh pr list failed' }
30 } catch (error) {
31 next = { prs: [], branch: '', fetchedAt, error: String(error) }
32 }
33 // A failed refresh keeps the last rows on screen under its error.
34 await update($, board, last =>
35 next.error !== undefined && last !== null ? { ...last, fetchedAt, error: next.error } : next,
36 )
37}
38
39const isOpen = async ($: EngineInterface): Promise<boolean> =>
40 (await $.ui.panes()).some(pane => pane.id === PANE)
41
42const clock = (ms: number): string => {
43 const at = new Date(ms)
44 return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
45}
46
47// Set while the pane is open; a reload drops it with the timer it names.
48let timer: Timer | undefined
49
50function poll($: EngineInterface): Promise<void> {
51 timer?.cancel()
52 timer = $.clock.every(EVERY_MS, () => void refresh($))
53 return refresh($)
54}
55
56export const register: Register = on => {
57 on('session.start', async ($, e, next) => {
58 await $.command.register({
59 name: 'prs',
60 description: 'Show my open PRs in this repo as stacks, with CI and review state',
61 })
62 // A reload finds the pane still up with no timer behind it.
63 if (await isOpen($)) void poll($)
64 return next(e)
65 })
66
67 on('command.run', { command: 'prs' }, async $ => {
68 const opened = await $.ui.open({ id: PANE, title: 'PRs', columns: 64 })
69 await poll($)
70 return { text: opened.isPlaced ? 'PR stack pane opened.' : 'PR stack pane is open; widen the terminal to see it.' }
71 })
72
73 on('ui.close', { id: PANE }, async ($, e, next) => {
74 const closed = await next(e)
75 if (!(await isOpen($))) {
76 timer?.cancel()
77 timer = undefined
78 }
79 return closed
80 })
81
82 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
83 const ran = await next(e)
84 if (timer !== undefined && PR_CHANGE.test(e.command)) $.clock.after(SETTLE_MS, () => void refresh($))
85 return ran
86 })
87
88 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
89 const { Box, Button, Link, Text } = $.ui.resolve(e)
90 const data = await read($, board)
91 if (data === null) return <Text dimColor>Loading your PRs…</Text>
92
93 return (
94 <Box flexDirection="column">
95 <Box justifyContent="space-between">
96 <Text dimColor>
97 {data.prs.length} open · {clock(data.fetchedAt)}
98 </Text>
99 <Button key="refresh" label="refresh" hotkey="r" plain dimColor onPress={() => refresh($)} />
100 </Box>
101 {data.error !== undefined && <Text color="red" wrap="truncate-end">{data.error}</Text>}
102 {data.prs.length === 0 && data.error === undefined && <Text dimColor>No open PRs of yours here.</Text>}
103 {data.prs.map(pr => {
104 const indent = ' '.repeat(pr.depth)
105 const isHere = pr.head === data.branch
106 return (
107 <Box flexDirection="column" marginTop={pr.depth === 0 ? 1 : 0}>
108 <Box>
109 <Text dimColor>{pr.depth === 0 ? '' : `${indent.slice(2)}└ `}</Text>
110 <Link href={pr.url} label={`#${pr.number}`} />
111 <Text bold={isHere} wrap="truncate-end">
112 {' '}
113 {pr.title}
114 {isHere ? ' ◀' : ''}
115 </Text>
116 </Box>
117 <Box>
118 <Text>{indent} </Text>
119 <Text {...(ciColor(pr) === undefined ? { dimColor: true } : { color: ciColor(pr) })}>{ciLine(pr)}</Text>
120 <Text dimColor>
121 {' '}
122 {pr.isDraft ? 'draft · ' : ''}
123 {pr.review}
124 </Text>
125 </Box>
126 {pr.failing.length > 0 && (
127 <Text color="red" wrap="truncate-end">
128 {indent} {pr.failing.slice(0, 3).join(', ')}
129 {pr.failing.length > 3 ? ` +${pr.failing.length - 3}` : ''}
130 </Text>
131 )}
132 </Box>
133 )
134 })}
135 </Box>
136 )
137 })
138}
139hooks/prs.ts 86 lines1import type { Pr, Review } from '../types'
2
3export const FIELDS = 'number,title,url,headRefName,baseRefName,isDraft,reviewDecision,statusCheckRollup'
4
5// One entry of `gh pr list --json statusCheckRollup`: a check run or a commit status.
6type Check = {
7 name?: string
8 context?: string
9 status?: string
10 conclusion?: string
11 state?: string
12}
13
14type Listed = {
15 number: number
16 title: string
17 url: string
18 headRefName: string
19 baseRefName: string
20 isDraft: boolean
21 reviewDecision?: string
22 statusCheckRollup?: Check[] | null
23}
24
25const PASSED = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
26const WAITING = new Set(['PENDING', 'EXPECTED'])
27const REVIEWS: Record<string, Review> = {
28 APPROVED: 'approved',
29 CHANGES_REQUESTED: 'changes requested',
30 REVIEW_REQUIRED: 'review required',
31}
32
33function tally(checks: readonly Check[]): Pick<Pr, 'passing' | 'pending' | 'failing'> {
34 const failing: string[] = []
35 let passing = 0
36 let pending = 0
37 for (const check of checks) {
38 // A check run carries `status` and `conclusion`; a commit status only `state`.
39 const isRun = check.status !== undefined
40 const result = isRun ? check.conclusion : check.state
41 if (isRun ? check.status !== 'COMPLETED' : WAITING.has(result ?? '')) pending += 1
42 else if (PASSED.has(result ?? '')) passing += 1
43 else failing.push(check.name ?? check.context ?? 'check')
44 }
45 return { passing, pending, failing }
46}
47
48/** `gh pr list` output as rows, each stack listed base first with its dependents beneath it. */
49export function toRows(json: string): Pr[] {
50 const listed = JSON.parse(json) as Listed[]
51 const heads = new Set(listed.map(pr => pr.headRefName))
52 const rows: Pr[] = []
53 const seen = new Set<number>()
54 const place = (pr: Listed, depth: number) => {
55 if (seen.has(pr.number)) return
56 seen.add(pr.number)
57 rows.push({
58 number: pr.number,
59 title: pr.title,
60 url: pr.url,
61 head: pr.headRefName,
62 isDraft: pr.isDraft,
63 review: REVIEWS[pr.reviewDecision ?? ''] ?? 'no review',
64 ...tally(pr.statusCheckRollup ?? []),
65 depth,
66 })
67 for (const child of listed) if (child.baseRefName === pr.headRefName) place(child, depth + 1)
68 }
69 for (const pr of listed) if (!heads.has(pr.baseRefName)) place(pr, 0)
70 // Whatever a cycle of bases left unplaced still shows.
71 for (const pr of listed) place(pr, 0)
72 return rows
73}
74
75export const ciLine = (pr: Pr): string => {
76 const parts = [
77 ...(pr.failing.length > 0 ? [`✗ ${pr.failing.length}`] : []),
78 ...(pr.pending > 0 ? [`● ${pr.pending}`] : []),
79 ...(pr.passing > 0 ? [`✓ ${pr.passing}`] : []),
80 ]
81 return parts.length === 0 ? 'no checks' : parts.join(' ')
82}
83
84export const ciColor = (pr: Pr): string | undefined =>
85 pr.failing.length > 0 ? 'red' : pr.pending > 0 ? 'yellow' : pr.passing > 0 ? 'green' : undefined
86types/index.d.ts 30 lines1export type Review = 'approved' | 'changes requested' | 'review required' | 'no review'
2
3export type Pr = {
4 number: number
5 title: string
6 url: string
7 head: string
8 isDraft: boolean
9 review: Review
10 passing: number
11 pending: number
12 // Names of the failing checks.
13 failing: string[]
14 // How many open PRs this one is stacked on.
15 depth: number
16}
17
18export type Board = {
19 prs: Pr[]
20 branch: string
21 fetchedAt: number
22 error?: string
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'pr-stack': { board: Board | null }
28 }
29}
30