SLOPSHOPPER

todo-band

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…

newpaneguardcommandprompttimer
v0.5.0no licenseupdated 2026-10-16binarythink/claude-marketplace/todo-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · todo-band
│ ┃ TODO ✕ › fix the failing auth test and add an audit log call │ ┃ auto ● │ ┃ No TODO.jsonl or TODO.md in this session's ⏺ Read(src/auth.ts) │ ┃ scratchpad. ⎿ 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 │ │ › /todo │ ⎿ todo-band: TODO pane opened (no TODO.jsonl or TODO.md in the scr │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · TODO
auto ● No TODO.jsonl or TODO.md in this session's scratchpad.
README

claude-marketplace

Personal Claude Code mods.

ModCommandDescription
git-graph/git-graphToggles 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/outputsToggles 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-todoToggles 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-band: TODO.jsonl

The 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.

  • No argument toggles; the pane header shows auto ● (on) or auto ○ (off).
  • The setting lasts for the session only. To start every session on, set the plugin option 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.

pii-guard: 전송 전 개인정보 확인

프롬프트(붙여넣은 텍스트 포함)와 @경로로 참조한 파일을 전송 전에 검사한다.

처리유형
확인 없이 차단주민등록번호·외국인등록번호(구분자 있는 형식), 여권번호·운전면허번호(키워드 또는 구분자 형식)
"그래도 보낼까요?" 확인구분자 없는 13자리 주민번호 형식, 휴대전화, 이메일, 카드(Luhn), 계좌(키워드), 주소(시·도~번지), 이미지·문서 첨부
  • 대화상자와 차단 사유에는 유형과 건수만 표시하고 감지한 값은 표시하지 않는다.
  • 막힌 프롬프트는 엔진이 입력창에 되돌려 두므로 값을 지우고 다시 보낼 수 있다.
  • 물어볼 수 없는 경우(-p, SDK)나 검사 중 오류가 나면 보내지 않는다(fail-closed).
  • 모델이 도구로 읽는 파일·명령 출력은 검사하지 않는다. 첨부 이미지·문서는 내용 검사 없이 확인만 한다.

herdr-screenshot: pane 화면 PNG 저장

Herdr pane 안에서 "이 화면 캡처해줘" 처럼 요청하면 스킬이 scripts/cap.sh 를 실행한다.

cap.sh [--pane <id>] [--out <png|dir>] [--title <text>] [--lines N]
  • 기본은 현재 pane($HERDR_PANE_ID)의 보이는 화면. --lines N 이면 scrollback 포함
  • 측정 패스로 콘텐츠 크기를 잰 뒤 그 크기로 2배 해상도 스크린샷을 찍으므로 넓은 pane 도 잘리지 않는다
  • 필요: Google Chrome, 폰트 D2KodingLigature Nerd Font (한글 2칸 정렬용)

session-outputs: 세션 산출물 뷰어

/outputs 로 pane 을 열고 닫는다. 현재 진행 중인 세션의 transcript(~/.claude/projects/<slug>/<session id>.jsonl)와 scratchpad 를 읽어 수집 출처별로 두 그룹에 나눠 보여준다. 다른 세션의 산출물은 보이지 않는다.

그룹출처
outputassistant 의 글과 도구 입력(MCP 호출 포함)에 나온 URL, Write·Edit·NotebookEdit 로 만든 파일, git·gh 명령의 입출력, 세션 중 checkout 된 branch, 세션 scratchpad 의 파일
input사용자 메시지의 글에 나온 URL
  • 같은 항목이 두 그룹에서 나오면 처음 나온 쪽에만 보인다. 사용자가 준 참고 링크를 assistant 가 다시 언급해도 input 에 남는다
  • 그룹 안에서는 세션에 처음 나온 순서이고, 파일과 링크가 섞인다. scratchpad 파일은 output 의 맨 끝에 온다
  • 각 행은 type(PR · Branch · Jira · Confluence · Notion · Slack · GitHub · Web · File · Scratch), name, path 순이다. 파일의 name 은 파일 이름, path 는 worktree 기준 폴더(root 는 .)이고, 링크의 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 링크는 참고 자료라 제외한다
  • branch 는 git checkout -b·git switch -c·git worktree add -b·git push -u 명령, git push 출력의 pull/new/<branch> URL 에서 찾는다
  • scratchpad 는 /tmp 아래라 재부팅 후에는 사라진다
  • r 로 다시 읽는다. 열려 있으면 10초마다 갱신한다
  • pane 에 포커스가 있을 때 j/k 로 한 item 씩 이동하고, c 는 선택한 item 의 이름, p 는 경로(파일은 절대 경로, 링크는 URL, URL 없는 branch 는 이름)를 클립보드에 복사한다
  • 4 MiB 가 넘는 transcript 는 tail -c 로 나눠 읽는다 ($.fs.read 상한)
  • 링크는 path 칸의 URL 을 클릭하면 열린다 (OSC 8 hyperlink). 파일은 OSC 8 을 못 그리는 터미널에서 file:// URL 이 덧붙지 않도록 링크를 걸지 않는다. p 로 절대 경로를 복사한다
  • attribution 링크(claude.ai/code/session_…, claude.com/claude-code), api.* 엔드포인트, Read·WebFetch 등 도구 결과 속 URL 은 잡음이라 제외한다

Install

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

Update

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

Develop

claude --plugin-dir ./git-graph   # loads the working copy, reloads on save
claude plugin validate .
claude plugin test ./git-graph
Source 4 files
hooks/register.tsx 317 lines
1import { 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}
317
hooks/auto.ts 29 lines
1// /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}
29
hooks/parse.ts 116 lines
1import 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}
116
types/index.d.ts 27 lines
1// 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