Shows the session scratchpad's TODO.jsonl (with dependencies) or TODO.md in a pane (/todo), plus a skill that writes TODO.jsonl and /auto-todo to have Claude…

Personal Claude Code mods.
| Mod | Command | Description |
|---|---|---|
git-graph | /git-graph | Toggles a side pane showing the working directory's git log as a graph |
pii-guard | — | Checks each prompt for personal data before it is sent: blocks Korean unique identifiers, asks before sending phone numbers, emails, cards, accounts, addresses and attachments |
session-outputs | /outputs | Toggles a pane listing what the current session produced — PRs, branches, Jira/Notion/Confluence/Slack links, edited files, scratchpad files — grouped into what the session produced (output) and links you gave it (input) |
todo-band | /todo, /auto-todo | Toggles a pane showing the session scratchpad's TODO.jsonl (or TODO.md); its todo-plan skill has Claude keep the plan there with dependencies, and /auto-todo has Claude keep it up to date on every request |
herdr-screenshot | — | Its herdr-screenshot skill saves a Herdr pane's screen as a PNG by rendering the pane's ANSI dump in headless Chrome |
The panes stay closed until you run the command; running it again closes the pane.
TODO.jsonlThe todo-plan skill has Claude write one JSON object per line to the session scratchpad:
{"id":"api","title":"Implement API","status":"doing","deps":["schema"]}
{"id":"deploy","title":"Deploy to dev1","status":"waiting","deps":["api"],"note":"user syncs ArgoCD"}
status is todo, doing (agent running it), waiting (waiting on the user to run or confirm something), done or cancelled. The pane orders items by deps, marks a todo whose deps are not all done as blocked, and flags broken lines, unknown deps and cycles. The full rules are in todo-band/skills/todo-plan/SKILL.md.
The hide done button in the pane's header (or h while the pane has focus) hides done and cancelled items (in TODO.md, - [x] lines) for the rest of the session; press show done (N) to bring them back. show keys (or k) prefixes each TODO.jsonl row with its id, e.g. ▶ [api] Implement API; hide keys turns it off.
/auto-todo [on|off]Without auto-todo, TODO.jsonl exists only once Claude picks the todo-plan skill for a multi-step task, and later requests in the session go untracked. With /auto-todo on, every prompt you type carries a hidden instruction to keep TODO.jsonl current: work requests (edits, commands, investigation) are appended as items and their statuses updated; questions that need no work get none. If a turn edits files or runs commands but leaves TODO.jsonl untouched, the next prompt also carries a one-time reminder.
auto ● (on) or auto ○ (off).autoTodoDefault to true in /config./auto-todo off stops the instruction and tells Claude once to stop updating the file on its own; the file and the pane stay.프롬프트(붙여넣은 텍스트 포함)와 @경로로 참조한 파일을 전송 전에 검사한다.
| 처리 | 유형 |
|---|---|
| 확인 없이 차단 | 주민등록번호·외국인등록번호(구분자 있는 형식), 여권번호·운전면허번호(키워드 또는 구분자 형식) |
| "그래도 보낼까요?" 확인 | 구분자 없는 13자리 주민번호 형식, 휴대전화, 이메일, 카드(Luhn), 계좌(키워드), 주소(시·도~번지), 이미지·문서 첨부 |
-p, SDK)나 검사 중 오류가 나면 보내지 않는다(fail-closed).Herdr pane 안에서 "이 화면 캡처해줘" 처럼 요청하면 스킬이 scripts/cap.sh 를 실행한다.
cap.sh [--pane <id>] [--out <png|dir>] [--title <text>] [--lines N]
$HERDR_PANE_ID)의 보이는 화면. --lines N 이면 scrollback 포함D2KodingLigature Nerd Font (한글 2칸 정렬용)/outputs 로 pane 을 열고 닫는다. 현재 진행 중인 세션의 transcript(~/.claude/projects/<slug>/<session id>.jsonl)와 scratchpad 를 읽어 수집 출처별로 두 그룹에 나눠 보여준다. 다른 세션의 산출물은 보이지 않는다.
| 그룹 | 출처 |
|---|---|
| output | assistant 의 글과 도구 입력(MCP 호출 포함)에 나온 URL, Write·Edit·NotebookEdit 로 만든 파일, git·gh 명령의 입출력, 세션 중 checkout 된 branch, 세션 scratchpad 의 파일 |
| input | 사용자 메시지의 글에 나온 URL |
.)이고, 링크의 path 는 URL 이다. path 는 흐리게 보인다. 지금 없는 파일은 (deleted) 로 표시한다git·gh 명령에서는 입력·출력 모두 GitHub 링크(PR · Branch · GitHub)만 남긴다. 출력은 gh pr create·gh issue create·gh release create·git push 의 것만 읽는다. gh pr view·gh api·git log 같은 조회 명령의 출력과 PR 본문(--body)의 Jira·Notion 링크는 참고 자료라 제외한다git checkout -b·git switch -c·git worktree add -b·git push -u 명령, git push 출력의 pull/new/<branch> URL 에서 찾는다/tmp 아래라 재부팅 후에는 사라진다r 로 다시 읽는다. 열려 있으면 10초마다 갱신한다j/k 로 한 item 씩 이동하고, c 는 선택한 item 의 이름, p 는 경로(파일은 절대 경로, 링크는 URL, URL 없는 branch 는 이름)를 클립보드에 복사한다tail -c 로 나눠 읽는다 ($.fs.read 상한)file:// URL 이 덧붙지 않도록 링크를 걸지 않는다. p 로 절대 경로를 복사한다claude.ai/code/session_…, claude.com/claude-code), api.* 엔드포인트, Read·WebFetch 등 도구 결과 속 URL 은 잡음이라 제외한다In a Claude Code terminal session:
/plugin install git-graph --marketplace binarythink/claude-marketplace
/plugin install todo-band --marketplace binarythink/claude-marketplace
/plugin install pii-guard --marketplace binarythink/claude-marketplace
/plugin install herdr-screenshot --marketplace binarythink/claude-marketplace
/plugin install session-outputs --marketplace binarythink/claude-marketplace
Answer y to add the marketplace, then pick a scope.
Or from the shell:
claude plugin marketplace add binarythink/claude-marketplace
claude plugin install git-graph@binarythink
claude plugin install todo-band@binarythink
claude plugin install pii-guard@binarythink
claude plugin install herdr-screenshot@binarythink
claude plugin install session-outputs@binarythink
Bump version in the mod's .claude-plugin/plugin.json when publishing a change, then:
claude plugin marketplace update binarythink
claude plugin update git-graph@binarythink
claude --plugin-dir ./git-graph # loads the working copy, reloads on save
claude plugin validate .
claude plugin test ./git-graphhooks/register.tsx 317 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TodoFile } from '../types'
5import { autoTodoContext, parseAutoTodoArgs } from './auto'
6import { lookOf, parseTodoJsonl, summaryOf } from './parse'
7
8const todo = atom({ plugin: 'todo-band', key: 'todo' } as const, null)
9// 세션 한정 on/off. null 은 아직 정하지 않음 — session.start 가 userConfig 기본값으로 채운다.
10const autoTodo = atom({ plugin: 'todo-band', key: 'autoTodo' } as const, null)
11// 세션 한정. true 면 pane 에서 완료(done)·취소(cancelled) 항목을 숨긴다.
12const hideDone = atom({ plugin: 'todo-band', key: 'hideDone' } as const, false)
13// 세션 한정. true 면 TODO.jsonl 행 앞에 항목 id(key)를 보여준다.
14const showKeys = atom({ plugin: 'todo-band', key: 'showKeys' } as const, false)
15
16const PANE = 'todo'
17const PANE_TITLE = 'TODO'
18// 앞의 것을 우선한다. TODO.jsonl 이 있으면 TODO.md 는 보지 않는다.
19const FILE_NAMES = ['TODO.jsonl', 'TODO.md']
20const POLL_MS = 2000
21const TOGGLE_KEY = 'toggle-done'
22const TOGGLE_HOTKEY = 'h'
23const KEYS_TOGGLE_KEY = 'toggle-keys'
24const KEYS_TOGGLE_HOTKEY = 'k'
25const isClosed = (status: string) => status === 'done' || status === 'cancelled'
26const isDoneLine = (line: string) => /^\s*[-*]\s+\[[xX]\]/.test(line)
27const JSONL = 'TODO.jsonl'
28// 이 도구를 쓴 턴은 작업을 한 턴으로 보고, TODO.jsonl 이 그대로면 다음 프롬프트에 리마인더를 붙인다.
29const WORK_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash'])
30const TMP_ROOTS = ['/private/tmp', '/tmp']
31
32// Claude Code keeps a session's scratchpad at
33// <tmp>/claude-<uid>/<cwd slug>/<session id>/scratchpad
34const slugOf = (path: string) => path.replace(/[^a-zA-Z0-9]/g, '-')
35
36async function findScratchpad($: EngineInterface, sessionId: string) {
37 const custom = await $.env.get('CLAUDE_CODE_TMPDIR')
38 const roots = custom ? [custom, ...TMP_ROOTS] : TMP_ROOTS
39 const slugs = [...new Set([slugOf(await $.session.root()), slugOf(await $.session.cwd())])]
40
41 for (const root of roots) {
42 const entries = await $.fs.list(root).catch(() => [])
43 const owners = entries.filter(one => one.kind === 'dir' && one.name.startsWith('claude-'))
44 for (const owner of owners) {
45 const base = `${root}/${owner.name}`
46 for (const slug of slugs) {
47 const dir = `${base}/${slug}/${sessionId}/scratchpad`
48 if (await $.fs.exists(dir)) return dir
49 }
50 // The slug rule may differ; fall back to scanning every project folder.
51 const projects = await $.fs.list(base).catch(() => [])
52 for (const project of projects) {
53 const dir = `${base}/${project.name}/${sessionId}/scratchpad`
54 if (project.kind === 'dir' && (await $.fs.exists(dir))) return dir
55 }
56 }
57 }
58
59 return undefined
60}
61
62// Reset on every reload, which only means one re-read of the TODO file.
63let sessionId = ''
64let scratchpad: string | undefined
65let loadedPath = ''
66let mtimeMs = -1
67let isPolling = false
68// auto-todo 의 턴 추적. reload 로 초기화돼도 리마인더 한 번을 놓칠 뿐이다.
69let isOffNoticePending = false
70let isNudgePending = false
71let turnId = ''
72let turnStartMtimeMs = -1
73let didWork = false
74
75async function locateScratchpad($: EngineInterface) {
76 const id = await $.session.id()
77 if (id !== sessionId) {
78 sessionId = id
79 scratchpad = undefined
80 loadedPath = ''
81 mtimeMs = -1
82 }
83 scratchpad ??= await findScratchpad($, sessionId)
84 return scratchpad
85}
86
87// TODO.jsonl 의 mtime. 파일이 없으면 -1.
88async function jsonlMtime($: EngineInterface) {
89 const dir = await locateScratchpad($)
90 if (dir === undefined) return -1
91 const stat = await $.fs.stat(`${dir}/${JSONL}`).catch(() => undefined)
92 return stat?.kind === 'file' ? stat.mtimeMs : -1
93}
94
95async function refresh($: EngineInterface) {
96 if (isPolling) return
97 isPolling = true
98 try {
99 await locateScratchpad($)
100 if (scratchpad === undefined) return
101
102 let found: { path: string; mtimeMs: number } | undefined
103 for (const name of FILE_NAMES) {
104 const path = `${scratchpad}/${name}`
105 const stat = await $.fs.stat(path).catch(() => undefined)
106 if (stat?.kind === 'file') {
107 found = { path, mtimeMs: stat.mtimeMs }
108 break
109 }
110 }
111 if (found === undefined) {
112 if (mtimeMs !== -1) {
113 loadedPath = ''
114 mtimeMs = -1
115 await update($, todo, () => null)
116 }
117 return
118 }
119 if (found.path === loadedPath && found.mtimeMs === mtimeMs) return
120
121 const { path } = found
122 const text = await $.fs.read(path)
123 loadedPath = path
124 mtimeMs = found.mtimeMs
125 const file: TodoFile = path.endsWith('.jsonl')
126 ? { format: 'jsonl', path, ...parseTodoJsonl(text) }
127 : { format: 'md', path, text }
128 await update($, todo, () => file)
129 } finally {
130 isPolling = false
131 }
132}
133
134export const register: Register = (on, options) => {
135 const autoTodoDefault = options.autoTodoDefault === true
136
137 on('session.start', async ($, e, next) => {
138 await $.command.register({
139 name: 'todo',
140 description: "Toggle the pane showing the session scratchpad's TODO.jsonl (or TODO.md)",
141 })
142 await $.command.register({
143 name: 'auto-todo',
144 description: 'Turn on or off having Claude keep TODO.jsonl up to date on every request (this session only)',
145 argumentHint: '[on|off]',
146 })
147 // reload 때는 $.state 가 남아 있으므로 세션에서 처음 시작할 때만 기본값을 넣는다.
148 if ((await read($, autoTodo)) === null) await update($, autoTodo, () => autoTodoDefault)
149 await refresh($).catch(() => undefined)
150 $.clock.every(POLL_MS, () => {
151 void refresh($).catch(() => undefined)
152 })
153
154 return next(e)
155 })
156
157 // /todo 는 pane 을 열고 닫는 toggle 이다. TODO 파일이 생겨도 자동으로 열지 않는다.
158 // 다른 탭 뒤에 열려 있으면 닫지 않고 앞으로 가져온다.
159 on('command.run', { command: 'todo' }, async $ => {
160 const pane = (await $.ui.panes()).find(one => one.id === PANE)
161 if (pane?.isShown) {
162 await $.ui.close({ id: PANE })
163 return { text: 'TODO pane closed.' }
164 }
165 if (pane) {
166 // 열린 id 를 다시 open 하면 제목만 바뀌고 탭은 그대로라, 닫았다 열어 맨 앞 탭으로 올린다.
167 await $.ui.close({ id: PANE })
168 await $.ui.open({ id: PANE, title: PANE_TITLE })
169 return { text: 'TODO pane brought to front.' }
170 }
171 await refresh($).catch(() => undefined)
172 await $.ui.open({ id: PANE, title: PANE_TITLE })
173 const file = await read($, todo)
174
175 return { text: file ? `TODO pane opened: ${file.path}` : 'TODO pane opened (no TODO.jsonl or TODO.md in the scratchpad yet).' }
176 })
177
178 on('command.run', { command: 'auto-todo' }, async ($, e) => {
179 const isOn = (await read($, autoTodo)) === true
180 const wanted = parseAutoTodoArgs(e.args, isOn)
181 if (wanted === undefined) return { text: `Usage: /auto-todo [on|off] (now ${isOn ? 'on' : 'off'})` }
182 if (wanted === isOn) return { text: `auto-todo is already ${isOn ? 'on' : 'off'}.` }
183 await update($, autoTodo, () => wanted)
184 // 켤 때는 지난 off 안내를, 끌 때는 남은 리마인더를 버린다.
185 isOffNoticePending = !wanted
186 isNudgePending = false
187 turnId = ''
188 return {
189 text: wanted
190 ? 'auto-todo on: Claude will keep TODO.jsonl up to date on every request in this session.'
191 : 'auto-todo off: Claude stops updating TODO.jsonl on its own. The file is kept.',
192 }
193 })
194
195 on('prompt.submit', async ($, e, next) => {
196 // 슬래시 명령과 사용자가 직접 치지 않은 프롬프트(알림 등)에는 붙이지 않는다.
197 if (e.command || e.isMeta) return next(e)
198 // 상태를 못 읽어도 프롬프트는 막지 않는다.
199 const isOn = await read($, autoTodo).then(value => value === true).catch(() => false)
200 const text = autoTodoContext({ isOn, isOffNoticePending, isNudgePending })
201 isOffNoticePending = false
202 isNudgePending = false
203 if (text === undefined) return next(e)
204 return next({ ...e, context: [...(e.context ?? []), text] })
205 })
206
207 on('turn.start', async ($, e, next) => {
208 if (e.agentId === undefined && (await read($, autoTodo)) === true) {
209 turnId = e.turnId
210 didWork = false
211 turnStartMtimeMs = await jsonlMtime($).catch(() => -1)
212 }
213 return next(e)
214 })
215
216 on('tool.call', ($, e, next) => {
217 if (e.agentId === undefined && WORK_TOOLS.has(e.tool)) didWork = true
218 return next(e)
219 })
220
221 on('turn.complete', async ($, e, next) => {
222 if (e.agentId === undefined && e.turnId === turnId) {
223 turnId = ''
224 if (e.reason === 'answer' && didWork && (await read($, autoTodo)) === true) {
225 isNudgePending = (await jsonlMtime($).catch(() => -1)) === turnStartMtimeMs
226 }
227 }
228 return next(e)
229 })
230
231 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
232 const { Box, Button, Text } = $.ui.resolve(e)
233 const file = await read($, todo)
234 const auto = (await read($, autoTodo)) === true ? 'auto ●' : 'auto ○'
235 const isHidingDone = (await read($, hideDone)) === true
236 const isShowingKeys = (await read($, showKeys)) === true
237 // 라벨은 숨긴 개수에 따라 바뀌므로 주소는 key 로 고정한다.
238 const toggle = (closedCount: number) => (
239 <Button
240 key={TOGGLE_KEY}
241 plain
242 dimColor
243 hotkey={TOGGLE_HOTKEY}
244 label={isHidingDone ? `show done (${closedCount})` : 'hide done'}
245 onPress={() => update($, hideDone, value => !value)}
246 />
247 )
248 const keysToggle = (
249 <Button
250 key={KEYS_TOGGLE_KEY}
251 plain
252 dimColor
253 hotkey={KEYS_TOGGLE_HOTKEY}
254 label={isShowingKeys ? 'hide keys' : 'show keys'}
255 onPress={() => update($, showKeys, value => !value)}
256 />
257 )
258
259 if (file?.format === 'jsonl') {
260 const closedCount = file.rows.filter(row => isClosed(row.status)).length
261 const rows = isHidingDone ? file.rows.filter(row => !isClosed(row.status)) : file.rows
262 return (
263 <Box flexDirection="column">
264 <Box flexDirection="row" gap={2}>
265 <Text bold>{`${auto} · ${file.rows.length === 0 ? 'No items in TODO.jsonl.' : summaryOf(file.rows)}`}</Text>
266 {file.rows.length > 0 && toggle(closedCount)}
267 {file.rows.length > 0 && keysToggle}
268 </Box>
269 {file.rows.length > 0 && rows.length === 0 && <Text dimColor>All items are done.</Text>}
270 {rows.map(row => {
271 const look = lookOf(row)
272 return (
273 <Box flexDirection="column">
274 <Text
275 color={look.color}
276 bold={look.bold}
277 dimColor={look.dimColor}
278 strikethrough={look.strikethrough}
279 wrap="truncate-end"
280 >
281 {`${look.glyph} ${isShowingKeys ? `[${row.id}] ` : ''}${row.title}${look.suffix ? ` (${look.suffix})` : ''}`}
282 </Text>
283 {row.note && <Text dimColor wrap="truncate-end">{` ${row.note}`}</Text>}
284 </Box>
285 )
286 })}
287 {file.errors.map(error => (
288 <Text color="error" wrap="truncate-end">{`! ${error}`}</Text>
289 ))}
290 </Box>
291 )
292 }
293
294 const allLines = file?.text.split('\n').filter(line => line.trim() !== '') ?? []
295 const closedCount = allLines.filter(isDoneLine).length
296 const lines = isHidingDone ? allLines.filter(line => !isDoneLine(line)) : allLines
297 return (
298 <Box flexDirection="column">
299 <Box flexDirection="row" gap={2}>
300 <Text dimColor>{auto}</Text>
301 {allLines.length > 0 && toggle(closedCount)}
302 </Box>
303 {allLines.length === 0 && <Text dimColor>No TODO.jsonl or TODO.md in this session's scratchpad.</Text>}
304 {lines.map(line => {
305 const isDone = isDoneLine(line)
306 const isHeading = /^\s*#/.test(line)
307 return (
308 <Text dimColor={isDone} strikethrough={isDone} bold={isHeading} wrap="truncate-end">
309 {line}
310 </Text>
311 )
312 })}
313 </Box>
314 )
315 })
316}
317hooks/auto.ts 29 lines1// /auto-todo 의 인자를 원하는 상태로 바꾼다. 빈 인자는 반전, 모르는 인자는 undefined.
2export function parseAutoTodoArgs(args: string, isOn: boolean): boolean | undefined {
3 const word = args.trim().toLowerCase()
4 if (word === '') return !isOn
5 if (word === 'on') return true
6 if (word === 'off') return false
7 return undefined
8}
9
10const ON = [
11 'auto-todo is on: keep the plan for this session in TODO.jsonl in the session scratchpad, following the todo-band:todo-plan skill (load it if its rules are not in context).',
12 'If this message asks for work (editing files, running commands, investigating), add items for it before you start: append them to the existing file, or create the file if there is none. Update each status as it changes.',
13 'A question that needs no work gets no item. Track progress only in TODO.jsonl, not with the built-in TodoWrite or Task tools.',
14].join(' ')
15
16const NUDGE = 'Your last turn edited files or ran commands but did not update TODO.jsonl: bring it up to date with what was done before continuing.'
17
18const OFF = 'auto-todo was turned off: stop maintaining TODO.jsonl on your own and change it only when the user asks.'
19
20// 사용자 프롬프트에 붙일 context. 붙일 것이 없으면 undefined.
21export function autoTodoContext(state: {
22 isOn: boolean
23 isOffNoticePending: boolean
24 isNudgePending: boolean
25}): string | undefined {
26 if (!state.isOn) return state.isOffNoticePending ? OFF : undefined
27 return state.isNudgePending ? `${ON}\n${NUDGE}` : ON
28}
29hooks/parse.ts 116 lines1import type { TodoItem, TodoRow, TodoStatus } from '../types'
2
3const STATUSES: readonly TodoStatus[] = ['todo', 'doing', 'waiting', 'done', 'cancelled']
4
5const isStatus = (value: unknown): value is TodoStatus => STATUSES.includes(value as TodoStatus)
6
7// JSONL 한 줄을 TodoItem 으로 읽는다. 형식이 틀리면 이유를 문자열로 돌려준다.
8function parseItem(line: string): TodoItem | string {
9 let value: unknown
10 try {
11 value = JSON.parse(line)
12 } catch {
13 return 'not valid JSON'
14 }
15 if (typeof value !== 'object' || value === null || Array.isArray(value)) return 'not a JSON object'
16 const { id, title, status, deps = [], note } = value as Record<string, unknown>
17 if (typeof id !== 'string' || id === '') return 'missing "id"'
18 if (typeof title !== 'string') return `${id}: missing "title"`
19 if (!isStatus(status)) return `${id}: "status" must be one of ${STATUSES.join(', ')}`
20 if (!Array.isArray(deps) || deps.some(dep => typeof dep !== 'string')) return `${id}: "deps" must be an array of ids`
21 if (note !== undefined && typeof note !== 'string') return `${id}: "note" must be a string`
22 return { id, title, status, deps, ...(note ? { note } : {}) }
23}
24
25// TODO.jsonl 을 읽어 deps 기준 위상 정렬(파일 순서 유지)한 행과 경고 목록을 만든다.
26// 깨진 줄·중복 id 는 건너뛰고, 없는 dep·순환 의존은 경고만 남기고 행은 그대로 보여준다.
27export function parseTodoJsonl(text: string): { rows: TodoRow[]; errors: string[] } {
28 const errors: string[] = []
29 const items: TodoItem[] = []
30 const byId = new Map<string, TodoItem>()
31
32 text.split('\n').forEach((raw, index) => {
33 const line = raw.trim()
34 if (line === '') return
35 const item = parseItem(line)
36 if (typeof item === 'string') {
37 errors.push(`line ${index + 1}: ${item}`)
38 return
39 }
40 if (byId.has(item.id)) {
41 errors.push(`line ${index + 1}: duplicate id "${item.id}"`)
42 return
43 }
44 byId.set(item.id, item)
45 items.push(item)
46 })
47
48 for (const item of items) {
49 for (const dep of item.deps) {
50 if (dep === item.id) errors.push(`${item.id}: depends on itself`)
51 else if (!byId.has(dep)) errors.push(`${item.id}: unknown dep "${dep}"`)
52 }
53 }
54
55 // 남은 항목 중 known dep 이 모두 배치된 첫 항목을 고른다. 항목 수가 적어 O(n²) 로 충분하다.
56 const ordered: TodoItem[] = []
57 const placed = new Set<string>()
58 const rest = [...items]
59 while (rest.length > 0) {
60 const at = rest.findIndex(item => item.deps.every(dep => placed.has(dep) || !byId.has(dep) || dep === item.id))
61 if (at < 0) {
62 errors.push(`dependency cycle among: ${rest.map(item => item.id).join(', ')}`)
63 ordered.push(...rest)
64 break
65 }
66 const item = rest.splice(at, 1)[0]!
67 placed.add(item.id)
68 ordered.push(item)
69 }
70
71 const rows = ordered.map(item => ({
72 ...item,
73 blockedBy: item.deps.filter(dep => byId.get(dep)?.status !== 'done'),
74 }))
75 return { rows, errors }
76}
77
78export type RowLook = {
79 glyph: string
80 color?: string
81 bold?: boolean
82 dimColor?: boolean
83 strikethrough?: boolean
84 // 상태 뒤에 붙이는 설명
85 suffix?: string
86}
87
88// 행 하나의 표시 방식. todo 는 deps 가 남아 있으면 blocked, 아니면 바로 착수 가능(ready)으로 나눈다.
89export function lookOf(row: TodoRow): RowLook {
90 switch (row.status) {
91 case 'done':
92 return { glyph: '✓', dimColor: true, strikethrough: true }
93 case 'cancelled':
94 return { glyph: '✗', dimColor: true, strikethrough: true, suffix: 'cancelled' }
95 case 'doing':
96 return { glyph: '▶', color: 'claude', bold: true, suffix: 'agent running' }
97 case 'waiting':
98 return { glyph: '⏸', color: 'warning', bold: true, suffix: 'waiting on user' }
99 case 'todo':
100 return row.blockedBy.length > 0
101 ? { glyph: '◌', dimColor: true, suffix: `after ${row.blockedBy.join(', ')}` }
102 : { glyph: '○' }
103 }
104}
105
106export function summaryOf(rows: TodoRow[]): string {
107 const count = (status: TodoStatus) => rows.filter(row => row.status === status).length
108 const active = rows.filter(row => row.status !== 'cancelled').length
109 const parts = [`${count('done')}/${active} done`]
110 if (count('doing') > 0) parts.push(`${count('doing')} running`)
111 if (count('waiting') > 0) parts.push(`${count('waiting')} waiting on user`)
112 const ready = rows.filter(row => row.status === 'todo' && row.blockedBy.length === 0).length
113 if (ready > 0) parts.push(`${ready} ready`)
114 return parts.join(' · ')
115}
116types/index.d.ts 27 lines1// doing: agent 가 직접 실행 중, waiting: 사용자가 실행하거나 결과를 확인해 주기를 기다리는 중
2export type TodoStatus = 'todo' | 'doing' | 'waiting' | 'done' | 'cancelled'
3
4export type TodoItem = {
5 id: string
6 title: string
7 status: TodoStatus
8 deps: string[]
9 note?: string
10}
11
12// 렌더링 단위. blockedBy 는 저장값이 아니라 deps 중 아직 done 이 아닌 id 로 계산한 값이다.
13export type TodoRow = TodoItem & { blockedBy: string[] }
14
15export type TodoFile =
16 | { format: 'md'; path: string; text: string }
17 | { format: 'jsonl'; path: string; rows: TodoRow[]; errors: string[] }
18
19declare module 'claude-code' {
20 interface PluginState {
21 // autoTodo: 세션 한정 on/off, null 은 session.start 가 아직 기본값을 넣지 않은 상태
22 // hideDone: 세션 한정, pane 에서 done·cancelled 항목(TODO.md 는 [x] 줄)을 숨기는지
23 // showKeys: 세션 한정, pane 에서 TODO.jsonl 행 앞에 항목 id 를 보여주는지
24 'todo-band': { todo: TodoFile | null; autoTodo: boolean | null; hideDone: boolean; showKeys: boolean }
25 }
26}
27