SLOPSHOPPER

backlog

/backlog: this repo's open GitHub issues, a board on desktop and a list in the terminal, by surface then label; each links to GitHub, ↻ refreshes, "work on it"…

newrowscommandprocess
v0.2.1MITupdated 2026-10-23bencsmartucph/ben-mods/backlog
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · backlog
› fix the failing auth test and add an audit log call ⏺ 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 › /backlog ⎿ backlog: couldn't reach GitHub (gh repo view failed) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

backlog

/backlog [label] shows the current repo's open GitHub issues as a working board, so you can pick the next thing to do without leaving Claude Code.

/backlog in the terminal: five open issues of a demo repo, in surface columns then by label, each with its age and a link

  • On the desktop app it draws a board of cards; in the terminal, a list.
  • Issues labelled desktop or terminal get columns of their own; the rest are grouped by their first label, unlabelled last. /backlog bug shows only issues labelled bug.
  • Each issue links to GitHub. Press one to read its first lines, then work on it to put "Work on owner/repo#12: title" in the prompt box.
  • ↻ fetches the list again in place. Nothing refreshes on its own.
  • Claude gets a short text version too (one line per issue, no bodies), so you can say "take the oldest bug".

Needs the GitHub CLI (gh), signed in. Any repo gh can see works, private ones included. Outside a GitHub repo, or when GitHub doesn't answer, it says which in one line.

Status: the terminal list has been run live; the desktop board has passed its tests but I haven't yet seen it live.

Install

claude plugin marketplace add bencsmartucph/ben-mods
claude plugin install backlog@ben-mods
Source 2 files
hooks/register.tsx 298 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BacklogGroup, BacklogIssue, BacklogListing } from '../types'
5
6// Listings by filter, so each /backlog row draws the issues it listed.
7const lists = atom({ plugin: 'backlog', key: 'lists' } as const, {} as Record<string, BacklogListing>)
8// Expanded issues, and issues whose prompt fill the surface refused, as "owner/repo#n".
9const open = atom({ plugin: 'backlog', key: 'open' } as const, [] as string[])
10const unfilled = atom({ plugin: 'backlog', key: 'unfilled' } as const, [] as string[])
11// Per filter: what the last ↻ press said when it failed, or "…" while it runs.
12const refreshing = atom({ plugin: 'backlog', key: 'refreshing' } as const, {} as Record<string, string>)
13
14const UNLABELLED = 'no label'
15const FALLBACK_COLUMNS = 100
16const EXCERPT_LINES = 6
17const CHIP = 'magenta'
18// Surface labels get their own columns first; any other label groups the rest.
19const SURFACES = ['desktop + terminal', 'desktop', 'terminal'] as const
20const MAX_PER_ROW = 3
21
22type GhIssue = {
23  number: number
24  title: string
25  labels?: { name: string }[]
26  body?: string
27  createdAt?: string
28  comments?: unknown[]
29}
30
31/** The state key for a run's arguments: the label filter, case folded. */
32export const keyOf = (args: string) => args.trim().toLowerCase()
33
34/** The first lines of an issue body, leading blank lines dropped, "…" when cut. */
35export function excerpt(body: string, lines = EXCERPT_LINES): string {
36  const all = body.replace(/\r/g, '').replace(/^\s*\n/, '').trimEnd().split('\n')
37  const head = all.slice(0, lines)
38  return all.length > lines ? [...head, '…'].join('\n') : head.join('\n')
39}
40
41/** Compact age: 5m, 3h, 4d, 2mo, 1y. */
42export function age(ms: number): string {
43  const m = Math.max(0, Math.floor(ms / 60_000))
44  if (m < 60) return `${m}m`
45  const h = Math.floor(m / 60)
46  if (h < 24) return `${h}h`
47  const d = Math.floor(h / 24)
48  if (d < 60) return `${d}d`
49  return d < 365 ? `${Math.floor(d / 30)}mo` : `${Math.floor(d / 365)}y`
50}
51
52/** gh's issue as kept in state. */
53export function toIssue(i: GhIssue): BacklogIssue {
54  const t = i.createdAt ? Date.parse(i.createdAt) : NaN
55  return {
56    number: i.number,
57    title: i.title,
58    labels: (i.labels ?? []).map(l => l.name),
59    createdAt: Number.isFinite(t) ? t : 0,
60    comments: Array.isArray(i.comments) ? i.comments.length : 0,
61    excerpt: excerpt(i.body ?? ''),
62  }
63}
64
65/** Which column an issue sits in when no filter is given: its surface, else its first other label. */
66export function columnOf(i: BacklogIssue): string {
67  const names = i.labels.map(n => n.toLowerCase())
68  const d = names.includes('desktop'), t = names.includes('terminal')
69  if (d && t) return SURFACES[0]
70  if (d) return SURFACES[1]
71  if (t) return SURFACES[2]
72  return i.labels[0] ?? UNLABELLED
73}
74
75/** Groups issues: the filter's label, or surface columns first, then other labels, unlabelled last. */
76export function group(issues: BacklogIssue[], filter: string): BacklogGroup[] {
77  const f = filter.trim().toLowerCase()
78  const groups = new Map<string, BacklogGroup>()
79  for (const i of issues) {
80    const hit = f ? i.labels.find(n => n.toLowerCase() === f) : columnOf(i)
81    if (hit === undefined) continue
82    const g = groups.get(hit) ?? { label: hit, issues: [] }
83    g.issues.push(i)
84    groups.set(hit, g)
85  }
86  const rank = (l: string) => {
87    const s = (SURFACES as readonly string[]).indexOf(l)
88    return s >= 0 ? s : l === UNLABELLED ? 99 : 10
89  }
90  const out = [...groups.values()].sort((a, b) => rank(a.label) - rank(b.label) || a.label.localeCompare(b.label))
91  for (const g of out) g.issues.sort((a, b) => a.number - b.number)
92  return out
93}
94
95/** Age and comment count, as the row and the model text both show them. */
96export function meta(i: BacklogIssue, now: number): string {
97  const parts = i.createdAt ? [age(now - i.createdAt)] : []
98  if (i.comments > 0) parts.push(`${i.comments} comment${i.comments === 1 ? '' : 's'}`)
99  return parts.join(' · ')
100}
101
102/** The short text the model reads: a header, then a label line and one line per issue, no bodies. */
103export function asText(listing: BacklogListing, filter: string, now: number): string {
104  const n = listing.groups.reduce((s, g) => s + g.issues.length, 0)
105  const head = `${listing.repo}: ${n} open issue${n === 1 ? '' : 's'}${filter.trim() ? ` labelled ${filter.trim()}` : ''}`
106  const body = listing.groups.flatMap(g => [`${g.label}:`, ...g.issues.map(i => {
107    const m = meta(i, now)
108    return `  #${i.number} ${i.title}${m ? ` (${m})` : ''}`
109  })])
110  return [head, ...body].join('\n')
111}
112
113export const prefill = (repo: string, n: number, title: string) => `Work on ${repo}#${n}: ${title}`
114export const issueUrl = (repo: string, n: number) => `https://github.com/${repo}/issues/${n}`
115
116const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
117const firstLine = (s: string) => s.trim().split(/\r?\n/)[0] ?? ''
118const toggle = (ids: string[] | undefined, id: string) =>
119  (ids ?? []).includes(id) ? (ids ?? []).filter(x => x !== id) : [...(ids ?? []), id]
120
121// gh's words for "this folder has no GitHub repo", as opposed to GitHub not answering.
122const NO_REPO = /not a git repository|no git remotes|none of the git remotes|no default remote|could not determine/i
123
124/** Why `gh repo view` failed, in one line: no repo here, or GitHub (network, sign-in, rate limit). */
125export function whyNoRepo(stderr: string): string {
126  const why = clip(firstLine(stderr) || 'gh repo view failed', 120)
127  return NO_REPO.test(stderr) ? `no GitHub repo here (${why})` : `couldn't reach GitHub (${why})`
128}
129
130/** Runs gh for this folder's repo, or `repo` when known: the listing, or one line saying why not. */
131async function fetchListing($: EngineInterface, filter: string, known?: string): Promise<BacklogListing | string> {
132  let repo = known ?? ''
133  if (!repo) {
134    const view = await $.process.run(['gh', 'repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], { timeoutMs: 20_000 })
135      .catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: String(err) }))
136    repo = view.stdout.trim()
137    if (view.exitCode !== 0 || !repo) return whyNoRepo(view.stderr)
138  }
139  const list = await $.process.run(
140    ['gh', 'issue', 'list', '-R', repo, '--state', 'open', '--limit', '300', '--json', 'number,title,labels,body,createdAt,comments'],
141    { timeoutMs: 30_000 },
142  ).catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: String(err) }))
143  if (list.exitCode !== 0) {
144    return `gh issue list failed for ${repo} (${clip(firstLine(list.stderr) || `exit ${list.exitCode}`, 120)})`
145  }
146  let issues: GhIssue[]
147  try {
148    issues = JSON.parse(list.stdout) as GhIssue[]
149  } catch {
150    return `could not read gh's issue list for ${repo}`
151  }
152  return { repo, groups: group(issues.map(toIssue), filter), fetchedAt: await $.clock.now() }
153}
154
155export const register: Register = on => {
156  on('session.start', async ($, e, next) => {
157    await $.command.register({ name: 'backlog', description: 'This repo’s open GitHub issues as a board; press one to read it, ↻ to refresh. Optional arg: a label.' })
158    return next(e)
159  })
160
161  on('command.run', { command: 'backlog' }, async ($, e) => {
162    const filter = (e.args ?? '').trim()
163    const listing = await fetchListing($, filter)
164    if (typeof listing === 'string') return { text: listing }
165    const now = listing.fetchedAt ?? 0
166    if (listing.groups.length === 0) {
167      return { text: `no open issues in ${listing.repo}${filter ? ` labelled ${filter}` : ''}` }
168    }
169    await update($, lists, m => ({ ...(m ?? {}), [keyOf(filter)]: listing }))
170    return { text: asText(listing, filter, now) }
171  })
172
173  on('ui.render', { component: 'CommandOutput', props: { command: 'backlog' } }, async ($, e, next) => {
174    if (e.props.isErrored) return next(e)
175    const filter = e.props.args ?? ''
176    const key = keyOf(filter)
177    const listing = (await read($, lists))[key]
178    // Error lines, empty results and a resumed transcript (state gone) draw as plain text.
179    if (!listing || !e.props.text.includes(`${listing.repo}: `)) return next(e)
180    const opened = await read($, open)
181    const refused = await read($, unfilled)
182    const status = (await read($, refreshing))[key]
183    const now = await $.clock.now()
184    const { Box, Button, Text, Markdown, Link } = $.ui.resolve(e)
185    const width = Math.max(30, (e.viewport?.columns ?? FALLBACK_COLUMNS) - 8)
186    const { repo } = listing
187    const total = listing.groups.reduce((s, g) => s + g.issues.length, 0)
188    const fetched = listing.fetchedAt ? ` · fetched ${age(now - listing.fetchedAt)} ago` : ''
189    const isDesktop = e.surface !== 'terminal'
190
191    const refresh = async () => {
192      await update($, refreshing, m => ({ ...(m ?? {}), [key]: '…' }))
193      // The repo is known from the first run, so a refresh only asks GitHub for the issues.
194      const fresh = await fetchListing($, filter, repo).catch((err: unknown) => clip(String(err), 120))
195      if (typeof fresh === 'string') {
196        await update($, refreshing, m => ({ ...(m ?? {}), [key]: `↻ failed, showing the last list: ${fresh}` }))
197        return
198      }
199      await update($, lists, m => ({ ...(m ?? {}), [key]: fresh }))
200      await update($, refreshing, m => { const { [key]: _gone, ...rest } = m ?? {}; return rest })
201    }
202
203    const header = (
204      <Box key="hdr" flexDirection="row" gap={1} flexWrap="wrap">
205        <Text dimColor>{`${repo} · ${total} open${filter.trim() ? ` · ${filter.trim()}` : ''}${fetched}`}</Text>
206        <Button key="refresh" plain label={status === '…' ? '↻ refreshing…' : '↻ refresh'} onPress={() => void refresh()} />
207        <Link href={`https://github.com/${repo}/issues`} label="GitHub ↗" />
208        {status && status !== '…' ? <Text key="rerr" color="red">{status}</Text> : null}
209      </Box>
210    )
211
212    // The opened part of a row or card: description, then the two ways onward.
213    const detail = (i: BacklogIssue, id: string, fill: string) => (
214      <Box key={`x-${i.number}`} flexDirection="column" marginLeft={isDesktop ? 0 : 4} gap={isDesktop ? 1 : 0}>
215        <Markdown key={`b-${i.number}`} dimColor text={i.excerpt || '_(no description)_'} />
216        <Box flexDirection="row" gap={1} flexWrap="wrap">
217          <Button key={`w-${i.number}`} variant="secondary" label="work on it" onPress={() => void workOn($, id, fill)} />
218          <Link href={issueUrl(repo, i.number)} label="open on GitHub ↗" />
219        </Box>
220        {refused.includes(id) ? <Text key={`f-${i.number}`}>{fill}</Text> : null}
221      </Box>
222    )
223
224    if (isDesktop) {
225      const perRow = Math.min(listing.groups.length, MAX_PER_ROW)
226      const colWidth = `${Math.floor(100 / perRow) - 2}%`
227      return (
228        <Box flexDirection="column" gap={1}>
229          {header}
230          <Box key="board" flexDirection="row" flexWrap="wrap" columnGap={2} rowGap={1} alignItems="flex-start">
231            {listing.groups.map(g => (
232              <Box key={`g-${g.label}`} width={colWidth} flexDirection="column" gap={1} borderStyle="round" borderDimColor paddingX={1} paddingY={1}>
233                <Text bold>{`${g.label} · ${g.issues.length}`}</Text>
234                {g.issues.map(i => {
235                  const id = `${repo}#${i.number}`
236                  const isOpen = opened.includes(id)
237                  const m = meta(i, now)
238                  const others = i.labels.filter(l => l.toLowerCase() !== g.label && !(['desktop', 'terminal'].includes(l.toLowerCase()) && (SURFACES as readonly string[]).includes(g.label)))
239                  return (
240                    <Box key={`r-${i.number}`} flexDirection="column" borderStyle="single" borderDimColor paddingX={1}>
241                      <Box flexDirection="row" gap={1}>
242                        <Link href={issueUrl(repo, i.number)} label={`#${i.number}`} />
243                        <Button key={`i-${i.number}`} plain label={`${isOpen ? '▾' : '▸'} ${i.title}`}
244                          onPress={() => update($, open, ids => toggle(ids, id))} />
245                      </Box>
246                      <Box flexDirection="row" gap={1} flexWrap="wrap">
247                        {others.map(l => <Text key={`c-${i.number}-${l}`} color={CHIP}>{`[${l}]`}</Text>)}
248                        {m ? <Text dimColor>{m}</Text> : null}
249                      </Box>
250                      {isOpen ? detail(i, id, prefill(repo, i.number, i.title)) : null}
251                    </Box>
252                  )
253                })}
254              </Box>
255            ))}
256          </Box>
257        </Box>
258      )
259    }
260
261    return (
262      <Box flexDirection="column">
263        {header}
264        {listing.groups.map(g => (
265          <Box key={`g-${g.label}`} flexDirection="column">
266            <Text bold>{g.label}</Text>
267            {g.issues.map(i => {
268              const id = `${repo}#${i.number}`
269              const isOpen = opened.includes(id)
270              const m = meta(i, now)
271              return (
272                <Box key={`r-${i.number}`} flexDirection="column">
273                  <Box flexDirection="row" gap={1}>
274                    <Button key={`i-${i.number}`} plain
275                      label={clip(`${isOpen ? '▾' : '▸'} #${i.number} ${i.title}`, Math.max(20, width - 28))}
276                      onPress={() => update($, open, ids => toggle(ids, id))} />
277                    {i.labels.map(l => <Text key={`c-${i.number}-${l}`} color={CHIP}>{`[${l}]`}</Text>)}
278                    {m ? <Text dimColor>{m}</Text> : null}
279                    <Link href={issueUrl(repo, i.number)} label="↗" />
280                  </Box>
281                  {isOpen ? detail(i, id, prefill(repo, i.number, i.title)) : null}
282                </Box>
283              )
284            })}
285          </Box>
286        ))}
287      </Box>
288    )
289  })
290}
291
292/** Fills the prompt; where the surface refuses, marks the row so it shows the text to copy. */
293async function workOn($: EngineInterface, id: string, text: string): Promise<void> {
294  const r = await $.prompt.fill({ text, mode: 'replace' }).catch(() => ({ isFilled: false }))
295  const isFilled = r.isFilled === true
296  await update($, unfilled, ids => isFilled ? (ids ?? []).filter(x => x !== id) : (ids ?? []).includes(id) ? (ids ?? []) : [...(ids ?? []), id])
297}
298
types/index.d.ts 28 lines
1export type BacklogIssue = {
2  number: number
3  title: string
4  labels: string[]
5  /** When it was opened, epoch ms. */
6  createdAt: number
7  comments: number
8  /** The first lines of the body, markdown. */
9  excerpt: string
10}
11export type BacklogGroup = { label: string; issues: BacklogIssue[] }
12export type BacklogListing = { repo: string; groups: BacklogGroup[]; /** When gh answered, epoch ms. */ fetchedAt?: number }
13
14declare module 'claude-code' {
15  interface PluginState {
16    backlog: {
17      // One listing per /backlog filter (lowercased args), so each row draws its own.
18      lists: Record<string, BacklogListing>
19      // Expanded rows, as "owner/repo#n".
20      open: string[]
21      // Rows whose prompt fill the surface refused, so the text is shown to copy.
22      unfilled: string[]
23      // Per filter: "…" while ↻ runs, or the line saying why it failed.
24      refreshing: Record<string, string>
25    }
26  }
27}
28