Side pane for Claude Code: the session's goal, the superpowers plan's progress, tasks and running subagents

A Claude Code mod that draws a usage HUD above the prompt. Two ASCII dogs keep an eye on your limits.
Claude Code 프롬프트 위에 사용량 HUD를 그려주는 mod입니다. 강아지 두 마리가 한도를 지켜봅니다.
__ 5h ████░░░░ 13% / \__ 7d █████░░░ 64% ctx ██░░░░░░ 23% 226k/1.0M
o-''|\_____/) ↻ 40m ( @\___ ↻ 17h40m last 1m54s · tools 13 +$0.80 2.2M↑ 10k↓
\_/|_) ) “공 물어올게” / O “장부 정리 중” cache 99%
\ __ / / (_____/ Opus 5.5 · medium AUTO $7.06 ⎇ main ✓
(_/ (_/ /_____/ U
Claude Code 터미널 세션의 프롬프트에서 다음을 입력합니다.
/plugin install usage-hud --marketplace Jang-seungminn/usage-hud
Add marketplace?에 y를 입력하고, scope를 고릅니다. 모든 세션에서 쓰려면 user scope를 선택합니다. 설치한 세션에서 바로 동작합니다.
같은 marketplace에 다른 mod도 있습니다. 필요한 것만 같은 방식으로 설치합니다.
/plugin install plan-board --marketplace Jang-seungminn/usage-hud
| mod | 하는 일 |
|---|---|
| plan-board | 사이드 패널: 목표, superpowers 플랜 진행률, 작업, 서브에이전트, 핀, 다음 단계, 인수인계 |
| auto-handoff | /handoff로 대화를 .claude/handoff/HANDOFF.md에 정리, 새 세션에서 /pickup으로 이어받기, /note로 메모. 컨텍스트 70%·90%에서 알림 |
| pinboard | /pin <결정>, /pin 할일: <내용>, /unpin <번호>, /pins. 핀은 .claude/pinboard.md에 저장되고 매 요청의 시스템 프롬프트에 실림 |
| next-steps | 작업을 끝낸 응답 끝에 다음 단계 체크박스 2~3개를 제안. /next로 목록, /next <번호>로 입력창에 넣기 |
| prompt-coach | /coach <요청>(또는 입력창의 내용)을 대상·완료 기준·제약이 분명한 요청으로 다듬어 입력창에 넣기. 실행 전 불명확하면 질문하라는 규칙 포함 |
| korean-ui | 한국어 응답 규칙, 기본 슬래시 명령 설명을 한국어로 |
auto-handoff, pinboard, next-steps, prompt-coach, korean-ui는 시스템 프롬프트에 짧은 규칙을 하나씩 덧붙입니다. 쓰지 않는 것은 설치하지 않는 편이 요청당 토큰을 아낍니다.
| 항목 | 내용 |
|---|---|
| 5h / 7d | 사용 한도 게이지, 리셋까지 남은 시간, 리셋 전에 한도가 바닥날 것 같으면 ⚡N 후 소진 |
| ctx | context window 사용량 |
| 턴 | 진행 중에는 경과 시간과 도구 호출 수, 끝난 뒤에는 그 턴의 비용과 입출력 토큰 |
| cache | 마지막 응답의 prompt cache 적중률 |
| 세션 | 모델 · effort, 권한 모드 배지, 누적 비용 |
| git | 브랜치, ahead/behind, staged/수정/추적 안 된 파일 수 |
| agents / bg | 실행 중인 서브에이전트와 백그라운드 작업 수 |
알림(toast):
수치에 따라 상태가 바뀝니다.
| 상태 | 조건 |
|---|---|
| 여유 | 기본 |
| 작업 중 | Claude가 일하는 동안 |
| 완료 | 턴이 끝나고 20초 동안 |
| 진땀 | 50% 이상 |
| 패닉 | 80% 이상 |
| 소진 | 100% |
| 수면 | 10분 넘게 쉬는 중 |
/hud를 입력하면 강아지를 끄고 켭니다. 강아지는 band가 5줄을 쓸 수 있을 때만 나오고, 터미널이 130칸 이상이면 모든 정보가 한 줄로 나란히 배치됩니다.
사이드 패널(fullscreen에서는 transcript 옆, 아니면 프롬프트 위)에 이 세션이 무엇을 하려는지와 어디까지 왔는지를 보여줍니다. 보여줄 것이 처음 생길 때 패널이 한 번 열리고, 닫은 뒤에는 /board로 다시 엽니다.
◎ 목표
GitHub 로그인 붙이기
plan Users can sign in with GitHub.
/goal bun test exits 0
▤ 플랜 2026-10-11-auth.md
plan Auth Feature Implementation Plan
task ██████░░░░░░ 1/3
step ██████████░░ 3/5
▶ Task 2: Session cookie
✓ Step 1: Write the failing test
○ Step 2: Implement setCookie
○ Task 3: Logout
☰ 작업 ████░░░░ 1/2
▶ Writing plan
✓ Write spec
⚙ 에이전트 실행 1 · 완료 2
● Explore auth code [Explore] 1m05s
✓ Review spec 42s
| 영역 | 어디서 오나 |
|---|---|
| 목표 | /mission <목표>로 정한 목표. 없으면 세션의 첫 프롬프트. 플랜 파일의 Goal: 줄, 내장 /goal(또는 ProposeGoal)의 완료 조건도 함께 |
| 플랜 | …/plans/*.md 파일을 Write/Edit 하면 읽어서, 체크박스가 있는 제목(### Task N: …)을 작업으로, 체크박스를 단계로 셉니다. 매 턴이 끝날 때 다시 읽으므로 sed 등으로 체크해도 반영됩니다. superpowers의 docs/superpowers/plans/, docs/plans/ 모두 해당 |
| spec | …/specs/*.md 또는 *-design.md를 쓰면 제목을 표시 |
| 작업 / 할 일 | TaskCreate·TaskUpdate·TaskList, TodoWrite. 진행 중 → 대기 → 완료 순으로, 완료는 최근 3개만 |
| 에이전트 | Agent 호출과 SubagentStart/Stop. 실행 중인 것은 경과 시간, 끝난 것은 최근 3개 |
| 핀 | pinboard의 .claude/pinboard.md (10초마다, 턴이 끝날 때 다시 읽음) |
| 다음 단계 | 마지막 응답의 다음 단계 아래 체크박스 (next-steps가 제안하게 함) |
| 인수인계 | auto-handoff의 .claude/handoff/HANDOFF.md 작성 시각과 NOTES.md 메모 수 |
핀·다음 단계·인수인계는 해당 mod가 없어도 파일이나 응답 형식만 맞으면 표시됩니다.
명령:
/board 패널 열기, /board clear 작업·플랜·에이전트 목록 비우기/mission <목표> 목표 정하기, /mission 현재 목표 보기, /mission clear 지우기각 mod는 plugins/<이름>/에 있고, 저장소 루트는 marketplace입니다.
claude --plugin-dir ./plugins/usage-hud --plugin-dir ./plugins/plan-board
claude plugin validate . # marketplace
for m in plugins/*; do claude plugin validate $m && claude plugin test $m; done
MIT
hooks/register.tsx 486 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BoardAgent, BoardGoal, BoardHandoff, BoardPin, BoardPlan, BoardSpec, BoardTask } from '../types'
5import {
6 bar, baseName, countBullets, createTask, formatElapsed, fromTodos, isPlanPath, isSpecPath, oneLine, parseNextSteps, parsePins, parsePlan,
7 planProgress, sortTasks, titleOf, updateTask, upsertAgent,
8} from './lib'
9
10const PANE = 'plan-board'
11const TITLE = 'Plan'
12
13const goal = atom({ plugin: 'plan-board', key: 'goal' } as const, {} as BoardGoal)
14const tasks = atom({ plugin: 'plan-board', key: 'tasks' } as const, [] as BoardTask[])
15const todos = atom({ plugin: 'plan-board', key: 'todos' } as const, [] as BoardTask[])
16const plan = atom({ plugin: 'plan-board', key: 'plan' } as const, null as BoardPlan | null)
17const spec = atom({ plugin: 'plan-board', key: 'spec' } as const, null as BoardSpec | null)
18const agents = atom({ plugin: 'plan-board', key: 'agents' } as const, [] as BoardAgent[])
19const now = atom({ plugin: 'plan-board', key: 'now' } as const, 0)
20const isOpened = atom({ plugin: 'plan-board', key: 'isOpened' } as const, false)
21const pins = atom({ plugin: 'plan-board', key: 'pins' } as const, [] as BoardPin[])
22const nextSteps = atom({ plugin: 'plan-board', key: 'nextSteps' } as const, [] as string[])
23const handoff = atom({ plugin: 'plan-board', key: 'handoff' } as const, { writtenAt: null, notes: 0 } as BoardHandoff)
24
25const SHOW_DONE = 3
26const SHOW_NEXT_TASKS = 4
27const SHOW_DONE_AGENTS = 3
28// /pin, /note 는 다른 mod 의 명령이라 끝나는 순간을 모른다: 파일을 이 간격으로 다시 읽는다.
29const DOCS_EVERY_MS = 10_000
30
31let ticker: { cancel: () => void } | undefined
32let docsTicker: { cancel: () => void } | undefined
33
34// Opened once, unasked, the first time there is something to show.
35async function openOnce($: EngineInterface): Promise<void> {
36 if (await read($, isOpened)) return
37 await update($, isOpened, () => true)
38 void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
39}
40
41async function loadPlan($: EngineInterface, path: string): Promise<void> {
42 try {
43 const text = await $.fs.read(path)
44 await update($, plan, () => parsePlan(path, text))
45 await openOnce($)
46 } catch {
47 // Moved or deleted: keep the last parse.
48 }
49}
50
51async function loadSpec($: EngineInterface, path: string): Promise<void> {
52 try {
53 const text = await $.fs.read(path)
54 await update($, spec, () => ({ path, title: titleOf(text, path) }))
55 await openOnce($)
56 } catch {
57 // ignore
58 }
59}
60
61async function readOr($: EngineInterface, path: string): Promise<string> {
62 try {
63 return await $.fs.read(path)
64 } catch {
65 return ''
66 }
67}
68
69const sameJson = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
70
71// pinboard 의 핀과 auto-handoff 의 인수인계서·메모. 없으면 비어 있을 뿐이다.
72async function refreshDocs($: EngineInterface): Promise<void> {
73 const base = `${await $.session.root()}/.claude`
74 const found = parsePins(await readOr($, `${base}/pinboard.md`))
75 const stat = await $.fs.stat(`${base}/handoff/HANDOFF.md`).catch(() => undefined)
76 const state: BoardHandoff = {
77 writtenAt: stat?.kind === 'file' ? stat.mtimeMs : null,
78 notes: countBullets(await readOr($, `${base}/handoff/NOTES.md`)),
79 }
80 await update($, pins, old => (sameJson(old, found) ? old : found))
81 await update($, handoff, old => (sameJson(old, state) ? old : state))
82}
83
84async function tick($: EngineInterface): Promise<void> {
85 const list = await read($, agents)
86 if (!list.some(a => a.status === 'running')) return
87 const at = await $.clock.now()
88 await update($, now, () => at)
89}
90
91async function reset($: EngineInterface): Promise<void> {
92 await update($, tasks, () => [])
93 await update($, todos, () => [])
94 await update($, plan, () => null)
95 await update($, spec, () => null)
96 await update($, agents, () => [])
97 await update($, nextSteps, () => [])
98}
99
100export const register: Register = on => {
101 on('session.start', async ($, e, next) => {
102 const result = await next(e)
103 await $.command.register({ name: 'board', description: 'plan-board: 진행 패널 열기 (/board clear 로 초기화)' })
104 await $.command.register({ name: 'mission', description: 'plan-board: 이 세션의 목표 정하기 (/mission <목표>, /mission clear)' })
105 await update($, now, () => 0)
106 ticker?.cancel()
107 ticker = $.clock.every(1000, () => tick($))
108 await refreshDocs($).catch(() => undefined)
109 docsTicker?.cancel()
110 docsTicker = $.clock.every(DOCS_EVERY_MS, () => refreshDocs($).catch(() => undefined))
111 // A reload keeps the state: show the pane again if it had content.
112 const p = await read($, plan)
113 if (p) await loadPlan($, p.path)
114 return result
115 })
116
117 on('session.end', async ($, e, next) => {
118 if (e.reason === 'clear') {
119 await reset($)
120 await update($, goal, () => ({}))
121 }
122 return next(e)
123 })
124
125 on('command.run', { command: 'board' }, async ($, e) => {
126 if (e.args.trim() === 'clear') {
127 await reset($)
128 return { text: 'plan-board: 작업·플랜·에이전트 목록을 비웠습니다. 목표는 /mission clear 로 지웁니다.' }
129 }
130 await update($, isOpened, () => true)
131 const r = await $.ui.open({ id: PANE, title: TITLE })
132 return { text: r.isPlaced ? 'plan-board 패널을 열었습니다.' : 'plan-board: 터미널을 넓히면 패널이 보입니다.' }
133 })
134
135 on('command.run', { command: 'mission' }, async ($, e) => {
136 const text = e.args.trim()
137 if (!text) {
138 const g = await read($, goal)
139 return { text: g.mission ? `목표: ${g.mission.text}` : '목표가 없습니다. /mission <목표> 로 정하세요.' }
140 }
141 if (text === 'clear') {
142 await update($, goal, g => ({ ...g, mission: undefined }))
143 return { text: '목표를 지웠습니다.' }
144 }
145 await update($, goal, g => ({ ...g, mission: { text: oneLine(text, 300), isFromPrompt: false } }))
146 await openOnce($)
147 return { text: `목표: ${oneLine(text, 300)}` }
148 })
149
150 // The built-in /goal sets a completion condition; mirror it.
151 on('command.run', { command: 'goal' }, async ($, e, next) => {
152 const result = await next(e)
153 const args = e.args.trim()
154 if (args === 'clear') await update($, goal, g => ({ ...g, condition: undefined }))
155 else if (args) await update($, goal, g => ({ ...g, condition: oneLine(args, 300) }))
156 return result
157 }).catch(($, e, next) => next(e))
158
159 // Until /mission names one, the session's first real prompt is its goal.
160 on('classic.UserPromptSubmit', async ($, e, next) => {
161 const text = e.prompt.trim()
162 if (text && !text.startsWith('/') && (e.source === undefined || e.source === 'user')) {
163 await update($, goal, g => (g.mission ? g : { ...g, mission: { text: oneLine(text), isFromPrompt: true } }))
164 }
165 return next(e)
166 }).catch(($, e, next) => next(e))
167
168 on('tool.call', async ($, e, next) => {
169 if (e.tool === 'Agent') {
170 const at = await $.clock.now()
171 const placeholder = `call:${e.tool_use_id}`
172 await update($, agents, list =>
173 upsertAgent(list, placeholder, { description: oneLine(e.description, 60), type: e.subagent_type, status: 'running' }, at),
174 )
175 await openOnce($)
176 const r = await next(e)
177 const agentId = !('deny' in r && r.deny) && !r.isError ? (r.result as { agentId?: string } | undefined)?.agentId : undefined
178 const ended = await $.clock.now()
179 await update($, agents, list => {
180 const rest = list.filter(a => a.id !== placeholder)
181 if (!agentId) return upsertAgent(rest, placeholder, { description: oneLine(e.description, 60), status: 'done', endedAt: ended }, at)
182 const isForeground = e.run_in_background === false
183 return upsertAgent(
184 rest,
185 agentId,
186 {
187 description: oneLine(e.description, 60),
188 type: e.subagent_type,
189 ...(isForeground ? { status: 'done' as const, endedAt: ended } : {}),
190 },
191 at,
192 )
193 })
194 return r
195 }
196
197 const r = await next(e)
198 if ('deny' in r && r.deny) return r
199 if (r.isError) return r
200
201 if (e.tool === 'TaskCreate') {
202 const id = (r.result as { task?: { id: string } } | undefined)?.task?.id
203 if (id) {
204 await update($, tasks, list => createTask(list, id, e.subject, e.activeForm))
205 await openOnce($)
206 }
207 } else if (e.tool === 'TaskUpdate') {
208 await update($, tasks, list =>
209 updateTask(list, { taskId: e.taskId, subject: e.subject, activeForm: e.activeForm, owner: e.owner, status: e.status }),
210 )
211 } else if (e.tool === 'TaskList') {
212 // The list is the truth: take its statuses and subjects.
213 const listed = (r.result as { tasks?: { id: string; subject: string; status: BoardTask['status']; owner?: string }[] } | undefined)?.tasks
214 if (listed) {
215 await update($, tasks, list =>
216 listed.map(t => ({ ...list.find(o => o.id === t.id), id: t.id, subject: oneLine(t.subject), status: t.status, owner: t.owner })),
217 )
218 }
219 } else if (e.tool === 'TodoWrite') {
220 await update($, todos, () => fromTodos(e.todos))
221 await openOnce($)
222 } else if (e.tool === 'ProposeGoal') {
223 const condition = (r.result as { condition?: string } | undefined)?.condition
224 if (condition) await update($, goal, g => ({ ...g, condition: oneLine(condition, 300) }))
225 } else if (e.tool === 'Write' || e.tool === 'Edit') {
226 if (isPlanPath(e.file_path)) await loadPlan($, e.file_path)
227 else if (isSpecPath(e.file_path)) await loadSpec($, e.file_path)
228 }
229 return r
230 }).catch(($, e, next) => next(e))
231
232 on('turn.complete', async ($, e, next) => {
233 const done = await next(e)
234 if (e.agentId === undefined && e.reason === 'answer') {
235 const found = parseNextSteps(e.answer)
236 await update($, nextSteps, () => found)
237 if (found.length > 0) await openOnce($)
238 await refreshDocs($).catch(() => undefined)
239 }
240 return done
241 }).catch(($, e, next) => next(e))
242
243 on('classic.SubagentStart', async ($, e, next) => {
244 const at = await $.clock.now()
245 await update($, agents, list => upsertAgent(list, e.agent_id, { type: e.agent_type, status: 'running' }, at))
246 return next(e)
247 }).catch(($, e, next) => next(e))
248
249 on('classic.SubagentStop', async ($, e, next) => {
250 const at = await $.clock.now()
251 await update($, agents, list =>
252 list.some(a => a.id === e.agent_id) ? upsertAgent(list, e.agent_id, { status: 'done', endedAt: at }, at) : list,
253 )
254 return next(e)
255 }).catch(($, e, next) => next(e))
256
257 // The main loop's Stop lists what still runs: whatever is not listed is done.
258 on('classic.Stop', async ($, e, next) => {
259 const live = (e.background_tasks ?? []).filter(t => t.type === 'subagent' || t.agent_type !== undefined)
260 const at = await $.clock.now()
261 await update($, agents, list => {
262 let out = list.map(a =>
263 a.status === 'running' && !live.some(t => t.id === a.id) ? { ...a, status: 'done' as const, endedAt: at } : a,
264 )
265 for (const t of live) {
266 out = upsertAgent(out, t.id, { description: oneLine(t.description || t.name || 'agent', 60), type: t.agent_type, status: 'running' }, at)
267 }
268 return out
269 })
270 // Plans are also ticked off by sed or a script: re-read at each turn's end.
271 const p = await read($, plan)
272 if (p) await loadPlan($, p.path)
273 return next(e)
274 }).catch(($, e, next) => next(e))
275
276 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
277 const { Box, Text } = $.ui.resolve(e)
278 const g = await read($, goal)
279 const p = await read($, plan)
280 const s = await read($, spec)
281 const taskList = await read($, tasks)
282 const todoList = await read($, todos)
283 const agentList = await read($, agents)
284 const pinList = await read($, pins)
285 const steps = await read($, nextSteps)
286 const ho = await read($, handoff)
287 // Read now so a redraw the ticker asked for is tracked; draw with the real time.
288 await read($, now)
289 const at = await $.clock.now()
290 const width = Math.max(20, e.props.bodyColumns)
291 const barWidth = Math.max(6, Math.min(20, width - 16))
292
293 const sections = []
294
295 const heading = (key: string, label: string, extra?: string) => (
296 <Text key={key} bold color="claude" wrap="truncate">
297 {label}
298 {extra ? <Text dimColor> {extra}</Text> : null}
299 </Text>
300 )
301
302 const progress = (done: number, total: number) => {
303 const [filled, empty] = bar(done, total, barWidth)
304 const isAll = total > 0 && done === total
305 return (
306 <Text wrap="truncate">
307 <Text color={isAll ? 'success' : 'claude'}>{'█'.repeat(filled)}</Text>
308 <Text color="subtle">{'░'.repeat(empty)}</Text>
309 <Text bold> {done}/{total}</Text>
310 </Text>
311 )
312 }
313
314 // ── goal ──
315 const goalLines = []
316 if (g.mission) {
317 goalLines.push(
318 <Text key="mission" wrap="wrap">
319 {g.mission.text}
320 {g.mission.isFromPrompt ? <Text dimColor> (첫 프롬프트)</Text> : null}
321 </Text>,
322 )
323 }
324 if (p?.goal && p.goal !== g.mission?.text) goalLines.push(<Text key="plan-goal" wrap="wrap"><Text dimColor>plan </Text>{p.goal}</Text>)
325 if (g.condition) goalLines.push(<Text key="cond" wrap="wrap"><Text color="success">/goal </Text>{g.condition}</Text>)
326 if (goalLines.length > 0) {
327 sections.push(
328 <Box key="goal" flexDirection="column">
329 {heading('h-goal', '◎ 목표')}
330 {goalLines}
331 </Box>,
332 )
333 }
334
335 // ── pinboard ──
336 if (pinList.length > 0) {
337 sections.push(
338 <Box key="pins" flexDirection="column">
339 {heading('h-pins', '⚑ 핀', `${pinList.length}`)}
340 {pinList.map((pin, i) => (
341 <Text key={`pin${i}`} wrap="truncate">
342 <Text color={pin.kind === '할일' ? 'warning' : 'suggestion'}>[{pin.kind}]</Text> {pin.text}
343 </Text>
344 ))}
345 </Box>,
346 )
347 }
348
349 // ── superpowers spec and plan ──
350 if (p || s) {
351 const lines = []
352 if (s) lines.push(<Text key="spec" dimColor wrap="truncate">spec {s.title}</Text>)
353 if (p) {
354 const pr = planProgress(p)
355 lines.push(<Text key="title" wrap="truncate"><Text dimColor>plan </Text>{p.title}</Text>)
356 if (pr.tasks > 0) {
357 lines.push(<Box key="bar" flexDirection="row"><Text dimColor>task </Text>{progress(pr.doneTasks, pr.tasks)}</Box>)
358 }
359 if (pr.steps > 0) {
360 lines.push(<Box key="sbar" flexDirection="row"><Text dimColor>step </Text>{progress(pr.doneSteps, pr.steps)}</Box>)
361 }
362 if (pr.current) {
363 const cur = pr.current
364 lines.push(<Text key="cur" bold color="warning" wrap="truncate">▶ {cur.title}</Text>)
365 cur.steps.forEach((step, i) =>
366 lines.push(
367 <Text key={`cs${i}`} wrap="truncate" dimColor={step.isDone}>
368 {' '}{step.isDone ? '✓' : '○'} {step.text}
369 </Text>,
370 ),
371 )
372 const after = p.tasks.slice(p.tasks.indexOf(cur) + 1).filter(t => t.steps.some(st => !st.isDone))
373 after.slice(0, SHOW_NEXT_TASKS).forEach((t, i) =>
374 lines.push(<Text key={`nt${i}`} wrap="truncate">○ {t.title}</Text>),
375 )
376 if (after.length > SHOW_NEXT_TASKS) lines.push(<Text key="more" dimColor> … 외 {after.length - SHOW_NEXT_TASKS}개</Text>)
377 } else if (pr.steps > 0) {
378 lines.push(<Text key="all" color="success">✓ 플랜의 모든 단계 완료</Text>)
379 }
380 }
381 sections.push(
382 <Box key="plan" flexDirection="column">
383 {heading('h-plan', '▤ 플랜', p ? baseName(p.path) : undefined)}
384 {lines}
385 </Box>,
386 )
387 }
388
389 // ── tasks (TaskCreate/TaskUpdate) and todos (TodoWrite) ──
390 const taskSection = (key: string, label: string, list: BoardTask[]) => {
391 if (list.length === 0) return null
392 const sorted = sortTasks(list)
393 const done = sorted.filter(t => t.status === 'completed')
394 const open = sorted.filter(t => t.status !== 'completed')
395 return (
396 <Box key={key} flexDirection="column">
397 <Box flexDirection="row" columnGap={1}>
398 {heading(`h-${key}`, label)}
399 {progress(done.length, list.length)}
400 </Box>
401 {open.map(t =>
402 t.status === 'in_progress' ? (
403 <Text key={t.id} bold color="warning" wrap="truncate">▶ {t.activeForm || t.subject}{t.owner ? <Text dimColor> @{t.owner}</Text> : null}</Text>
404 ) : (
405 <Text key={t.id} wrap="truncate">○ {t.subject}{t.owner ? <Text dimColor> @{t.owner}</Text> : null}</Text>
406 ),
407 )}
408 {done.slice(-SHOW_DONE).map(t => (
409 <Text key={t.id} dimColor wrap="truncate">✓ {t.subject}</Text>
410 ))}
411 {done.length > SHOW_DONE ? <Text dimColor> … 완료 {done.length - SHOW_DONE}개 더</Text> : null}
412 </Box>
413 )
414 }
415 const ts = taskSection('tasks', '☰ 작업', taskList)
416 if (ts) sections.push(ts)
417 const td = taskSection('todos', '☰ 할 일', todoList)
418 if (td) sections.push(td)
419
420 // ── subagents ──
421 if (agentList.length > 0) {
422 const running = agentList.filter(a => a.status === 'running')
423 const finished = agentList.filter(a => a.status === 'done')
424 sections.push(
425 <Box key="agents" flexDirection="column">
426 {heading('h-agents', '⚙ 에이전트', `실행 ${running.length} · 완료 ${finished.length}`)}
427 {running.map(a => (
428 <Text key={a.id} wrap="truncate">
429 <Text color="claude">● </Text>
430 {a.description}
431 {a.type ? <Text dimColor> [{a.type}]</Text> : null}
432 <Text dimColor> {formatElapsed(at - a.startedAt)}</Text>
433 </Text>
434 ))}
435 {finished.slice(-SHOW_DONE_AGENTS).map(a => (
436 <Text key={a.id} dimColor wrap="truncate">
437 ✓ {a.description}
438 {a.endedAt ? ` ${formatElapsed(a.endedAt - a.startedAt)}` : ''}
439 </Text>
440 ))}
441 </Box>,
442 )
443 }
444
445 // ── 마지막 응답이 제안한 다음 단계 ──
446 if (steps.length > 0) {
447 sections.push(
448 <Box key="next" flexDirection="column">
449 {heading('h-next', '→ 다음 단계', '/next <번호>')}
450 {steps.map((one, i) => (
451 <Text key={`next${i}`} wrap="truncate">{i + 1}. {one}</Text>
452 ))}
453 </Box>,
454 )
455 }
456
457 // ── auto-handoff ──
458 if (ho.writtenAt !== null || ho.notes > 0) {
459 sections.push(
460 <Box key="handoff" flexDirection="column">
461 {heading('h-handoff', '⇄ 인수인계')}
462 <Text wrap="truncate" dimColor={ho.writtenAt === null}>
463 {ho.writtenAt === null ? '인수인계서 없음 · /handoff' : `인수인계서 ${formatElapsed(at - ho.writtenAt)} 전 · /pickup`}
464 </Text>
465 {ho.notes > 0 ? <Text dimColor>메모 {ho.notes}개 · /note</Text> : null}
466 </Box>,
467 )
468 }
469
470 if (sections.length === 0) {
471 return (
472 <Box flexDirection="column">
473 <Text dimColor wrap="wrap">아직 계획이 없습니다.</Text>
474 <Text dimColor wrap="wrap">{'/mission <목표>'} 로 목표를 정하거나, 플랜·작업·서브에이전트가 생기면 여기에 나타납니다.</Text>
475 </Box>
476 )
477 }
478
479 return (
480 <Box flexDirection="column" rowGap={1}>
481 {sections}
482 </Box>
483 )
484 })
485}
486hooks/lib.ts 178 lines1import type { BoardAgent, BoardPin, BoardPlan, BoardTask, PlanStep, PlanTask, TaskStatus } from '../types'
2
3// superpowers writes docs/superpowers/plans/<date>-<feature>.md and
4// docs/superpowers/specs/<date>-<topic>-design.md; older versions docs/plans/.
5export function isPlanPath(path: string): boolean {
6 return /\.md$/i.test(path) && /(^|[\\/])plans?[\\/]/i.test(path)
7}
8
9export function isSpecPath(path: string): boolean {
10 return /\.md$/i.test(path) && (/(^|[\\/])specs?[\\/]/i.test(path) || /-design\.md$/i.test(path))
11}
12
13export function baseName(path: string): string {
14 return path.split(/[\\/]/).pop() ?? path
15}
16
17// Markdown emphasis and inline code read as plain text in a narrow pane.
18export function plain(text: string): string {
19 return text.replace(/\*\*|__|`/g, '').replace(/\s+/g, ' ').trim()
20}
21
22export function oneLine(text: string, max = 160): string {
23 const line = plain(text.split('\n').find(l => l.trim()) ?? '')
24 return line.length > max ? `${line.slice(0, max - 1)}…` : line
25}
26
27export function titleOf(text: string, path: string): string {
28 const h1 = text.match(/^#\s+(.+)$/m)
29 return h1 ? plain(h1[1]!) : baseName(path)
30}
31
32const CHECKBOX = /^\s*[-*+]\s+\[([ xX])\]\s+(.*)$/
33const HEADING = /^(#{2,4})\s+(.+)$/
34
35// Each heading that holds checkboxes is one task; its checkboxes are the steps.
36export function parsePlan(path: string, text: string): BoardPlan {
37 const tasks: PlanTask[] = []
38 const loose: PlanStep[] = []
39 let current: PlanTask | undefined
40 let isFenced = false
41
42 for (const line of text.split('\n')) {
43 if (/^\s*(```|~~~)/.test(line)) isFenced = !isFenced
44 if (isFenced) continue
45 const heading = line.match(HEADING)
46 if (heading) {
47 current = { title: plain(heading[2]!), steps: [] }
48 tasks.push(current)
49 continue
50 }
51 const box = line.match(CHECKBOX)
52 if (box) (current ? current.steps : loose).push({ text: plain(box[2]!), isDone: box[1] !== ' ' })
53 }
54
55 const goal = text.match(/^\s*\*\*Goal:?\*\*:?\s*(.+)$/im)
56 return {
57 path,
58 title: titleOf(text, path),
59 goal: goal ? plain(goal[1]!) : undefined,
60 tasks: tasks.filter(t => t.steps.length > 0),
61 loose,
62 }
63}
64
65export type PlanProgress = { doneSteps: number; steps: number; doneTasks: number; tasks: number; current?: PlanTask }
66
67export function planProgress(plan: BoardPlan): PlanProgress {
68 const all = [...plan.tasks.flatMap(t => t.steps), ...plan.loose]
69 const doneTasks = plan.tasks.filter(t => t.steps.every(s => s.isDone)).length
70 return {
71 doneSteps: all.filter(s => s.isDone).length,
72 steps: all.length,
73 doneTasks,
74 tasks: plan.tasks.length,
75 current: plan.tasks.find(t => t.steps.some(s => !s.isDone)),
76 }
77}
78
79// ── TaskCreate / TaskUpdate / TodoWrite ──
80
81export function createTask(list: BoardTask[], id: string, subject: string, activeForm?: string): BoardTask[] {
82 if (list.some(t => t.id === id)) return list
83 return [...list, { id, subject: oneLine(subject), status: 'pending', activeForm }]
84}
85
86export type TaskPatch = {
87 taskId: string
88 subject?: string
89 activeForm?: string
90 owner?: string
91 status?: TaskStatus | 'deleted'
92}
93
94export function updateTask(list: BoardTask[], patch: TaskPatch): BoardTask[] {
95 const status = patch.status
96 if (status === 'deleted') return list.filter(t => t.id !== patch.taskId)
97 const has = list.some(t => t.id === patch.taskId)
98 const base: BoardTask[] = has ? list : [...list, { id: patch.taskId, subject: patch.subject ?? `#${patch.taskId}`, status: 'pending' }]
99 return base.map(t =>
100 t.id !== patch.taskId
101 ? t
102 : {
103 ...t,
104 subject: patch.subject === undefined ? t.subject : oneLine(patch.subject),
105 activeForm: patch.activeForm ?? t.activeForm,
106 owner: patch.owner ?? t.owner,
107 status: status ?? t.status,
108 },
109 )
110}
111
112export function fromTodos(todos: readonly { content: string; status: TaskStatus; activeForm?: string }[]): BoardTask[] {
113 return todos.map((t, i) => ({ id: `todo${i}`, subject: oneLine(t.content), status: t.status, activeForm: t.activeForm }))
114}
115
116const ORDER: Record<TaskStatus, number> = { in_progress: 0, pending: 1, completed: 2 }
117
118// In progress first, then pending, then done; stable within each.
119export function sortTasks(list: readonly BoardTask[]): BoardTask[] {
120 return list.map((t, i) => [t, i] as const).sort((a, b) => ORDER[a[0].status] - ORDER[b[0].status] || a[1] - b[1]).map(([t]) => t)
121}
122
123// ── subagents ──
124
125export function upsertAgent(list: BoardAgent[], id: string, patch: Partial<BoardAgent>, at: number): BoardAgent[] {
126 const found = list.find(a => a.id === id)
127 const next: BoardAgent = found
128 ? { ...found, ...stripUndefined(patch) }
129 : { id, description: patch.type ?? 'agent', status: 'running', startedAt: at, ...stripUndefined(patch) }
130 return [...list.filter(a => a.id !== id), next].sort((a, b) => a.startedAt - b.startedAt).slice(-30)
131}
132
133function stripUndefined<T extends object>(o: T): Partial<T> {
134 return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as Partial<T>
135}
136
137export function formatElapsed(ms: number): string {
138 const s = Math.max(0, Math.round(ms / 1000))
139 if (s < 60) return `${s}s`
140 const m = Math.floor(s / 60)
141 if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
142 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
143}
144
145export function bar(done: number, total: number, width: number): [number, number] {
146 if (total <= 0) return [0, width]
147 const filled = Math.min(width, Math.round((done / total) * width))
148 return [filled, width - filled]
149}
150
151// ── pinboard, next-steps, auto-handoff 가 남기는 것 ──
152
153const PIN = /^- \[(결정|할일)\] \d{4}-\d{2}-\d{2} (.+)$/
154
155export function parsePins(text: string): BoardPin[] {
156 return text.split('\n').flatMap(line => {
157 const found = PIN.exec(line.trim())
158 return found ? [{ kind: found[1] as BoardPin['kind'], text: found[2]!.trim() }] : []
159 })
160}
161
162// 응답의 마지막 "다음 단계" 아래에 있는 열린 체크박스.
163export function parseNextSteps(answer: string): string[] {
164 const at = answer.lastIndexOf('다음 단계')
165 if (at === -1) return []
166 return answer
167 .slice(at)
168 .split('\n')
169 .map(line => /^\s*[-*]\s*\[ \]\s*(.+)$/.exec(line)?.[1])
170 .filter((one): one is string => one !== undefined && one.trim() !== '')
171 .map(one => plain(one))
172 .slice(0, 5)
173}
174
175export function countBullets(text: string): number {
176 return text.split('\n').filter(line => line.startsWith('- ')).length
177}
178types/index.d.ts 58 lines1export type TaskStatus = 'pending' | 'in_progress' | 'completed'
2
3export type BoardTask = { id: string; subject: string; status: TaskStatus; activeForm?: string; owner?: string }
4
5export type PlanStep = { text: string; isDone: boolean }
6export type PlanTask = { title: string; steps: PlanStep[] }
7
8export type BoardPlan = {
9 path: string
10 title: string
11 goal?: string
12 tasks: PlanTask[]
13 // Checkboxes outside any task heading.
14 loose: PlanStep[]
15}
16
17export type BoardSpec = { path: string; title: string }
18
19export type BoardAgent = {
20 id: string
21 description: string
22 type?: string
23 status: 'running' | 'done'
24 startedAt: number
25 endedAt?: number
26}
27
28export type BoardGoal = {
29 // Set with /mission, or the session's first prompt until then.
30 mission?: { text: string; isFromPrompt: boolean }
31 // The built-in /goal completion condition.
32 condition?: string
33}
34
35// pinboard 의 .claude/pinboard.md 한 줄: - [결정|할일] YYYY-MM-DD 내용
36export type BoardPin = { kind: '결정' | '할일'; text: string }
37
38// auto-handoff 의 .claude/handoff/ 상태
39export type BoardHandoff = { writtenAt: number | null; notes: number }
40
41declare module 'claude-code' {
42 interface PluginState {
43 'plan-board': {
44 goal: BoardGoal
45 tasks: BoardTask[]
46 todos: BoardTask[]
47 plan: BoardPlan | null
48 spec: BoardSpec | null
49 agents: BoardAgent[]
50 now: number
51 isOpened: boolean
52 pins: BoardPin[]
53 nextSteps: string[]
54 handoff: BoardHandoff
55 }
56 }
57}
58