SLOPSHOPPER

board

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

newpanecommandprocesstimer
v0.4.0MITupdated 2026-10-10AbyssCN/claude-lead-harness/mods/board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · board
│ ┃ board ✕ › fix the failing auth test and add an audit log call │ ┃ board:这个仓没有可读的进度文件 │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /board │ ⎿ board: board:/work/app 没有可读的进度文件(_NEXT.md / docs/status/NEXT.md │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · board
board:这个仓没有可读的进度文件
README

board(进度板)

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):_NEXT.md · 最新 docs/plan/<版本>/GOAL.md · docs/plan/DECISIONS-PENDING.md · docs/plan/TODO.md · docs/ROADMAP.md。
  • status-next 布局(有 docs/status/NEXT.md):docs/status/NEXT.md · docs/ops/roadmap.md(### 现在 / ### 接下来 状态表,## <n>. 待… 待拍板节)。

别的仓放 .claude/board.json({ "sections": [...] },段的写法见 types/index.d.ts)即可,它优先于预设。

GOAL 进行中标记

- [ ] 〔进行中 <run名>〕项名 …:派出 / 开工时加,合入验收改 [x] 时删,片停了也删。board 据此分:[x] 完成 · 带标记 = 进行中 · 其余 [ ] = 规划中(GOAL 规划范围内未开始);GOAL 外的 TODO / ROADMAP 候选是「未做」。

配置(plugin userConfig)

选项默认说明
reposDir空 = ~/repos/board <仓名>、/board all 在这里找仓;会话在其下时以 <reposDir>/<仓名> 为仓根,否则以 cwd 为仓根

安装

/plugin marketplace add AbyssCN/claude-lead-harness
/plugin install board@claude-lead-harness
Source 3 files
hooks/register.tsx 412 lines
1import { 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}
412
hooks/parse.ts 369 lines
1// 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}
369
types/index.d.ts 25 lines
1// 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