SLOPSHOPPER

pr-stack

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

newpaneguardcommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-stack
│ ┃ PRs ✕ › fix the failing auth test and add an audit log call │ ┃ 0 open · 08:53 r: refresh │ ┃ SyntaxError: JSON Parse error: Unexpected E… ⏺ 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 │ │ › /prs │ ⎿ pr-stack: PR stack pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · PRs
0 open · 08:53 r: refresh SyntaxError: JSON Parse error: Unexpected EOF
README

frontierism

Building the new Frontier.

Skills

skills/ holds my agent skills for Claude Code and Codex.

SkillWhat 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-rebaseFor my own PRs. They chain, and share pr-common.md.
pr-peer-reviewFor 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-learningSocratic tutoring and debate practice.
xurlRead-only X research: in-app browser by default, xurl for explicit API requests.

Install

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

Adding one

  • Skill: create 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.
  • Mod: put the plugin folder (.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.
Source 3 files
hooks/register.tsx 139 lines
1import { 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}
139
hooks/prs.ts 86 lines
1import 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
86
types/index.d.ts 30 lines
1export 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