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

Claude Code mods for development workflows, published as a plugin marketplace.
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.
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.f): each open FEAT- with a done/total bar and its active stories.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.
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).
| Command | What it does |
|---|---|
/board | Open the pane |
/board <docs folder> | Point it at a project's docs folder (remembered per working directory) |
/board reset | Forget that folder |
/board close | Close 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).
claude --plugin-dir plugins/backlog-board # run from this folder
claude plugin validate plugins/backlog-board
claude plugin test plugins/backlog-boardhooks/register.tsx 358 lines1import { 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}
358hooks/parse.ts 161 lines1// 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}
161types/index.d.ts 66 lines1export 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