SLOPSHOPPER

plan-board

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

newpaneguardcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plan-board
│ ┃ Plan ✕ › fix the failing auth test and add an audit log call │ ┃ ◎ 목표 │ ┃ fix the failing auth test and add an audit ⏺ Read(src/auth.ts) │ ┃ log call (첫 프롬프트) ⎿ 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 │ ⎿ plan-board: plan-board 패널을 열었습니다. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Plan
◎ 목표 fix the failing auth test and add an audit log call (첫 프롬프트)
README

usage-hud

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 후 소진
ctxcontext window 사용량
턴진행 중에는 경과 시간과 도구 호출 수, 끝난 뒤에는 그 턴의 비용과 입출력 토큰
cache마지막 응답의 prompt cache 적중률
세션모델 · effort, 권한 모드 배지, 누적 비용
git브랜치, ahead/behind, staged/수정/추적 안 된 파일 수
agents / bg실행 중인 서브에이전트와 백그라운드 작업 수

알림(toast):

  • context가 80%를 넘거나 사용 한도가 90%를 넘는 순간
  • 50% 넘게 쓴 한도가 리셋될 때

강아지

  • 5h 강아지(전신): 5시간 한도와 context를 지켜봅니다.
  • 7d 강아지(얼굴): 7일 한도를 지켜봅니다.

수치에 따라 상태가 바뀝니다.

상태조건
여유기본
작업 중Claude가 일하는 동안
완료턴이 끝나고 20초 동안
진땀50% 이상
패닉80% 이상
소진100%
수면10분 넘게 쉬는 중

/hud를 입력하면 강아지를 끄고 켭니다. 강아지는 band가 5줄을 쓸 수 있을 때만 나오고, 터미널이 130칸 이상이면 모든 정보가 한 줄로 나란히 배치됩니다.

참고

  • 사용 한도(5h/7d)는 Claude 구독 계정에서만 표시됩니다.
  • 권한 모드 배지는 다음 프롬프트나 도구 실행 때 갱신됩니다.

plan-board

사이드 패널(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

License

MIT

Source 3 files
hooks/register.tsx 486 lines
1import { 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}
486
hooks/lib.ts 178 lines
1import 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}
178
types/index.d.ts 58 lines
1export 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