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

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.
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.
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.** …
[next], [todo], [new], [parked], [blocked] (case-insensitive).bold run, else its first sentence.## and inside code fences are not items.[new] is for items Claude proposes; the person promotes them to [todo] or [next].
/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.
hooks/register.tsx 106 lines1import { 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}
106hooks/parse.ts 112 lines1import 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}
112types/index.d.ts 27 lines1export 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