SLOPSHOPPER

backlog

A brief view of the repo's BACKLOG.md: what's next, and how many items are to-do, new, parked or blocked

newpaneguardcommandstatusprompt
v0.2.0no licenseupdated 2026-10-08liveweird/claude-mods/backlog
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · backlog
│ ┃ Backlog ✕ › fix the failing auth test and add an audit log call │ ┃ No BACKLOG.md in the working directory. │ ⏺ 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: Backlog pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Backlog
No BACKLOG.md in the working directory.
README

backlog

A Claude Code mod that summarises the repository's BACKLOG.md: what's next, and how many items are to-do, new (proposed, not yet confirmed), parked or blocked.

  • Status line: backlog · next: Real-Jira first sync · 9 to-do · 2 new · 3 parked/blocked
  • /backlog opens a pane: each status with its items' titles and the ## section they sit under, plus an untagged count.
  • backlog: a read-only tool, so Claude can answer "what's next?" without reading the file.

It refreshes at session start, on every prompt (so a git pull shows), after an Edit or Write to BACKLOG.md, on a working-directory change and on /backlog. A repo without BACKLOG.md, or with no tagged items, shows nothing.

It always reads the main checkout's BACKLOG.md (via git rev-parse --git-common-dir), so a session working in a linked worktree, such as .claude/worktrees/<name>, still shows the trunk's backlog, not the worktree's copy. Outside a git repo it reads the working directory's file.

The convention

Tag each top-level item under a ## section at its start; the topic sections stay as they are:

## Engineering follow-ups

- [todo] **Data profile: multi-project boards.** A board whose filter spans several projects …
- [parked] **D6 — de-Jira the Connector seam.** Not before the GitLab connector.
- [new] **Cache the profile query.** …
  • Tags: [next], [todo], [new], [parked], [blocked] (case-insensitive).
  • The title is the item's first bold run, else its first sentence.
  • Nested bullets belong to their parent; bullets before the first ## and inside code fences are not items.
  • An untagged top-level bullet is counted as untagged, so a forgotten tag shows up in the pane.

[new] is for items Claude proposes; the person promotes them to [todo] or [next].

Install

/plugin install backlog --marketplace liveweird/claude-mods

Answer y, pick the user scope. Develop with claude plugin validate . and claude plugin test . in this folder, then /reload-plugins.

Source 3 files
hooks/register.tsx 106 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { EMPTY, LABELS, STATUSES, parseBacklog, reportText, statusText, summarize } from './parse'
5
6const PANE = 'backlog'
7const FILE = 'BACKLOG.md'
8
9const backlog = atom({ plugin: 'backlog', key: 'backlog' } as const, EMPTY)
10
11export const register: Register = on => {
12  on('session.start', async ($, e, next) => {
13    await $.command.register({ name: 'backlog', description: "The backlog at a glance: what's next, to-do, new, parked, blocked" })
14    await $.tool.register({
15      name: 'backlog',
16      description:
17        "Read-only: the working directory's BACKLOG.md by status tag ([next], [todo], [new], [parked], [blocked]), each item's title and section, and the untagged count.",
18      inputSchema: { type: 'object', properties: {} },
19    })
20    await refresh($)
21
22    return next(e)
23  })
24
25  on('tool.call', { tool: 'mcp__backlog__backlog' }, async $ => ({ result: reportText(summarize(await refresh($).catch(() => EMPTY))) }))
26
27  // A git pull or an edit made outside Claude shows on the next prompt; one small file read.
28  on('prompt.submit', async ($, e, next) => {
29    await refresh($).catch(() => undefined)
30    return next(e)
31  })
32
33  on('classic.CwdChanged', async ($, e, next) => {
34    const ran = await next(e)
35    await refresh($).catch(() => undefined)
36    return ran
37  })
38
39  for (const tool of ['Edit', 'Write'] as const) {
40    on('tool.call', { tool }, async ($, e, next) => {
41      const ran = await next(e)
42      if (isBacklogFile(e.file_path)) await refresh($).catch(() => undefined)
43      return ran
44    })
45  }
46
47  on('command.run', { command: 'backlog' }, async $ => {
48    await refresh($)
49    await $.ui.open({ id: PANE, title: 'Backlog' })
50    return { text: 'Backlog pane opened.' }
51  })
52
53  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
54    const { Box, Text } = $.ui.resolve(e)
55    const sum = summarize(await read($, backlog))
56    if (!sum.found || sum.tagged === 0) return <Text dimColor>{reportText(sum)}</Text>
57
58    return (
59      <Box flexDirection="column">
60        {STATUSES.filter(s => sum.byStatus[s].length > 0).map(status => {
61          const items = sum.byStatus[status]
62          const held = status === 'parked' || status === 'blocked'
63          return (
64            <Box flexDirection="column" marginBottom={1}>
65              <Text bold color={status === 'next' ? 'success' : held ? 'warning' : undefined}>
66                {LABELS[status]} {items.length}
67              </Text>
68              {items.map(i => (
69                <Text wrap="truncate-end" dimColor={status !== 'next'}>
70                  {'  '}
71                  {i.title}
72                  <Text dimColor> · {i.section}</Text>
73                </Text>
74              ))}
75            </Box>
76          )
77        })}
78        {sum.untagged > 0 ? <Text color="warning">Untagged: {sum.untagged}</Text> : null}
79      </Box>
80    )
81  })
82}
83
84function isBacklogFile(path: unknown): boolean {
85  return typeof path === 'string' && /(^|[/\\])BACKLOG\.md$/.test(path)
86}
87
88/**
89 * The main checkout's BACKLOG.md. git's common dir is the main checkout's `.git` from a linked worktree too, so a
90 * session working in `.claude/worktrees/<name>` still reads the main file, not the worktree's (older) copy. Outside a
91 * git repo, or in a bare/submodule layout, it falls back to the working directory's file.
92 */
93async function backlogPath($: EngineInterface): Promise<string> {
94  const git = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir']).catch(() => undefined)
95  const common = git?.exitCode === 0 ? git.stdout.trim() : ''
96  return /[\\/]\.git$/.test(common) ? `${common.slice(0, -'.git'.length)}${FILE}` : FILE
97}
98
99async function refresh($: EngineInterface) {
100  const text = await $.fs.read(await backlogPath($)).catch(() => undefined)
101  const parsed = text === undefined ? EMPTY : parseBacklog(text)
102  await update($, backlog, () => parsed)
103  $.ui.status(statusText(summarize(parsed)))
104  return parsed
105}
106
hooks/parse.ts 112 lines
1import type { Backlog, Item, Status } from '../types'
2
3export const STATUSES: readonly Status[] = ['next', 'todo', 'new', 'parked', 'blocked']
4
5export const EMPTY: Backlog = { found: false, items: [], untagged: 0 }
6
7const BULLET = /^[-*+]\s+(.*)$/
8const TAG = /^\[(next|todo|new|parked|blocked)\]\s*(.*)$/i
9const SECTION = /^##\s+(.*?)\s*#*\s*$/
10/** A heading's trailing parenthetical, `(needs the user)`: dropped from the section name. */
11const ASIDE = /\s*\([^)]*\)\s*$/
12const FENCE = /^\s*(```|~~~)/
13const BOLD = /\*\*(.+?)\*\*/
14const TITLE_MAX = 60
15
16/**
17 * The tagged items of a backlog file. An item is a top-level bullet under a `##` section, tagged at its start
18 * (`- [todo] **Title.** …`); nested bullets belong to their parent, the preamble before the first `##` and fenced
19 * code are not items, and an untagged top-level bullet is only counted.
20 */
21export function parseBacklog(md: string): Backlog {
22  const items: Item[] = []
23  let untagged = 0
24  let section: string | undefined
25  let fenced = false
26  for (const line of md.split(/\r?\n/)) {
27    if (FENCE.test(line)) fenced = !fenced
28    if (fenced) continue
29    const heading = SECTION.exec(line)
30    if (heading) {
31      section = (heading[1] ?? '').replace(ASIDE, '')
32      continue
33    }
34    if (section === undefined) continue
35    const bullet = BULLET.exec(line)
36    if (!bullet) continue
37    const tag = TAG.exec(bullet[1] ?? '')
38    if (!tag) {
39      untagged += 1
40      continue
41    }
42    items.push({ status: (tag[1] ?? '').toLowerCase() as Status, title: titleOf(tag[2] ?? ''), section })
43  }
44  return { found: true, items, untagged }
45}
46
47/** The first bold run, else the text up to its first sentence end, at most TITLE_MAX characters. */
48export function titleOf(text: string): string {
49  const bold = BOLD.exec(text)?.[1]
50  const raw = (bold ?? text.split(/(?<=[.:;])\s/)[0] ?? text).trim().replace(/[.:;,]+$/, '')
51  return raw.length > TITLE_MAX ? `${raw.slice(0, TITLE_MAX - 1)}…` : raw
52}
53
54export type Summary = {
55  found: boolean
56  next: Item[]
57  byStatus: Record<Status, Item[]>
58  untagged: number
59  tagged: number
60}
61
62export function summarize(backlog: Backlog): Summary {
63  const byStatus = Object.fromEntries(STATUSES.map(s => [s, backlog.items.filter(i => i.status === s)])) as Record<Status, Item[]>
64  return { found: backlog.found, next: byStatus.next, byStatus, untagged: backlog.untagged, tagged: backlog.items.length }
65}
66
67/** The non-zero counts, parked and blocked together: `9 to-do · 2 new · 3 parked/blocked`. */
68function counts(sum: Summary): string[] {
69  const held = sum.byStatus.parked.length + sum.byStatus.blocked.length
70  return [
71    sum.byStatus.todo.length ? `${sum.byStatus.todo.length} to-do` : '',
72    sum.byStatus.new.length ? `${sum.byStatus.new.length} new` : '',
73    held ? `${held} parked/blocked` : '',
74  ].filter(Boolean)
75}
76
77const MIN_TITLE = 12
78
79/** The status-line entry; undefined without a tagged backlog, so other repos stay quiet. */
80export function statusText(sum: Summary, maxLen = 80): string | undefined {
81  if (!sum.found || sum.tagged === 0) return undefined
82  const tail = counts(sum).map(c => ` · ${c}`).join('')
83  const first = sum.next[0]
84  if (first === undefined) return `backlog${tail}`.slice(0, maxLen)
85  const more = sum.next.length > 1 ? ` +${sum.next.length - 1}` : ''
86  const room = maxLen - `backlog · next: ${more}${tail}`.length
87  if (room < MIN_TITLE) return `backlog · next: ${clip(first.title, maxLen - 16)}`
88  return `backlog · next: ${clip(first.title, room)}${more}${tail}`
89}
90
91function clip(text: string, max: number): string {
92  return text.length > max ? `${text.slice(0, Math.max(max - 1, 0))}…` : text
93}
94
95export const LABELS: Record<Status, string> = { next: 'Next', todo: 'To-do', new: 'New', parked: 'Parked', blocked: 'Blocked' }
96
97/** The summary as plain text: the read-only tool's answer. */
98export function reportText(sum: Summary): string {
99  if (!sum.found) return 'No BACKLOG.md in the working directory.'
100  if (sum.tagged === 0) {
101    return `BACKLOG.md has no tagged items (${sum.untagged} untagged). Tag top-level items with [next], [todo], [new], [parked] or [blocked].`
102  }
103  const lines: string[] = []
104  for (const status of STATUSES) {
105    const items = sum.byStatus[status]
106    if (items.length === 0) continue
107    lines.push(`${LABELS[status]} (${items.length}):`, ...items.map(i => `  - ${i.title} (${i.section})`))
108  }
109  if (sum.untagged > 0) lines.push(`Untagged: ${sum.untagged}`)
110  return lines.join('\n')
111}
112
types/index.d.ts 27 lines
1export type Status = 'next' | 'todo' | 'new' | 'parked' | 'blocked'
2
3export type Item = {
4  status: Status
5  /** The item's first bold run, else its first sentence, clipped. */
6  title: string
7  /** The `##` section it sits under. */
8  section: string
9}
10
11export type Backlog = {
12  /** Whether BACKLOG.md was read at all. */
13  found: boolean
14  items: Item[]
15  /** Top-level bullets under a section that carry no tag. */
16  untagged: number
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    backlog: {
22      /** BACKLOG.md as last read from the working directory. */
23      backlog: Backlog
24    }
25  }
26}
27