SLOPSHOPPER

backlog-board

Sidebar pane showing virtual-team backlog progress (backlog.md, features/, handoffs/)

newpanecommandtoasttimer
v0.2.0no licenseupdated 2026-10-09The-CMO-Lab/development/plugins/backlog-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · backlog-board
│ ┃ Backlog ✕ › fix the failing auth test and add an audit log call │ ┃ [ Overview ] [ Features ] [ Refresh ] │ ┃ No backlog.md found. Run /board <docs ⏺ Read(src/auth.ts) │ ┃ folder> to point at one. ⎿ 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 │ │ › /board │ ⎿ backlog-board: No backlog.md found. Run /board <docs folder> to │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Backlog
[ Overview ] [ Features ] [ Refresh ] No backlog.md found. Run /board <docs folder> to point at one.
README

development

Claude Code mods for development workflows, published as a plugin marketplace.

backlog-board

A sidebar pane in Claude Code that shows the progress of a project managed with the virtual-team skills, so you don't have to switch between a browser board and the terminal.

It reads the project's docs folder as it is: backlog.md, features/, decisions/ and handoffs/. Nothing is copied or written back.

  • Overview (o): percent done, counts by status (doing, verify, blocked, ready, parked), priority directives, the in-progress / verify / blocked lists, the next ready items and the latest handoff.
  • Features (f): each open FEAT- with a done/total bar and its active stories.
  • Refresh (r). It also refreshes on its own every 10 s, after every Claude turn, and when features/, decisions/ or handoffs/ change.

Statuses follow the backlog's markers ([ ], [>], [=], [~], [x]) and status: fields, the same way virtual-team's backlog-board.html reads them.

Install

In a Claude Code terminal session:

/plugin install backlog-board --marketplace The-CMO-Lab/development

Answer y to add the marketplace, then choose a scope (user scope loads it in every session).

Use

CommandWhat it does
/boardOpen the pane
/board <docs folder>Point it at a project's docs folder (remembered per working directory)
/board resetForget that folder
/board closeClose the pane

With no folder given, it looks for ./docs/backlog.md, then ./backlog.md, and opens the pane by itself when it finds one. In fullscreen mode the pane docks beside the transcript from 110 columns; narrower, it shows above the prompt.

Options, under /config: Docs folder (default: auto-detect) and Refresh interval (seconds, default 10).

Develop

claude --plugin-dir plugins/backlog-board   # run from this folder
claude plugin validate plugins/backlog-board
claude plugin test plugins/backlog-board
Source 3 files
hooks/register.tsx 358 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BoardDetail, BoardItem, BoardSnapshot, BoardView } from '../types'
5import { docTitle, summarize } from './parse'
6
7const PANE = 'backlog-board'
8const TITLE = 'Backlog'
9const WATCH_DIRS = ['features', 'decisions', 'handoffs']
10
11const snapshot = atom({ plugin: 'backlog-board', key: 'snapshot' } as const, null)
12const error = atom({ plugin: 'backlog-board', key: 'error' } as const, null)
13const view = atom({ plugin: 'backlog-board', key: 'view' } as const, 'overview')
14const selected = atom({ plugin: 'backlog-board', key: 'selected' } as const, null)
15const expanded = atom({ plugin: 'backlog-board', key: 'expanded' } as const, [])
16
17const TYPE_COLOR: Record<BoardItem['type'], string> = { FEAT: 'claude', BUG: 'error', S: 'suggestion', Task: 'subtle' }
18
19const COL_LABEL: Record<BoardItem['col'], string> = {
20  done: 'Done', todo: 'Ready', prog: 'In progress', blocked: 'Blocked', verify: 'Waiting for verification', parked: 'Parked',
21}
22const COL_COLOR: Record<BoardItem['col'], string> = {
23  done: 'success', todo: 'text', prog: 'warning', blocked: 'error', verify: 'suggestion', parked: 'subtle',
24}
25
26/** Cuts a title to one row: Button labels don't truncate on their own. */
27const clip = (text: string, room: number) => (text.length <= Math.max(8, room) ? text : `${text.slice(0, Math.max(8, room) - 1)}…`)
28const join = (dir: string, name: string) => (dir === '.' || dir === '' ? name : `${dir.replace(/\/$/, '')}/${name}`)
29const hhmm = (ms: number) => new Date(ms).toTimeString().slice(0, 5)
30const bar = (part: number, whole: number, width: number) => {
31  const w = Math.max(4, width)
32  const n = whole > 0 ? Math.round((part / whole) * w) : 0
33  return '█'.repeat(n) + '░'.repeat(w - n)
34}
35
36// Module variables reset on hot reload; that only costs one extra re-read.
37let configured = ''
38let lastSig = ''
39let busy = false
40
41async function storeKey($: EngineInterface) { return `docsDir:${await $.session.cwd()}` }
42
43/** The folder holding backlog.md: /board override, then the option, then ./docs, then the working directory. */
44async function docsDir($: EngineInterface): Promise<string | undefined> {
45  const override = (await $.store.get(await storeKey($))) as string | undefined
46  for (const dir of [override, configured, 'docs', '.']) {
47    if (dir && (await $.fs.exists(join(dir, 'backlog.md')))) return dir
48  }
49  return undefined
50}
51
52async function signature($: EngineInterface, dir: string): Promise<string> {
53  const parts = [String((await $.fs.stat(join(dir, 'backlog.md'))).mtimeMs)]
54  for (const sub of WATCH_DIRS) {
55    const entries = await $.fs.list(join(dir, sub)).catch(() => [])
56    parts.push(`${entries.length}:${Math.max(0, ...entries.map(x => x.mtimeMs))}`)
57  }
58  return parts.join('|')
59}
60
61async function featureTitles($: EngineInterface, dir: string): Promise<Record<string, string>> {
62  const titles: Record<string, string> = {}
63  const entries = await $.fs.list(join(dir, 'features')).catch(() => [])
64  for (const f of entries) {
65    if (f.kind !== 'file' || !f.name.endsWith('.md')) continue
66    const t = docTitle(await $.fs.read(join(dir, `features/${f.name}`)).catch(() => ''))
67    if (t.id && t.title) titles[t.id] = t.title
68  }
69  return titles
70}
71
72async function latestHandoff($: EngineInterface, dir: string): Promise<BoardSnapshot['handoff']> {
73  const entries = await $.fs.list(join(dir, 'handoffs')).catch(() => [])
74  const name = entries.filter(f => f.kind === 'file' && f.name.endsWith('.md')).map(f => f.name).sort().pop()
75  if (!name) return undefined
76  const title = docTitle(await $.fs.read(join(dir, `handoffs/${name}`)).catch(() => '')).title
77  return { date: name.slice(0, 10), title: title ?? name.replace(/\.md$/, '') }
78}
79
80async function refresh($: EngineInterface, force = false): Promise<boolean> {
81  if (busy) return false
82  busy = true
83  try {
84    const dir = await docsDir($)
85    if (!dir) {
86      lastSig = ''
87      await update($, snapshot, () => null)
88      await update($, error, () => 'No backlog.md found. Run /board <docs folder> to point at one.')
89      return false
90    }
91    const sig = `${dir}#${await signature($, dir)}`
92    if (!force && sig === lastSig) return true
93    const isFirst = lastSig === ''
94    const md = await $.fs.read(join(dir, 'backlog.md'))
95    const next = summarize(md, {
96      docsDir: dir,
97      loadedAt: await $.clock.now(),
98      featureTitles: await featureTitles($, dir),
99      handoff: await latestHandoff($, dir),
100    })
101    lastSig = sig
102    await update($, snapshot, () => next)
103    await update($, error, () => null)
104    if (!isFirst && !force) $.ui.toast('Backlog changed — board updated')
105    return true
106  } catch (err) {
107    await update($, error, () => `Could not read the backlog: ${err instanceof Error ? err.message : String(err)}`)
108    return false
109  } finally {
110    busy = false
111  }
112}
113
114export const register: Register = (on, options) => {
115  configured = String(options.docsDir ?? '').trim()
116  const pollMs = Math.max(2, Number(options.pollSeconds) || 10) * 1000
117
118  on('session.start', async ($, e, next) => {
119    await $.command.register({
120      name: 'board',
121      description: 'Show virtual-team backlog progress in a sidebar',
122      argumentHint: '[close | reset | <docs folder>]',
123    })
124    const found = await refresh($, true)
125    $.clock.every(pollMs, () => void refresh($))
126    if (found) void $.ui.open({ id: PANE, title: TITLE })
127    return next(e)
128  })
129
130  // Agents edit the backlog during turns: pick changes up as soon as a turn ends.
131  on('turn.complete', async ($, e, next) => {
132    const result = await next(e)
133    await refresh($).catch(() => false)
134    return result
135  })
136
137  on('command.run', { command: 'board' }, async ($, e) => {
138    const arg = e.args.trim()
139    if (arg === 'close') {
140      await $.ui.close({ id: PANE })
141      return { text: 'Backlog pane closed.' }
142    }
143    if (arg === 'reset') {
144      await $.store.delete(await storeKey($))
145    } else if (arg) {
146      const dir = arg.replace(/\/backlog\.md$/, '')
147      if (!(await $.fs.exists(join(dir, 'backlog.md')))) return { text: `No backlog.md in ${dir}.` }
148      await $.store.set(await storeKey($), dir)
149    }
150    const found = await refresh($, true)
151    const opened = await $.ui.open({ id: PANE, title: TITLE })
152    const s = await read($, snapshot)
153    if (!found || !s) return { text: (await read($, error)) ?? 'No backlog found.' }
154    const where = opened.isPlaced ? 'Backlog pane opened' : 'Backlog pane is waiting for a wider terminal'
155    return { text: `${where} (${s.docsDir}/backlog.md).` }
156  })
157
158  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
159    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
160    const s = await read($, snapshot)
161    const err = await read($, error)
162    const tab = await read($, view)
163    const open = await read($, selected)
164    const shown = new Set(await read($, expanded))
165    const cols = Math.max(20, e.props.bodyColumns)
166
167    const top = () => $.ui.scroll({ in: PANE, to: 'start' }).catch(() => undefined)
168    const setView = (v: BoardView) => async () => {
169      await update($, selected, () => null)
170      await update($, view, () => v)
171      await top()
172    }
173    const openItem = (key: string) => async () => {
174      await update($, selected, () => key)
175      await top()
176    }
177    const back = async () => {
178      const was = open
179      await update($, selected, () => null)
180      if (was) await $.ui.scroll({ to: { key: `i-${was}` }, block: 'center' }).catch(() => undefined)
181    }
182    const toggleFeature = (id: string) => () =>
183      update($, expanded, list => (list.includes(id) ? list.filter(x => x !== id) : [...list, id]))
184    const showFeature = (id: string) => async () => {
185      await update($, expanded, list => (list.includes(id) ? list : [...list, id]))
186      await update($, selected, () => null)
187      await update($, view, () => 'features')
188      await $.ui.scroll({ to: { key: `f-${id}` }, block: 'start' }).catch(() => undefined)
189    }
190
191    const tabs = (
192      <Box flexDirection="row" gap={1}>
193        <Button key="tab-overview" hotkey="o" variant={tab === 'overview' && !open ? 'primary' : 'secondary'} label="Overview" onPress={setView('overview')} />
194        <Button key="tab-features" hotkey="f" variant={tab === 'features' && !open ? 'primary' : 'secondary'} label="Features" onPress={setView('features')} />
195        <Button key="refresh" hotkey="r" label="Refresh" onPress={() => void refresh($, true)} />
196      </Box>
197    )
198
199    if (!s) {
200      return (
201        <Box flexDirection="column">
202          {tabs}
203          <Text dimColor>{err ?? 'Loading the backlog…'}</Text>
204        </Box>
205      )
206    }
207
208    const footer = (
209      <Box marginTop={1}>
210        <Text dimColor wrap="truncate-middle">
211          {s.docsDir}/backlog.md · {err ? 'read failed' : `updated ${hhmm(s.loadedAt)}`}
212        </Text>
213      </Box>
214    )
215
216    // One row per item: the whole row is a button that opens its details.
217    const row = (it: BoardItem, indent = '') => (
218      <Button key={`i-${it.key}`} plain onPress={openItem(it.key)}>
219        {indent}
220        <Text color={TYPE_COLOR[it.type]} dimColor={it.col === 'done' || it.col === 'parked'}>{it.id || '•'}</Text>{' '}
221        <Text dimColor={it.col === 'done' || it.col === 'parked'} strikethrough={it.col === 'parked'}>
222          {clip(it.title, cols - indent.length - it.id.length - 2)}
223        </Text>
224      </Button>
225    )
226
227    const d: BoardDetail | undefined = open ? s.details[open] : undefined
228    if (open) {
229      if (!d) {
230        return (
231          <Box flexDirection="column">
232            {tabs}
233            <Button key="back" hotkey="b" label="← Back" onPress={back} />
234            <Text dimColor>That item is no longer in the backlog.</Text>
235            {footer}
236          </Box>
237        )
238      }
239      const feature = d.feature?.startsWith('FEAT-') && d.feature !== d.id ? s.features.find(f => f.id === d.feature) : undefined
240      return (
241        <Box flexDirection="column">
242          {tabs}
243          <Box marginTop={1}>
244            <Button key="back" hotkey="b" label="← Back" onPress={back} />
245          </Box>
246          <Box marginTop={1} flexDirection="column">
247            <Text wrap="wrap">
248              <Text bold color={TYPE_COLOR[d.type]}>{d.id || 'Item'}</Text> <Text bold>{d.title}</Text>
249            </Text>
250            <Text wrap="wrap">
251              <Text color={COL_COLOR[d.col]}>{COL_LABEL[d.col]}</Text>
252              <Text dimColor> · status:{d.status} · line {d.line}</Text>
253            </Text>
254            {d.prs.length ? <Text color="warning">{d.prs.join(', ')}</Text> : null}
255            {feature ? (
256              <Button key="feature" plain onPress={showFeature(feature.id)}>
257                <Text dimColor>feature </Text>
258                <Text color="claude">{feature.id}</Text> {clip(feature.title, cols - 20)}
259              </Button>
260            ) : d.feature && d.feature !== d.id ? (
261              <Text dimColor>feature {d.feature}</Text>
262            ) : null}
263            {d.fields.map(([k, v]) => (
264              <Text wrap="wrap">
265                <Text dimColor>{k} </Text>
266                {v}
267              </Text>
268            ))}
269          </Box>
270          <Box marginTop={1} flexDirection="column">
271            {d.body ? <Markdown text={d.body} /> : <Text dimColor>No description.</Text>}
272          </Box>
273          {footer}
274        </Box>
275      )
276    }
277
278    if (tab === 'features') {
279      return (
280        <Box flexDirection="column">
281          {tabs}
282          <Text dimColor>
283            {s.features.length} open features · {s.featuresDone} complete · select one to see its stories
284          </Text>
285          {s.features.map(f => {
286            const isOpen = shown.has(f.id)
287            return (
288              <Box flexDirection="column" marginTop={1}>
289                <Button key={`f-${f.id}`} plain onPress={toggleFeature(f.id)}>
290                  {isOpen ? '▾ ' : '▸ '}
291                  <Text color="claude">{f.id}</Text> {clip(f.title, cols - f.id.length - 4)}
292                </Button>
293                <Text>
294                  {'  '}
295                  <Text color="success">{bar(f.done, f.total, Math.min(20, cols - 20))}</Text> {f.done}/{f.total}
296                  {f.active ? <Text color="warning"> · {f.active} active</Text> : null}
297                </Text>
298                {isOpen ? f.stories.map(st => row(st, '    ')) : null}
299              </Box>
300            )
301          })}
302          {footer}
303        </Box>
304      )
305    }
306
307    const section = (label: string, color: string, list: BoardItem[]) =>
308      list.length === 0 ? null : (
309        <Box flexDirection="column" marginTop={1}>
310          <Text bold color={color}>
311            {label} <Text dimColor>{list.length}</Text>
312          </Text>
313          {list.map(it => row(it))}
314        </Box>
315      )
316
317    const c = s.counts
318    const total = c.done + c.todo + c.prog + c.blocked + c.verify
319    const pct = total ? Math.round((c.done / total) * 100) : 0
320    return (
321      <Box flexDirection="column">
322        {tabs}
323        <Box marginTop={1}>
324          <Text>
325            <Text bold>{pct}% done </Text>
326            <Text color="success">{bar(c.done, total, Math.min(30, cols - 22))}</Text>
327            <Text dimColor> {c.done}/{total}</Text>
328          </Text>
329        </Box>
330        <Text wrap="wrap">
331          <Text color="warning">● {c.prog} doing</Text>  <Text color="suggestion">◐ {c.verify} verify</Text>
332          {'  '}<Text color="error">✕ {c.blocked} blocked</Text>  <Text>○ {c.todo} ready</Text>
333          <Text dimColor>  · {c.parked} parked</Text>
334        </Text>
335        {s.directives.map(dir => (
336          <Text dimColor italic wrap="wrap">
337            {dir}
338          </Text>
339        ))}
340        {section('In progress', 'warning', s.doing)}
341        {section('Verify', 'suggestion', s.verify)}
342        {section('Blocked', 'error', s.blocked)}
343        {section('Next up', 'text', s.next.slice(0, 8))}
344        {s.handoff ? (
345          <Box flexDirection="column" marginTop={1}>
346            <Text bold>Last handoff</Text>
347            <Text wrap="truncate-end">
348              <Text dimColor>{s.handoff.date}</Text> {s.handoff.title}
349            </Text>
350          </Box>
351        ) : null}
352        <Text dimColor>Select an item to see its details.</Text>
353        {footer}
354      </Box>
355    )
356  })
357}
358
hooks/parse.ts 161 lines
1// Pure parsing of a virtual-team backlog, ported from docs/backlog-board.html
2// (parseBacklog / makeItem / finishItem) so the pane and the browser board agree.
3
4import type { BoardCol, BoardDetail, BoardFeature, BoardItem, BoardSnapshot } from '../types'
5
6const MARKER: Record<string, string> = { x: 'done', '~': 'partial', '>': 'doing', '=': 'implemented', ' ': 'ready' }
7
8const STATUS_COL: Record<string, BoardCol> = {
9  done: 'done', complete: 'done', completed: 'done', shipped: 'done', closed: 'done',
10  ready: 'todo', planned: 'todo', draft: 'todo', backlog: 'todo', todo: 'todo', open: 'todo', specced: 'todo', tbd: 'todo', new: 'todo',
11  partial: 'prog', doing: 'prog', in_progress: 'prog', 'in-progress': 'prog', progress: 'prog', implementing: 'prog', wip: 'prog',
12  blocked: 'blocked', error: 'blocked', failed: 'blocked', waiting: 'blocked',
13  staging: 'verify', implemented: 'verify', verify: 'verify', verifying: 'verify', review: 'verify', testing: 'verify',
14  deferred: 'parked', superseded: 'parked', reverted: 'parked', cancelled: 'parked', canceled: 'parked', wontfix: 'parked', dropped: 'parked',
15}
16
17export type ParsedItem = BoardItem & { section: string; kv: Record<string, string>; line: number; desc: string; more: string[] }
18
19export function parseBacklog(md: string): { items: ParsedItem[]; directives: string[] } {
20  const items: ParsedItem[] = []
21  const directives: string[] = []
22  let section = ''
23  let inComment = false
24  let cur: ParsedItem | null = null
25  const seen = new Map<string, number>()
26  md.split(/\r?\n/).forEach((l, i) => {
27    if (inComment) { if (l.includes('-->')) inComment = false; return }
28    if (/^\s*<!--/.test(l)) { if (!l.includes('-->')) inComment = true; return }
29    const h = l.match(/^(#{1,4})\s+(.*)/)
30    if (h) { if (h[1]!.length === 2) section = h[2]!.trim(); cur = null; return }
31    const m = l.match(/^\s*- \[(.)\]\s+(.*)$/)
32    if (m) {
33      cur = makeItem(m[1]!, m[2]!, section, i + 1)
34      // An id listed twice keeps both rows, the second keyed apart.
35      const n = seen.get(cur.key) ?? 0
36      seen.set(cur.key, n + 1)
37      if (n > 0) cur.key = `${cur.key}#${n + 1}`
38      items.push(cur)
39      return
40    }
41    const t = l.trim()
42    if (!t) return
43    if (/^\*\*/.test(t) && /priorit/i.test(t)) { directives.push(t.replace(/\*\*/g, '')); return }
44    if (cur) cur.more.push(l.replace(/^ {2,4}/, ''))
45  })
46  return { items, directives }
47}
48
49function makeItem(marker: string, text: string, section: string, line: number): ParsedItem {
50  const segs = text.split(' | ')
51  let first = segs[0] ?? ''
52  const kv: Record<string, string> = {}
53  let desc = ''
54  let last = 0
55  while (last + 1 < segs.length && /^[a-z_]+:/.test(segs[last + 1] ?? '')) last++
56  for (let j = 1; j <= last; j++) {
57    const seg = segs[j] ?? ''
58    const k = seg.indexOf(':')
59    const rest = seg.slice(k + 1)
60    const d = j === last ? rest.indexOf(' — ') : -1
61    kv[seg.slice(0, k)] = (d >= 0 ? rest.slice(0, d) : rest).trim()
62    if (d >= 0) desc = rest.slice(d + 3)
63  }
64  if (last + 1 < segs.length) desc += (desc ? ' | ' : '') + segs.slice(last + 1).join(' | ')
65  if (segs.length === 1) { const d = first.indexOf(' — '); if (d > 0) { desc = first.slice(d + 3); first = first.slice(0, d) } }
66  let id = ''
67  let title = first.trim()
68  const im = first.match(/^((?:S|FEAT|BUG|CCM|TASK)-\d+)\s*:\s*([\s\S]*)$/)
69  if (im) { id = im[1]!; title = im[2]!.trim() }
70  const type = !id ? 'Task' : id.startsWith('FEAT') ? 'FEAT' : id.startsWith('BUG') ? 'BUG' : id.startsWith('S-') ? 'S' : 'Task'
71
72  const declared = (kv.status || '').toLowerCase().trim().split(/\s+/)[0] || ''
73  let status: string
74  if (marker === 'x' || /^done$/i.test(section)) status = 'done'
75  else if (declared && declared !== 'ready') status = declared
76  else status = MARKER[marker] || declared || 'ready'
77  if (marker === '~' && (declared === 'ready' || !declared)) status = 'partial'
78
79  return {
80    key: id || `line-${line}`, id, title: stripMd(title), status, col: STATUS_COL[status] || 'todo', type,
81    feature: kv.feature || (type === 'FEAT' ? id : undefined), section, kv, line, desc: desc.trim(), more: [],
82  }
83}
84
85export function stripMd(s: string): string {
86  return s.replace(/\*\*|__|`/g, '').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').trim()
87}
88
89/** Frontmatter `title`/`id`, or the first H1, of a feature spec or handoff. */
90export function docTitle(text: string): { id?: string; title?: string } {
91  const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/)
92  const field = (k: string) => fm?.[1]?.match(new RegExp(`^${k}:\\s*["']?(.*?)["']?\\s*$`, 'm'))?.[1]
93  const h1 = text.match(/^#\s+(.+)$/m)?.[1]
94  const title = field('title') || h1
95  const id = field('id') || h1?.match(/FEAT-\d+/)?.[0]
96  return { id, title: title ? stripMd(title).replace(/^FEAT-\d+\s*[—–:-]\s*/, '') : undefined }
97}
98
99const strip = ({ section: _s, kv: _k, line: _l, desc: _d, more: _m, ...item }: ParsedItem): BoardItem => item
100
101function detail(it: ParsedItem): BoardDetail {
102  const body = [it.desc, ...it.more].filter(Boolean).join('\n')
103  const prs = [...new Set(`${it.title}\n${body}`.match(/\bPR #\d+/g) ?? [])]
104  const fields = Object.entries(it.kv).filter(([k]) => k !== 'status' && k !== 'feature')
105  return { ...strip(it), line: it.line, fields, body, prs }
106}
107
108export function summarize(
109  md: string,
110  extra: { docsDir: string; loadedAt: number; featureTitles?: Record<string, string>; handoff?: BoardSnapshot['handoff'] },
111): BoardSnapshot {
112  const { items, directives } = parseBacklog(md)
113  const counts: Record<BoardCol, number> = { done: 0, todo: 0, prog: 0, blocked: 0, verify: 0, parked: 0 }
114  for (const it of items) counts[it.col]++
115
116  const titles: Record<string, string> = { ...extra.featureTitles }
117  for (const it of items) if (it.type === 'FEAT' && it.id) titles[it.id] = it.title
118
119  const byFeat = new Map<string, BoardFeature>()
120  for (const it of items) {
121    if (it.type === 'FEAT' || !it.feature?.startsWith('FEAT-')) continue
122    const f = byFeat.get(it.feature) ?? { id: it.feature, title: titles[it.feature] ?? '', done: 0, active: 0, total: 0, stories: [] }
123    byFeat.set(it.feature, f)
124    f.stories.push(strip(it))
125    if (it.col === 'parked') continue
126    f.total++
127    if (it.col === 'done') f.done++
128    else if (it.col !== 'todo') f.active++
129    byFeat.set(it.feature, f)
130  }
131  const all = [...byFeat.values()]
132  const open = all
133    .filter(f => f.total > 0 && f.done < f.total)
134    .sort((a, b) => (b.active > 0 ? 1 : 0) - (a.active > 0 ? 1 : 0) || b.done / b.total - a.done / a.total || a.id.localeCompare(b.id))
135
136  const open_ = (col: BoardCol) => items.filter(i => i.col === col).map(strip)
137  const next = items.filter(i => i.col === 'todo' && i.type !== 'FEAT' && /ready|in progress/i.test(i.section)).slice(0, 12)
138
139  // Details only for what the pane can open: open items, and the stories of open features.
140  const listed = new Set(open.flatMap(f => f.stories.map(st => st.key)))
141  for (const it of items) if (it.col === 'prog' || it.col === 'verify' || it.col === 'blocked') listed.add(it.key)
142  for (const it of next) listed.add(it.key)
143  const details: Record<string, BoardDetail> = {}
144  for (const it of items) if (listed.has(it.key)) details[it.key] = detail(it)
145
146  return {
147    docsDir: extra.docsDir,
148    loadedAt: extra.loadedAt,
149    counts,
150    doing: open_('prog'),
151    verify: open_('verify'),
152    blocked: open_('blocked'),
153    next: next.map(strip),
154    features: open,
155    featuresDone: all.filter(f => f.total > 0 && f.done === f.total).length,
156    directives,
157    handoff: extra.handoff,
158    details,
159  }
160}
161
types/index.d.ts 66 lines
1export type BoardCol = 'done' | 'todo' | 'prog' | 'blocked' | 'verify' | 'parked'
2
3export type BoardItem = {
4  /** Unique within a snapshot: the id, or `line-N` for an item with none. */
5  key: string
6  id: string
7  title: string
8  status: string
9  col: BoardCol
10  type: 'FEAT' | 'BUG' | 'S' | 'Task'
11  feature?: string
12}
13
14/** What the detail view shows for one item. */
15export type BoardDetail = BoardItem & {
16  line: number
17  /** The `key:value` segments other than status and feature, in file order. */
18  fields: [string, string][]
19  /** The description after ` — ` plus continuation lines, as markdown. */
20  body: string
21  /** `PR #N` references found in the title and body. */
22  prs: string[]
23}
24
25export type BoardFeature = {
26  id: string
27  title: string
28  done: number
29  active: number
30  total: number
31  /** Every story of the feature in file order, parked ones included. */
32  stories: BoardItem[]
33}
34
35export type BoardSnapshot = {
36  docsDir: string
37  loadedAt: number
38  counts: Record<BoardCol, number>
39  doing: BoardItem[]
40  verify: BoardItem[]
41  blocked: BoardItem[]
42  next: BoardItem[]
43  features: BoardFeature[]
44  featuresDone: number
45  directives: string[]
46  handoff?: { date: string; title: string }
47  /** Details by key, for every item the pane lists. */
48  details: Record<string, BoardDetail>
49}
50
51export type BoardView = 'overview' | 'features'
52
53declare module 'claude-code' {
54  interface PluginState {
55    'backlog-board': {
56      snapshot: BoardSnapshot | null
57      error: string | null
58      view: BoardView
59      /** Key of the item open in the detail view, or null for the list. */
60      selected: string | null
61      /** Feature ids expanded on the Features tab. */
62      expanded: string[]
63    }
64  }
65}
66