进度板:/board 一屏看当前仓的下一步 · GOAL 勾选 · 待拍板 · TODO · roadmap 在进行项 · 在跑的片 · 最近提交;/board <仓名> 看别的仓,/board all 各仓摘要。纯读文件,不调模型

A read-only progress board for the current repo (next steps, GOAL checklist, pending decisions, TODO, roadmap, running runs, recent commits) as a command and a sidebar; no model calls.
relay 自动 /clear 之后随时看仓的进度,不用再问。纯读文件 + git,不调模型、不写文件。
/board:当前仓 —— 下一步 · 当前 GOAL 各节勾选与未勾项 · 待拍板 · TODO 各节未勾数 · roadmap 在进行 / 已排 / 待拍板(status-next 布局:现在 / 接下来 里未 Done 的,At Risk / Blocked 标 ⚠)· 在跑的片(装了 delegate 时)· 最近 5 个提交。每段后面写来源文件和它多久前改过。/board <仓名>:看 <reposDir>/<仓名>。/board open / /board close:开关右侧常驻侧栏(当前仓:标题卡 + GOAL 完成率 · ▶ 进行中卡片 · GOAL 各节与 ROADMAP 各层的彩色分段条(绿 完成 · 黄 进行中 · 灰 规划中)· 其余各段点 ▸ 展开;≥110 列全屏终端是侧栏,否则在输入框上方)。开着时每分钟和每轮回答后刷新,/clear 或重启后自动重开。/board roadmap|goal|todo|next|待拍板 [仓名]:那一段的完整列表,逐项不截断。roadmap = 全文件每一行(按 ## / ### 分组,带状态;「之后」表带触发条件);goal = 每项 ✓ 完成 / ▶ 进行中 / ○ 规划中。/board all:reposDir 下每个认得的仓(只看主 checkout),每段一行摘要。内置两套预设,按仓里有哪个文件认:
_NEXT.md):_NEXT.md · 最新 docs/plan/<版本>/GOAL.md · docs/plan/DECISIONS-PENDING.md · docs/plan/TODO.md · docs/ROADMAP.md。docs/status/NEXT.md):docs/status/NEXT.md · docs/ops/roadmap.md(### 现在 / ### 接下来 状态表,## <n>. 待… 待拍板节)。别的仓放 .claude/board.json({ "sections": [...] },段的写法见 types/index.d.ts)即可,它优先于预设。
- [ ] 〔进行中 <run名>〕项名 …:派出 / 开工时加,合入验收改 [x] 时删,片停了也删。board 据此分:[x] 完成 · 带标记 = 进行中 · 其余 [ ] = 规划中(GOAL 规划范围内未开始);GOAL 外的 TODO / ROADMAP 候选是「未做」。
| 选项 | 默认 | 说明 |
|---|---|---|
reposDir | 空 = ~/repos | /board <仓名>、/board all 在这里找仓;会话在其下时以 <reposDir>/<仓名> 为仓根,否则以 cwd 为仓根 |
/plugin marketplace add AbyssCN/claude-lead-harness
/plugin install board@claude-lead-harnesshooks/register.tsx 412 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BoardSection, PaneModel } from '../types'
5import { age, barCells, byVersionDesc, checklistGroups, fitCells, render, renderFull, roadmapLayers, short } from './parse'
6
7// Built-in layouts (SDD D-3), picked by which file a repo has; `.claude/board.json` overrides.
8const PRESETS: ReadonlyArray<{ probe: string; sections: BoardSection[] }> = [
9 {
10 // next-md layout: _NEXT.md at the root, versioned docs/plan/<ver>/GOAL.md, DECISIONS-PENDING / TODO, docs/ROADMAP.md table.
11 probe: '_NEXT.md',
12 sections: [
13 { kind: 'list', label: '下一步', file: '_NEXT.md', heading: '^## 现在', after: '^\\*\\*下一步\\*\\*', stamp: '^最近更新[::](.*)$' },
14 { kind: 'checklist', label: 'GOAL', file: 'docs/plan/*/GOAL.md', group: '###', titles: true },
15 { kind: 'pending', label: '待拍板', file: 'docs/plan/DECISIONS-PENDING.md' },
16 { kind: 'checklist', label: 'TODO', file: 'docs/plan/TODO.md', group: '##' },
17 { kind: 'table', label: 'ROADMAP', file: 'docs/ROADMAP.md', show: '^(进行中|已排|待(?!核))', count: '^(候选|待核)' },
18 ],
19 },
20 {
21 // status-next layout: docs/status/NEXT.md plus a docs/ops/roadmap.md with 现在 / 接下来 status tables
22 // and a numbered「待 <owner>」section.
23 probe: 'docs/status/NEXT.md',
24 sections: [
25 { kind: 'list', label: '下一步', file: 'docs/status/NEXT.md', heading: '^## ▶️ 下一步', stamp: '^## 🧭 现状[((]([^))]+)' },
26 { kind: 'list', label: '待拍板(roadmap)', file: 'docs/ops/roadmap.md', heading: '^## \\d+\\. 待' },
27 { kind: 'list', label: 'NEXT 里的待办 / 待裁', file: 'docs/status/NEXT.md', heading: '^## 🙋', countOnly: true },
28 { kind: 'table', label: 'ROADMAP 现在 + 接下来', file: 'docs/ops/roadmap.md', heading: '^### (现在|接下来)', show: '^(At Risk|Blocked|On Track|Not Started)', count: '^(Done)', alert: 'At Risk|Blocked' },
29 ],
30 },
31]
32
33const PANE = 'board'
34const PANE_MIN_COLUMNS = 110
35const TICK_MS = 60_000
36// Wanted across sessions (a /clear starts a new one): set by /board open, cleared by /board close.
37const WANT_KEY = 'paneOpen'
38const model = atom({ plugin: 'board', key: 'model' } as const, null)
39const at = atom({ plugin: 'board', key: 'at' } as const, 0)
40// Which sidebar blocks are expanded, by key; the GOAL sections and running items always show.
41const open = atom({ plugin: 'board', key: 'open' } as const, {})
42
43// userConfig reposDir (`~/` allowed), default ~/repos: where `/board <name>` and `/board all` look.
44let reposDirOption = ''
45
46async function home($: EngineInterface): Promise<string> {
47 return (await $.env.get('HOME').catch(() => undefined)) || '.'
48}
49
50async function reposDir($: EngineInterface): Promise<string> {
51 const p = reposDirOption
52 if (p === '~' || p.startsWith('~/')) return `${await home($)}${p.slice(1)}`
53 return p || `${await home($)}/repos`
54}
55
56// The repo the session is in: <reposDir>/<name> when cwd is under reposDir, else cwd itself.
57async function repoRoot($: EngineInterface): Promise<string> {
58 const cwd = await $.session.cwd()
59 const rd = await reposDir($)
60 if (!cwd.startsWith(`${rd}/`)) return cwd
61 return `${rd}/${cwd.slice(rd.length + 1).split('/')[0]}`
62}
63
64const exists = async ($: EngineInterface, p: string): Promise<boolean> => !!(await $.fs.stat(p).catch(() => null))
65
66async function readText($: EngineInterface, p: string): Promise<string | null> {
67 try {
68 return String(await $.fs.read(p))
69 } catch {
70 return null
71 }
72}
73
74async function sectionsOf($: EngineInterface, root: string): Promise<BoardSection[] | null> {
75 const own = await readText($, `${root}/.claude/board.json`)
76 if (own) {
77 try {
78 const s = JSON.parse(own).sections
79 if (Array.isArray(s)) return s as BoardSection[]
80 } catch {
81 // Malformed: fall back to the presets.
82 }
83 }
84 for (const p of PRESETS) if (await exists($, `${root}/${p.probe}`)) return p.sections
85 return null
86}
87
88// A file path with one `*` segment → the highest-versioned match, and what the `*` stood for.
89async function resolveFile($: EngineInterface, root: string, file: string): Promise<{ path: string; tag: string } | null> {
90 const star = file.indexOf('*')
91 if (star < 0) return { path: `${root}/${file}`, tag: '' }
92 const dir = `${root}/${file.slice(0, star)}`
93 const rest = file.slice(star + 1)
94 const names = (await $.fs.list(dir).catch(() => []))
95 .filter(d => d.kind === 'dir')
96 .map(d => d.name)
97 .sort(byVersionDesc)
98 for (const n of names) if (await exists($, `${dir}${n}${rest}`)) return { path: `${dir}${n}${rest}`, tag: ` ${n}` }
99 return null
100}
101
102async function git($: EngineInterface, root: string, args: string[]): Promise<string> {
103 try {
104 const r = await $.process.run(['git', '-C', root, ...args], { timeoutMs: 5000 })
105 return r.exitCode === 0 ? r.stdout.trim() : ''
106 } catch {
107 return ''
108 }
109}
110
111// Each section as a summary line and its details, or one line saying which file was missing.
112async function board($: EngineInterface, root: string, sections: readonly BoardSection[], brief: boolean): Promise<string[]> {
113 const now = Date.now()
114 const out: string[] = []
115 for (const s of sections) {
116 const f = await resolveFile($, root, s.file)
117 const md = f ? await readText($, f.path) : null
118 if (!f || md === null) {
119 out.push(`▸ ${s.label}:读不到 ${s.file}`)
120 continue
121 }
122 const st = await $.fs.stat(f.path).catch(() => null)
123 const r = render({ ...s, label: `${s.label}${f.tag}` }, md)
124 out.push(`▸ ${r.summary}${st ? ` [${f.path.slice(root.length + 1)} ${age(now - st.mtimeMs)}]` : ''}`)
125 if (!brief) out.push(...r.detail.map(d => ` ${d}`))
126 }
127 return out
128}
129
130// Absent noun (delegate not installed): the call throws, caught here.
131async function delegateRuns($: EngineInterface): Promise<Array<{ label: string; state: string; executor?: string }> | null> {
132 try {
133 const runs = await $.delegate!.runs()
134 return Array.isArray(runs) ? runs : null
135 } catch {
136 return null
137 }
138}
139
140async function header($: EngineInterface, root: string): Promise<string> {
141 const branch = await git($, root, ['rev-parse', '--abbrev-ref', 'HEAD'])
142 const head = await git($, root, ['rev-parse', '--short', 'HEAD'])
143 return `${root.split('/').pop()} · ${branch || '?'} ${head}`
144}
145
146async function fullBoard($: EngineInterface, root: string, here: boolean): Promise<string> {
147 const sections = await sectionsOf($, root)
148 if (!sections) return `board:${root} 没有可读的进度文件(_NEXT.md / docs/status/NEXT.md / .claude/board.json)`
149 const lines = [await header($, root), ...(await board($, root, sections, false))]
150 // delegate's runs belong to this session, so only on the board of the repo it runs in.
151 if (here) {
152 const runs = await delegateRuns($)
153 const live = (runs ?? []).filter(r => r.state === 'running')
154 if (runs) lines.push(`▸ 在跑的片(${live.length}):${live.map(r => `${r.label}${r.executor ? `(${r.executor})` : ''}`).join(' · ') || '无'}`)
155 }
156 const log = await git($, root, ['log', '-5', '--format=%h %s'])
157 if (log) lines.push('▸ 最近提交', ...log.split('\n').map(l => ` ${short(l, 90)}`))
158 return lines.join('\n')
159}
160
161// `/board <section> [repo]`: which sections a word names.
162const SECTION_WORDS: Record<string, (s: BoardSection) => boolean> = {
163 roadmap: s => /^ROADMAP/.test(s.label),
164 goal: s => /^GOAL/.test(s.label),
165 todo: s => /^TODO/.test(s.label),
166 next: s => /^下一步/.test(s.label),
167 下一步: s => /^下一步/.test(s.label),
168 待拍板: s => s.kind === 'pending' || /^待/.test(s.label),
169 pending: s => s.kind === 'pending' || /^待/.test(s.label),
170}
171
172async function fullSection($: EngineInterface, root: string, word: string): Promise<string> {
173 const sections = (await sectionsOf($, root)) ?? []
174 const picked = sections.filter(SECTION_WORDS[word]!)
175 if (!picked.length) return `board:${root.split('/').pop()} 没有「${word}」这一段`
176 const out: string[] = []
177 for (const s of picked) {
178 const f = await resolveFile($, root, s.file)
179 const md = f ? await readText($, f.path) : null
180 if (!f || md === null) {
181 out.push(`▸ ${s.label}:读不到 ${s.file}`)
182 continue
183 }
184 out.push(`▸ ${render({ ...s, label: `${s.label}${f.tag}` }, md).summary} [${f.path.slice(root.length + 1)}]`, ...renderFull(s, md), '')
185 }
186 return `${root.split('/').pop()}\n${out.join('\n').trim()}`
187}
188
189async function allBoards($: EngineInterface, rd: string): Promise<string> {
190 const out: string[] = []
191 for (const d of await $.fs.list(rd).catch(() => [])) {
192 if (d.kind !== 'dir') continue
193 const root = `${rd}/${d.name}`
194 // Main checkouts only: a worktree's .git is a file.
195 if ((await $.fs.stat(`${root}/.git`).catch(() => null))?.kind !== 'dir') continue
196 const sections = await sectionsOf($, root)
197 if (!sections) continue
198 out.push(await header($, root), ...(await board($, root, sections, true)), '')
199 }
200 return out.join('\n').trim() || `board:${rd} 下没有带进度文件的仓`
201}
202
203// The sidebar's model for the repo this session works in.
204async function buildModel($: EngineInterface, root: string): Promise<PaneModel | null> {
205 const sections = await sectionsOf($, root)
206 if (!sections) return null
207 const m: PaneModel = { title: await header($, root), goal: null, running: [], blocks: [], roadmap: [] }
208 for (const s of sections) {
209 const f = await resolveFile($, root, s.file)
210 const md = f ? await readText($, f.path) : null
211 if (!f || md === null) {
212 m.blocks.push({ key: s.label, summary: `${s.label}:读不到`, lines: [s.file] })
213 continue
214 }
215 if (s.kind === 'checklist' && s.titles && !m.goal) {
216 const groups = checklistGroups(md, s.group)
217 m.goal = {
218 tag: `${s.label}${f.tag}`,
219 groups: groups.map(g => ({ name: g.name, done: g.done, running: g.running.length, planned: g.planned.length })),
220 }
221 m.running = groups.flatMap(g => g.running)
222 continue
223 }
224 if (s.kind === 'table') m.roadmap = roadmapLayers(md)
225 const r = render({ ...s, label: `${s.label}${f.tag}` }, md)
226 const st = await $.fs.stat(f.path).catch(() => null)
227 const full = s.kind === 'table' ? r.detail : renderFull(s, md).filter(Boolean)
228 m.blocks.push({ key: s.label, summary: `${r.summary}${st ? ` · ${age(Date.now() - st.mtimeMs)}` : ''}`, lines: full.slice(0, 15) })
229 }
230 const runs = await delegateRuns($)
231 const live = (runs ?? []).filter(r => r.state === 'running')
232 if (runs) m.blocks.push({ key: '在跑的片', summary: `在跑的片 ${live.length}`, lines: live.map(r => `${r.label}${r.executor ? `(${r.executor})` : ''}`) })
233 const log = await git($, root, ['log', '-5', '--format=%h %s'])
234 if (log) m.blocks.push({ key: '最近提交', summary: '最近提交', lines: log.split('\n') })
235 return m
236}
237
238async function refresh($: EngineInterface): Promise<void> {
239 const m = await buildModel($, await repoRoot($))
240 await update($, model, () => m)
241 await update($, at, () => Date.now())
242}
243
244export const register: Register = (on, options) => {
245 reposDirOption = String(options.reposDir ?? '').trim()
246 on('session.start', async ($, e, next) => {
247 await $.command.register({
248 name: 'board',
249 description: '进度板:下一步 · GOAL 进行中 / 规划中 · 待拍板 · TODO · roadmap · 最近提交(纯读文件);open / close 开关侧栏',
250 argumentHint: '[仓名|all|open|close|roadmap|goal|todo|next|待拍板 [仓名]]',
251 })
252 $.clock.every(TICK_MS, () => void refresh($).catch(() => undefined))
253 // Re-open the sidebar a /clear or restart closed, if the user asked for it.
254 if (await $.store.get(WANT_KEY).catch(() => false)) {
255 void refresh($)
256 .then(() => $.ui.open({ id: PANE, title: '进度' }))
257 .catch(() => undefined)
258 }
259 return next(e)
260 })
261
262 on('turn.complete', async ($, e, next) => {
263 const done = await next(e)
264 if (!e.agentId) void refresh($).catch(() => undefined)
265 return done
266 })
267
268 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
269 const { Box, Text, Button } = $.ui.resolve(e)
270 const m = await read($, model)
271 const expanded = await read($, open)
272 const updated = await read($, at)
273 const W = Math.max(30, e.props.bodyColumns)
274 if (!m) return <Text dimColor>board:这个仓没有可读的进度文件</Text>
275
276 // A row: fixed-width label, a done / running / planned bar, a count.
277 const LABEL = Math.min(14, Math.floor(W / 3))
278 const COUNT = 7
279 const barRow = (key: string, name: string, t: { done: number; running: number; planned: number }) => {
280 const b = barCells(t, Math.max(6, W - LABEL - COUNT - 3))
281 return (
282 <Text key={key} wrap="truncate-end">
283 {' ' + fitCells(name, LABEL) + ' '}
284 <Text color="success">{'█'.repeat(b.done)}</Text>
285 <Text color="warning">{'█'.repeat(b.running)}</Text>
286 <Text dimColor>{'░'.repeat(b.planned)}</Text>
287 <Text dimColor>{' ' + `${t.done}/${t.done + t.running + t.planned}`.padStart(COUNT - 1)}</Text>
288 </Text>
289 )
290 }
291 const toggle = (key: string) => async () => {
292 await update($, open, o => ({ ...o, [key]: !o[key] }))
293 }
294 const sum = m.goal?.groups.reduce((a, g) => ({ done: a.done + g.done, running: a.running + g.running, planned: a.planned + g.planned }), { done: 0, running: 0, planned: 0 })
295 const pct = sum ? Math.round((sum.done / Math.max(1, sum.done + sum.running + sum.planned)) * 100) : 0
296 const time = updated ? new Date(updated).toTimeString().slice(0, 5) : '—'
297
298 return (
299 <Box flexDirection="column" width={W}>
300 <Box borderStyle="round" borderColor="claude" flexDirection="column" paddingX={1}>
301 <Text wrap="truncate-end">
302 <Text bold>{m.title}</Text>
303 <Text dimColor>{' ' + time}</Text>
304 </Text>
305 {m.goal && sum ? (
306 <Text wrap="truncate-end">
307 <Text bold>{`${m.goal.tag} ${sum.done}/${sum.done + sum.running + sum.planned} `}</Text>
308 <Text color="success">{`完成 ${pct}%`}</Text>
309 <Text color="warning">{` 进行中 ${sum.running}`}</Text>
310 <Text dimColor>{` 规划中 ${sum.planned}`}</Text>
311 </Text>
312 ) : null}
313 </Box>
314
315 {m.running.length ? (
316 <Box flexDirection="column" marginTop={1}>
317 <Text bold color="warning">{` ▶ 进行中 (${m.running.length})`}</Text>
318 {m.running.map((r, i) => (
319 <Box key={`run${i}`} flexDirection="column">
320 <Text wrap="truncate-end">
321 <Text color="warning">{' ┃ '}</Text>
322 <Text bold>{r.title}</Text>
323 </Text>
324 {r.runs ? (
325 <Text wrap="truncate-end">
326 <Text color="warning">{' ┃ '}</Text>
327 <Text dimColor>{r.runs}</Text>
328 </Text>
329 ) : null}
330 </Box>
331 ))}
332 </Box>
333 ) : null}
334
335 {m.goal ? (
336 <Box flexDirection="column" marginTop={1}>
337 <Text bold>{' GOAL 各节'}</Text>
338 {m.goal.groups.map((g, i) => barRow(`g${i}`, g.name.replace('/', ' '), g))}
339 </Box>
340 ) : null}
341
342 {m.roadmap.length ? (
343 <Box flexDirection="column" marginTop={1}>
344 <Text bold>{' ROADMAP'}</Text>
345 {m.roadmap.map((t, i) => barRow(`r${i}`, t.name, t))}
346 </Box>
347 ) : null}
348
349 <Box flexDirection="column" marginTop={1}>
350 {m.blocks.map(b => {
351 const isOpen = !!expanded[b.key]
352 return (
353 <Box key={`b-${b.key}`} flexDirection="column">
354 <Button plain label={b.summary} onPress={toggle(b.key)}>
355 <Text wrap="truncate-end">
356 <Text color="claude">{isOpen ? ' ▾ ' : ' ▸ '}</Text>
357 {fitCells(b.summary, W - 4).trimEnd()}
358 </Text>
359 </Button>
360 {isOpen
361 ? b.lines.map((l, i) => (
362 <Text key={`${b.key}${i}`} wrap="truncate-end" dimColor={!/^\s*▶/.test(l)} color={/^\s*▶/.test(l) ? 'warning' : undefined}>
363 {' ' + l}
364 </Text>
365 ))
366 : null}
367 </Box>
368 )
369 })}
370 </Box>
371
372 <Text dimColor wrap="truncate-end">
373 {'\n '}
374 <Text color="success">{'██'}</Text>
375 {' 完成 '}
376 <Text color="warning">{'██'}</Text>
377 {' 进行中 '}
378 {'░░ 规划中 · 点 ▸ 展开'}
379 </Text>
380 </Box>
381 )
382 })
383
384 on('command.run', { command: 'board' }, async ($, e) => {
385 const [word = '', repo = ''] = e.args.trim().split(/\s+/)
386 const rd = await reposDir($)
387 if (SECTION_WORDS[word.toLowerCase()]) {
388 const root = repo ? `${rd}/${repo}` : await repoRoot($)
389 if (repo && !(await exists($, root))) return { text: `board:没有 ${rd}/${repo}` }
390 return { text: await fullSection($, root, word.toLowerCase()) }
391 }
392 const arg = word
393 if (arg === 'all') return { text: await allBoards($, rd) }
394 if (arg === 'open') {
395 await $.store.set(WANT_KEY, true)
396 await refresh($)
397 const r = await $.ui.open({ id: PANE, title: '进度' })
398 const wide = e.presentation.isFullscreen && e.presentation.columns >= PANE_MIN_COLUMNS
399 return { text: r.isPlaced ? `board:侧栏已打开${wide ? '' : '(终端不是 ≥110 列全屏,会显示在输入框上方)'};clear 后自动重开,/board close 关` : 'board:侧栏没放上,终端变宽后再试' }
400 }
401 if (arg === 'close') {
402 await $.store.set(WANT_KEY, false)
403 await $.ui.close({ id: PANE }).catch(() => undefined)
404 return { text: 'board:侧栏已关,不再自动打开' }
405 }
406 const here = await repoRoot($)
407 const root = arg ? `${rd}/${arg}` : here
408 if (arg && !(await exists($, root))) return { text: `board:没有 ${rd}/${arg}` }
409 return { text: await fullBoard($, root, root === here) }
410 })
411}
412hooks/parse.ts 369 lines1// Pure readers: one markdown file + one section spec → a summary line and detail lines.
2import type { BoardSection } from '../types'
3
4export type Rendered = { summary: string; detail: string[] }
5
6// Markdown → plain: bold, code, struck-out markers and [text](link) dropped.
7export const plain = (s: string): string =>
8 s.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/\*\*|`|~~/g, '').trim()
9
10export const short = (s: string, n = 60): string => {
11 const p = plain(s).replace(/ · /g, '/')
12 return p.length > n ? `${p.slice(0, n)}…` : p
13}
14
15// The lines under every heading matching `heading`, each up to the next heading of the same or a higher level.
16export function under(md: string, heading: string): string[] {
17 const re = new RegExp(heading)
18 const out: string[] = []
19 let level = 0
20 for (const line of md.split('\n')) {
21 const h = line.match(/^(#+) /)
22 if (h) {
23 const l = h[1]!.length
24 if (re.test(line)) level = l
25 else if (level && l <= level) level = 0
26 continue
27 }
28 if (level) out.push(line)
29 }
30 return out
31}
32
33// Unindented list items (`1. x`, `- x`), text only.
34export const topItems = (lines: readonly string[]): string[] =>
35 lines.flatMap(l => {
36 const m = l.match(/^(?:\d+\.|[-*]) (.*)$/)
37 return m ? [m[1]!] : []
38 })
39
40// An item's name: its leading bold, else the text before the first colon / paren / dash.
41export function itemTitle(text: string, n = 40): string {
42 const t = text.replace(/^\[[ xX]\] /, '')
43 const bold = t.match(/^\*\*(.+?)\*\*/)
44 if (bold) return short(bold[1]!, n)
45 return short(t.split(/[::((—]/)[0] ?? t, n)
46}
47
48// `prefix` and the items on one line, cut with a count of the rest.
49export function joinCapped(items: readonly string[], prefix = '', width = 110): string {
50 let line = prefix
51 for (const [i, it] of items.entries()) {
52 const next = line === prefix ? `${prefix}${it}` : `${line} · ${it}`
53 if (next.length > width && line !== prefix) return `${line} … +${items.length - i}`
54 line = next
55 }
56 return line
57}
58
59// Highest version first: 10.11 > 10.9 > 10.8.
60export const byVersionDesc = (a: string, b: string): number => {
61 const na = a.match(/\d+/g)?.map(Number) ?? []
62 const nb = b.match(/\d+/g)?.map(Number) ?? []
63 for (let i = 0; i < Math.max(na.length, nb.length); i++) {
64 const d = (nb[i] ?? -1) - (na[i] ?? -1)
65 if (d) return d
66 }
67 return b.localeCompare(a)
68}
69
70// Open rows of a DECISIONS-PENDING table: a struck-out question or one marked 关闭 is closed.
71export function openDecisionRows(md: string): string[] {
72 return md
73 .split('\n')
74 .filter(l => /^\|\s*\d+\s*\|/.test(l))
75 .map(l => l.split('|')[2]?.trim() ?? '')
76 .filter(q => q !== '' && !q.startsWith('~~') && !/关闭/.test(q))
77}
78
79function renderList(s: Extract<BoardSection, { kind: 'list' }>, md: string): Rendered {
80 let lines = under(md, s.heading)
81 if (s.after) {
82 const re = new RegExp(s.after)
83 const i = lines.findIndex(l => re.test(l))
84 lines = i < 0 ? [] : lines.slice(i + 1)
85 const end = lines.findIndex(l => /^(---|\*\*[^*]+\*\*\s*$)/.test(l))
86 if (end >= 0) lines = lines.slice(0, end)
87 }
88 const items = topItems(lines)
89 const m = s.stamp ? md.match(new RegExp(s.stamp, 'm')) : null
90 const stamp = m ? ` · ${plain(m[1] ?? m[0])}` : ''
91 if (s.countOnly) return { summary: `${s.label} ${items.length} 条${stamp}`, detail: [] }
92 const shown = items.slice(0, s.max ?? 6)
93 return {
94 summary: `${s.label}${stamp}`,
95 detail: [
96 ...shown.map((t, i) => `${i + 1}. ${itemTitle(t, 70)}`),
97 ...(items.length > shown.length ? [`… 另 ${items.length - shown.length} 条`] : []),
98 ],
99 }
100}
101
102// GOAL's in-progress marker: `- [ ] 〔进行中 <run名…>〕…`; dropped when the item is ticked.
103const RUNNING = /^〔进行中\s*([^〕]*)〕\s*/
104
105export type Group = { name: string; done: number; running: Array<{ title: string; runs: string }>; planned: string[] }
106
107// Top-level checkboxes grouped by the headings of one level; open ones split into running and planned.
108export function checklistGroups(md: string, group: '##' | '###'): Group[] {
109 const groups: Group[] = []
110 let g: Group | null = null
111 const level = group.length
112 for (const line of md.split('\n')) {
113 const h = line.match(/^(#+) (.*)$/)
114 if (h && h[1]!.length <= level) {
115 g = h[1]!.length === level ? { name: short(h[2]!.split(/[((]/)[0] ?? h[2]!, 24), done: 0, running: [], planned: [] } : null
116 if (g) groups.push(g)
117 continue
118 }
119 const c = line.match(/^- \[([ xX])\] (.*)$/)
120 if (!c) continue
121 if (!g) groups.push((g = { name: '(未分节)', done: 0, running: [], planned: [] }))
122 if (c[1] !== ' ') {
123 g.done++
124 continue
125 }
126 const r = c[2]!.match(RUNNING)
127 if (r) {
128 g.running.push({ title: itemTitle(c[2]!.slice(r[0].length), 40), runs: r[1]!.trim() })
129 } else g.planned.push(c[2]!)
130 }
131 return groups
132}
133
134function renderChecklist(s: Extract<BoardSection, { kind: 'checklist' }>, md: string): Rendered {
135 const groups = checklistGroups(md, s.group)
136 const done = groups.reduce((n, x) => n + x.done, 0)
137 const running = groups.reduce((n, x) => n + x.running.length, 0)
138 const planned = groups.reduce((n, x) => n + x.planned.length, 0)
139 const total = done + running + planned
140 if (!s.titles) {
141 return {
142 summary: `${s.label} ${done}/${total} · 未勾 ${running + planned}`,
143 detail: groups.filter(x => x.running.length + x.planned.length).map(x => `${x.name} ${x.done}/${x.done + x.running.length + x.planned.length}`),
144 }
145 }
146 return {
147 summary: `${s.label} 规划完成 ${done}/${total} · 进行中 ${running} · 规划中 ${planned}`,
148 detail: groups
149 .filter(x => x.running.length + x.planned.length)
150 .flatMap(x => [
151 joinCapped(x.planned.map(t => itemTitle(t, 28)), `${x.name} ${x.done}/${x.done + x.running.length + x.planned.length}${x.planned.length ? ' 规划中:' : ''}`),
152 ...x.running.map(t => ` ▶ ${short(t.title, 28)}${t.runs ? `(${t.runs})` : ''}`),
153 ]),
154 }
155}
156
157type Row = { name: string; status: string }
158
159// Rows of every markdown table in `lines` with a column whose header matches `column` (状态 by default);
160// a column other than 状态 prefixes the value with its header (触发:W1 出闸).
161export function tableRows(lines: readonly string[], column = /状态/): Row[] {
162 const out: Row[] = []
163 let col = -1
164 let prefix = ''
165 for (const [i, line] of lines.entries()) {
166 if (!line.startsWith('|')) {
167 col = -1
168 continue
169 }
170 const cells = line.split('|').slice(1, -1).map(c => c.trim())
171 if (/^\|[\s:|-]+\|$/.test(line)) continue
172 if (/^\|[\s:|-]+\|$/.test(lines[i + 1] ?? '')) {
173 col = cells.findIndex(c => column.test(c))
174 prefix = col >= 0 && !/状态/.test(cells[col]!) ? `${plain(cells[col]!)}:` : ''
175 continue
176 }
177 if (col < 0 || cells[col] === undefined) continue
178 const first = plain(cells[0] ?? '')
179 const name = /^\S{1,6}$/.test(first) ? `${first} ${plain(cells[1] ?? '')}` : first
180 out.push({ name, status: prefix + plain(cells[col]!) })
181 }
182 return out
183}
184
185function renderTable(s: Extract<BoardSection, { kind: 'table' }>, md: string): Rendered {
186 const rows = tableRows(s.heading ? under(md, s.heading) : md.split('\n'))
187 const show = new RegExp(s.show)
188 const count = s.count ? new RegExp(s.count) : null
189 const alert = s.alert ? new RegExp(s.alert) : null
190 const shown = new Map<string, string[]>()
191 const counted = new Map<string, number>()
192 for (const r of rows) {
193 const m = r.status.match(show)
194 if (m) {
195 const k = m[1] ?? m[0]
196 shown.set(k, [...(shown.get(k) ?? []), `${alert?.test(r.status) ? '⚠ ' : ''}${itemTitle(r.name, 26)}`])
197 continue
198 }
199 const c = count ? r.status.match(count) : null
200 if (c) counted.set(c[1] ?? c[0], (counted.get(c[1] ?? c[0]) ?? 0) + 1)
201 }
202 const tally = [...[...shown].map(([k, v]) => [k, v.length] as const), ...counted]
203 return {
204 summary: `${s.label}:${tally.map(([k, n]) => `${k} ${n}`).join(' · ') || '无'}`,
205 detail: [...shown].map(([k, v]) => joinCapped(v, `${k}:`)),
206 }
207}
208
209function renderPending(s: Extract<BoardSection, { kind: 'pending' }>, md: string): Rendered {
210 const rows = openDecisionRows(md)
211 return { summary: `${s.label} ${rows.length}`, detail: rows.length ? [joinCapped(rows.map(r => itemTitle(r, 24)))] : [] }
212}
213
214export function render(s: BoardSection, md: string): Rendered {
215 switch (s.kind) {
216 case 'list':
217 return renderList(s, md)
218 case 'checklist':
219 return renderChecklist(s, md)
220 case 'table':
221 return renderTable(s, md)
222 case 'pending':
223 return renderPending(s, md)
224 }
225}
226
227const GLYPH = { done: '✓', running: '▶', planned: '○' } as const
228
229// Every item of a section, one per line, under the file's headings: what `/board <section>` prints.
230export function renderFull(s: BoardSection, md: string): string[] {
231 if (s.kind === 'pending') return openDecisionRows(md).map((r, i) => `${i + 1}. ${short(r, 120)}`)
232 if (s.kind === 'list') {
233 let lines = under(md, s.heading)
234 if (s.after) {
235 const i = lines.findIndex(l => new RegExp(s.after!).test(l))
236 lines = i < 0 ? [] : lines.slice(i + 1)
237 }
238 return topItems(lines).map((t, i) => `${i + 1}. ${short(t, 160)}`)
239 }
240 const out: string[] = []
241 const lines = md.split('\n')
242 if (s.kind === 'checklist') {
243 const level = s.group.length
244 for (const line of lines) {
245 const h = line.match(/^(#+) (.*)$/)
246 if (h && h[1]!.length <= level) {
247 if (h[1]!.length === level) out.push('', `## ${short(h[2]!.split(/[((]/)[0] ?? h[2]!, 40)}`)
248 continue
249 }
250 const c = line.match(/^- \[([ xX])\] (.*)$/)
251 if (!c) continue
252 const r = c[2]!.match(RUNNING)
253 const glyph = c[1] !== ' ' ? GLYPH.done : r ? GLYPH.running : GLYPH.planned
254 const body = r ? c[2]!.slice(r[0].length) : c[2]!
255 out.push(` ${glyph} ${itemTitle(body, 60)}${r && r[1]!.trim() ? `(${r[1]!.trim()})` : ''}`)
256 }
257 return out.slice(out[0] === '' ? 1 : 0)
258 }
259 // table: every row of the whole file (the summary's heading filter does not apply), under its ## / ###;
260 // Later-style tables with a 触发 column instead of 状态 count too.
261 let heading: string[] = []
262 let block: string[] = []
263 const flush = (): void => {
264 const rows = tableRows(block, /状态|触发/)
265 block = []
266 if (!rows.length) return
267 out.push('', heading.filter(Boolean).join(' / '))
268 for (const r of rows) out.push(` ${itemTitle(r.name, 50)} — ${short(r.status, 40)}`)
269 }
270 for (const line of lines) {
271 const h = line.match(/^(#+) (.*)$/)
272 if (h) {
273 flush()
274 const l = h[1]!.length
275 if (l === 2) heading = [plain(h[2]!)]
276 else if (l === 3) heading = [heading[0] ?? '', plain(h[2]!)]
277 continue
278 }
279 if (line.startsWith('|')) block.push(line)
280 else flush()
281 }
282 flush()
283 return out.slice(out[0] === '' ? 1 : 0)
284}
285
286// How long ago, coarse: 刚刚 / 12 分钟前 / 3 小时前 / 2 天前.
287export function age(ms: number): string {
288 const min = Math.floor(ms / 60_000)
289 if (min < 2) return '刚刚'
290 if (min < 60) return `${min} 分钟前`
291 if (min < 48 * 60) return `${Math.floor(min / 60)} 小时前`
292 return `${Math.floor(min / 1440)} 天前`
293}
294
295export type Tally = { name: string; done: number; running: number; planned: number }
296
297const DONE = /^(已上线|已并 main|Done)/
298const RUNNING_STATUS = /^(进行中|已排|On Track|At Risk|Blocked)/
299const SKIPPED = /^(不做|Void)/
300
301// Roadmap rows counted per `##` layer (per `###` when the file has one layer): done / running / planned.
302export function roadmapLayers(md: string): Tally[] {
303 const by = (level: 2 | 3): Tally[] => {
304 const out: Tally[] = []
305 let cur: Tally | null = null
306 let block: string[] = []
307 const flush = (): void => {
308 for (const r of tableRows(block, /状态|触发/)) {
309 if (!cur || SKIPPED.test(r.status) || r.name.startsWith('~~')) continue
310 if (DONE.test(r.status)) cur.done++
311 else if (RUNNING_STATUS.test(r.status)) cur.running++
312 else cur.planned++
313 }
314 block = []
315 }
316 for (const line of md.split('\n')) {
317 const h = line.match(/^(#+) (.*)$/)
318 if (h) {
319 flush()
320 if (h[1]!.length === level) {
321 const name = plain(h[2]!).replace(/\s*·\s*/, ' ').split(/\s*[((]/)[0]!.replace(/^\d+\.\s*/, '')
322 out.push((cur = { name, done: 0, running: 0, planned: 0 }))
323 } else if (h[1]!.length < level) cur = null
324 continue
325 }
326 if (line.startsWith('|')) block.push(line)
327 else flush()
328 }
329 flush()
330 return out.filter(t => t.done + t.running + t.planned > 0)
331 }
332 const layers = by(2)
333 return layers.length > 1 ? layers : by(3)
334}
335
336// Display width: CJK and full-width glyphs take two cells.
337export const cellsOf = (s: string): number =>
338 [...s].reduce((n, ch) => n + (/[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩\u{1f300}-\u{1faff}\u{20000}-\u{3fffd}]/u.test(ch) ? 2 : 1), 0)
339
340// Cut or pad to exactly `n` cells.
341export function fitCells(s: string, n: number): string {
342 let out = ''
343 let used = 0
344 const chars = [...s]
345 const total = cellsOf(s)
346 for (const ch of chars) {
347 const w = cellsOf(ch)
348 if (used + w > n - (total > n ? 1 : 0)) break
349 out += ch
350 used += w
351 }
352 if (total > n) {
353 out += '…'
354 used += 1
355 }
356 return out + ' '.repeat(Math.max(0, n - used))
357}
358
359// A bar of `width` cells split done / running / planned, each at least one cell when non-zero.
360export function barCells(t: Omit<Tally, 'name'>, width: number): { done: number; running: number; planned: number } {
361 const total = t.done + t.running + t.planned
362 if (!total) return { done: 0, running: 0, planned: width }
363 let done = Math.round((t.done / total) * width)
364 let running = t.running ? Math.max(1, Math.round((t.running / total) * width)) : 0
365 if (t.done && !done) done = 1
366 if (done + running > width) done = width - running
367 return { done, running, planned: width - done - running }
368}
369types/index.d.ts 25 lines1// A section of the board, as presets and a repo's `.claude/board.json` (`{ "sections": [...] }`) write it.
2// `file` is relative to the repo root; a `*` path segment picks the highest-versioned match (10.11 > 10.9).
3// Regexes are strings, matched per line.
4export type BoardSection =
5 | { kind: 'list'; label: string; file: string; heading: string; after?: string; stamp?: string; countOnly?: boolean; max?: number }
6 | { kind: 'checklist'; label: string; file: string; group: '##' | '###'; titles?: boolean }
7 | { kind: 'table'; label: string; file: string; heading?: string; show: string; count?: string; alert?: string }
8 | { kind: 'pending'; label: string; file: string }
9
10export type PaneTally = { name: string; done: number; running: number; planned: number }
11export type PaneModel = {
12 title: string
13 goal: { tag: string; groups: PaneTally[] } | null
14 running: Array<{ title: string; runs: string }>
15 blocks: Array<{ key: string; summary: string; lines: string[] }>
16 roadmap: PaneTally[]
17}
18
19// The sidebar's model (the current repo's board), refreshed on open, each turn and every minute.
20declare module 'claude-code' {
21 interface PluginState {
22 board: { model: PaneModel | null; at: number; open: Record<string, boolean> }
23 }
24}
25