A To Do pane of pending decisions and backlog tasks, kept in .claude/todo-panel.md and ordered by Claude.

A project-level To Do panel for Claude Code.
Claude Code's built-in task list answers "what steps are we doing in this session?" and disappears when the session ends. todo-panel covers the other layer: what is still left to do in the whole project, and which questions are waiting on you. The list lives in your project as .claude/todo-panel.md: plain Markdown, hand-editable, and fine to commit. It persists across sessions, and Claude maintains it and keeps it in priority order. Tasks left open in the built-in list are folded in automatically.
The panel opens in a side pane next to Claude Code and has two sections:
To Do ✕
Prioritized by Claude
Pending Decisions (2)
? Should login use OAuth or an email code?
Blocks: Implement the login flow
? Should reports support PDF export?
Type /decide-questions and Claude will ask about each decision in turn.
Backlog (3)
1. Implement the login flow
Can start as soon as the login method is decided
2. Add integration tests for the API
Biggest test gap after the last round of changes
3. Update the deployment docs
Low risk, can be done any time
Requires Claude Code 2.1.287 or later (check with claude --version, update with claude update).
Option 1: terminal
claude plugin marketplace add mingfeng6684/claude-todo-panel
claude plugin install todo-panel@todo-panel-local
Option 2: inside a Claude Code session
/plugin install todo-panel --marketplace mingfeng6684/claude-todo-panel
When it asks Add marketplace?, answer y and pick the user scope.
Option 3: Claude desktop app (/plugin is not available in the app)
Click + next to the input box → Plugins → Add marketplace, enter mingfeng6684/claude-todo-panel, then Add plugin.
After installing, start a new session, or type /reload-plugins in an existing one.
The first time you use it in a project, run:
/todo init
Claude first detects the plan documents and skills in your project (for example docs/, plans/, superpowers plans, PRDs, TODO/FIXME comments) and uses a menu to ask which sources to build the list from. After reading them it shows you a proposed list and waits for your confirmation before writing anything, so it won't make up tasks. If the project has nothing to read, that's fine: Claude asks what the project is for and what you want done next, and drafts a starting list from your answers and the code itself.
Once the project has a list, the panel opens automatically at the start of every session; you don't need to run a command again.
Commands:
| Command | What it does |
|---|---|
/todo show | Open the panel |
/todo hide | Close the panel (or click its close button). After closing, it won't pop up again automatically in this session; /todo show brings it back any time |
/decide-questions | Claude lists all pending decisions, then asks about them one at a time with multiple-choice options. After each answer, the decision is removed, the task list is updated, and Claude tells you which tasks were unblocked |
You never have to tidy the list by hand. Finished items disappear on their own:
docs/plans/auth.md), ticking the item - [x] in that file makes it disappear from the panel..claude/todo-panel.md directly and the panel updates within a few seconds. Changing an item to - [x] counts as done and removes it..claude/todo-panel.md looks like this, and the header says it's safe to edit by hand:
<!-- todo-panel: kept by the todo-panel mod; safe to edit by hand -->
# To Do
## Sources
- docs/plans/
## Pending Decisions
- [ ] Should login use OAuth or an email code? <!-- id:d1 blocks:t1 -->
## Backlog
- [ ] Implement the login flow <!-- id:t1 src:docs%2Fplans%2Fauth.md -->
- Can start as soon as the login method is decided
- [ ] Add integration tests for the API <!-- id:t2 -->
- Biggest test gap after the last round of changes
The id, blocks, and src values in the comments are metadata the panel keeps for itself, and you can ignore them: a missing id is filled in automatically, and blocks and src are optional. The indented line under a task is its reason ("why"). Commit the file to your repo and the whole team, and every session, sees the same list.
English, Traditional Chinese (繁體中文), and Japanese (日本語) are supported. The setting defaults to auto, which uses the first of these that it finds:
/config (zh-TW / en / ja)language setting in Claude CodeLC_ALL / LC_MESSAGES / LANG)If none of them gives a supported language, English is used.
Everything runs locally; the mod makes no network requests and runs no external commands.
.claude/todo-panel.md in your project — the list itself./todo init, to drop items that are ticked - [x] there.config.json, and the LC_ALL / LC_MESSAGES / LANG environment variables — only to pick the panel language.mcp__todo-panel__update) and two commands for Claude, and observes the built-in task-list tools to fold session tasks into the Backlog.Showing the panel, click-to-fill, and file syncing all run locally and cost no tokens. Tokens are only used for:
/todo init, which runs once; the cost depends on how much documentation your project has.hooks/register.tsx 312 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { Lang, SessionTask, TodoDoc } from '../types'
5import { TEXT, decidePrompt, fromAppConfig, guidance, initPrompt, toLang } from './i18n'
6import { FILE, apply, inProject, isPlanSource, parse, serialize, summary, syncPlans, syncSession } from './model'
7import type { Op } from './model'
8
9const PANE = 'todo-panel'
10const TOOL = 'mcp__todo-panel__update'
11
12const docAtom = atom({ plugin: 'todo-panel', key: 'doc' } as const, null as TodoDoc | null)
13const langAtom = atom({ plugin: 'todo-panel', key: 'lang' } as const, 'en' as Lang)
14const tasksAtom = atom({ plugin: 'todo-panel', key: 'tasks' } as const, [] as SessionTask[])
15const hiddenAtom = atom({ plugin: 'todo-panel', key: 'isHidden' } as const, false)
16
17const UPDATE_SCHEMA = {
18 type: 'object',
19 properties: {
20 ops: {
21 type: 'array',
22 description: 'Changes applied in order. Leave empty to just read the list.',
23 items: {
24 type: 'object',
25 properties: {
26 op: { type: 'string', enum: ['add_task', 'add_decision', 'edit', 'remove', 'reorder', 'set_sources'] },
27 id: { type: 'string', description: 'edit/remove: the item id (t1, d1, ...)' },
28 title: { type: 'string', description: 'add_task/edit: task title, short' },
29 question: { type: 'string', description: 'add_decision: the question the user must decide' },
30 why: { type: 'string', description: 'add_task/edit: one line on why the task sits at this place' },
31 blocks: { type: 'string', description: 'add_decision/edit: id of the task this decision holds up' },
32 source: { type: 'string', description: 'file the item came from, e.g. docs/plans/x.md' },
33 before: { type: 'string', description: 'add_task: insert before this task id (default: end)' },
34 ids: { type: 'array', items: { type: 'string' }, description: 'reorder: backlog ids in the new order' },
35 sources: { type: 'array', items: { type: 'string' }, description: 'set_sources: doc folders and skills to read from' },
36 },
37 required: ['op'],
38 },
39 },
40 },
41}
42
43let lastText: string | undefined
44/** The folder the session started in: the list stays there when a command `cd`s elsewhere. */
45let root: string | undefined
46
47async function load($: EngineInterface): Promise<TodoDoc | null> {
48 try {
49 const text = await $.fs.read(inProject(root, FILE))
50 lastText = text
51 return parse(text)
52 } catch {
53 lastText = undefined
54 return null
55 }
56}
57
58async function save($: EngineInterface, doc: TodoDoc): Promise<void> {
59 const text = serialize(doc)
60 if (text !== lastText) {
61 await $.fs.write(inProject(root, FILE), text)
62 lastText = text
63 }
64 await update($, docAtom, () => doc)
65}
66
67async function resolveLang($: EngineInterface, options: PluginOptions): Promise<Lang> {
68 const chosen = toLang(String(options.language ?? 'auto'))
69 if (chosen) return chosen
70 const settings = await $.settings.read().catch(() => ({}) as Record<string, unknown>)
71 const fromSettings = toLang(typeof settings.language === 'string' ? settings.language : undefined)
72 if (fromSettings) return fromSettings
73 const fromApp = await appLocale($)
74 if (fromApp) return fromApp
75 return toLang(await $.env.get('LC_ALL')) ?? toLang(await $.env.get('LC_MESSAGES')) ?? toLang(await $.env.get('LANG')) ?? 'en'
76}
77
78/** The language picked in the Claude desktop app (its config.json `locale`, e.g. "zh-Hant"). */
79async function appLocale($: EngineInterface): Promise<Lang | undefined> {
80 const appData = await $.env.get('APPDATA')
81 const home = await $.env.get('HOME')
82 const paths = [
83 ...(appData ? [`${appData}\\Claude\\config.json`] : []),
84 ...(home ? [`${home}/Library/Application Support/Claude/config.json`, `${home}/.config/Claude/config.json`] : []),
85 ]
86 for (const path of paths) {
87 try {
88 const lang = fromAppConfig(await $.fs.read(path))
89 if (lang) return lang
90 } catch {
91 // No desktop app config here.
92 }
93 }
94 return undefined
95}
96
97/** Drops items whose plan file now has them ticked. */
98async function withPlans($: EngineInterface, doc: TodoDoc): Promise<TodoDoc> {
99 const paths = [...new Set([...doc.decisions, ...doc.backlog].map(i => i.source).filter(isPlanSource))]
100 const files = new Map<string, string>()
101 for (const path of paths) {
102 try {
103 files.set(path, await $.fs.read(inProject(root, path)))
104 } catch {
105 // A plan that moved or was deleted keeps its items.
106 }
107 }
108 return syncPlans(doc, files)
109}
110
111/** A command's own run cannot submit a prompt; it goes once the command has finished. */
112function submitLater($: EngineInterface, text: string) {
113 $.clock.after(0, () => {
114 void (async () => {
115 const lang = await read($, langAtom)
116 try {
117 const sent = await $.prompt.submit({ text })
118 if ('drop' in sent) $.ui.toast(TEXT[lang].submitFailed)
119 } catch {
120 $.ui.toast(TEXT[lang].submitFailed)
121 }
122 })()
123 })
124}
125
126/** Opens the pane; an automatic open stays away once the person closed it this session. */
127async function openPane($: EngineInterface, isAsked = false) {
128 if (isAsked) await update($, hiddenAtom, () => false)
129 else if (await read($, hiddenAtom)) return
130 await $.ui.open({ id: PANE, title: 'To Do' })
131}
132
133export const register: Register = (on, options) => {
134 on('session.start', async ($, e, next) => {
135 root = e.cwd
136 await $.command.register({ name: 'todo', description: 'To Do pane: `/todo show` opens it, `/todo hide` closes it, `/todo init` sets it up for this project', argumentHint: '[show|hide|init]' })
137 await $.command.register({ name: 'decide-questions', description: 'Let Claude ask you about each pending decision in the To Do list' })
138 await $.tool.register({
139 name: 'update',
140 description: [
141 'Read or change the project To Do list (.claude/todo-panel.md) the user sees in a side pane. Answers with the current list; call with no ops to read it.',
142 'Pending Decisions: questions only the user can answer (blocks = the task id it holds up). Backlog: work agreed but not done, at task level (never single steps), in the order you recommend, each with a one-line why for its place.',
143 'Remove an item once done or decided. Only use this when the project has a To Do list or the user asked to set one up.',
144 ].join(' '),
145 inputSchema: UPDATE_SCHEMA,
146 isDeferred: false,
147 })
148 const lang = await resolveLang($, options)
149 await update($, langAtom, () => lang)
150
151 const doc = await load($)
152 if (doc) {
153 await save($, await withPlans($, doc))
154 void openPane($)
155 } else {
156 await update($, docAtom, () => null)
157 }
158
159 // Picks up hand edits of the file and language changes.
160 $.clock.every(4000, () => {
161 void (async () => {
162 const nowLang = await resolveLang($, options)
163 if (nowLang !== (await read($, langAtom))) await update($, langAtom, () => nowLang)
164 const before = lastText
165 const fresh = await load($)
166 if (lastText !== before) await update($, docAtom, () => fresh)
167 })()
168 })
169
170 return next(e)
171 })
172
173 on('command.run', { command: 'todo' }, async ($, e) => {
174 const t = TEXT[await read($, langAtom)]
175 const arg = e.args.trim().toLowerCase()
176 if (arg === 'init') {
177 // The pane opens on the first write, so it never shows "not set up" mid-init.
178 submitLater($, initPrompt(await read($, langAtom)))
179 return { text: t.initStarted }
180 }
181 if (arg === 'hide' || arg === 'close') {
182 await update($, hiddenAtom, () => true)
183 await $.ui.close({ id: PANE })
184 return { text: t.closed }
185 }
186 if (arg !== '' && arg !== 'show') return { text: t.usage }
187 await openPane($, true)
188 return { text: (await read($, docAtom)) === null ? t.notInit : t.opened }
189 })
190
191 on('command.run', { command: 'decide-questions' }, async $ => {
192 const lang = await read($, langAtom)
193 const doc = await read($, docAtom)
194 if (doc === null) return { text: TEXT[lang].notInit }
195 if (doc.decisions.length === 0) return { text: TEXT[lang].nothingToDecide }
196 submitLater($, decidePrompt(lang, summary(doc)))
197 return { text: TEXT[lang].decideStarted }
198 })
199
200 on('ui.close', async ($, e, next) => {
201 if (e.id === PANE && e.origin.kind === 'person') await update($, hiddenAtom, () => true)
202 return next(e)
203 })
204
205 on('prompt.compose', async ($, e, next) => {
206 const composed = await next(e)
207 if ((await read($, docAtom)) === null) return composed
208 const lang = await read($, langAtom)
209 return { sections: [...composed.sections, { id: 'todo-panel:guidance', text: guidance(lang), scope: 'session' as const }] }
210 })
211
212 on('tool.call', { tool: TOOL }, async ($, e) => {
213 const input = e as unknown as { ops?: Op[] }
214 const current = (await read($, docAtom)) ?? (await load($)) ?? { sources: [], decisions: [], backlog: [] }
215 const { doc, notes } = apply(current, input.ops ?? [])
216 if ((input.ops ?? []).length > 0) {
217 await save($, doc)
218 void openPane($)
219 }
220 return { result: [summary(doc), ...notes.map(n => `note: ${n}`)].join('\n') }
221 }).catch(() => ({ deny: 'todo-panel could not update .claude/todo-panel.md.' }))
222
223 // Built-in task list: remember each task's title and state, fold into the backlog at turn end.
224 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
225 const ran = await next(e)
226 if (e.tool === 'TodoWrite' && ran.deny === undefined && !ran.isError) {
227 const tasks = e.todos.map(t => ({ key: `todo:${t.content}`, title: t.content, isDone: t.status === 'completed' }))
228 await update($, tasksAtom, list => [...list.filter(t => !t.key.startsWith('todo:')), ...tasks])
229 }
230 return ran
231 })
232 .catch(($, e, next) => next(e))
233
234 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
235 const ran = await next(e)
236 if (e.tool === 'TaskCreate' && ran.deny === undefined && !ran.isError && ran.result) {
237 const id = (ran.result as { task?: { id?: string } }).task?.id
238 if (id) await update($, tasksAtom, list => [...list.filter(t => t.key !== `task:${id}`), { key: `task:${id}`, title: e.subject, isDone: false }])
239 }
240 return ran
241 })
242 .catch(($, e, next) => next(e))
243
244 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
245 const ran = await next(e)
246 if (e.tool === 'TaskUpdate' && ran.deny === undefined && !ran.isError) {
247 const key = `task:${e.taskId}`
248 await update($, tasksAtom, list =>
249 e.status === 'deleted'
250 ? list.map(t => (t.key !== key ? t : { ...t, isDone: true }))
251 : list.map(t => (t.key !== key ? t : { ...t, title: e.subject ?? t.title, isDone: e.status ? e.status === 'completed' : t.isDone })),
252 )
253 }
254 return ran
255 })
256 .catch(($, e, next) => next(e))
257
258 on('turn.complete', async ($, e, next) => {
259 const done = await next(e)
260 const doc = await read($, docAtom)
261 if (doc === null || e.isAborted) return done
262 const tasks = await read($, tasksAtom)
263 const synced = await withPlans($, syncSession(doc, tasks))
264 if (serialize(synced) !== serialize(doc)) await save($, synced)
265 return done
266 })
267
268 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
269 const { Box, Text, Button } = $.ui.resolve(e)
270 const lang = await read($, langAtom)
271 const doc = await read($, docAtom)
272 const t = TEXT[lang]
273
274 if (doc === null) {
275 return (
276 <Box flexDirection="column">
277 <Text dimColor>{t.notInit}</Text>
278 </Box>
279 )
280 }
281
282 const titleOf = (id: string | undefined) => doc.backlog.find(task => task.id === id)?.title
283
284 return (
285 <Box flexDirection="column">
286 <Text dimColor>{t.sortedBy}</Text>
287 <Text> </Text>
288 <Text bold>{t.decisions} ({doc.decisions.length})</Text>
289 {doc.decisions.length === 0 && <Text dimColor>{t.noDecisions}</Text>}
290 {doc.decisions.map(d => (
291 <Box key={`d-${d.id}`} flexDirection="column">
292 <Text>? {d.title}</Text>
293 {titleOf(d.blocks) !== undefined && <Text dimColor> {t.blocks}{titleOf(d.blocks)}</Text>}
294 </Box>
295 ))}
296 {doc.decisions.length > 0 && <Text dimColor>{t.decideHint}</Text>}
297 <Text> </Text>
298 <Text bold>{t.backlog} ({doc.backlog.length})</Text>
299 {doc.backlog.length === 0 && <Text dimColor>{t.noBacklog}</Text>}
300 {doc.backlog.map((task, i) => (
301 <Box key={`t-${task.id}`} flexDirection="column">
302 <Button key={`fill-${task.id}`} plain onPress={() => $.prompt.fill({ text: task.title, mode: 'replace' })}>
303 {`${i + 1}. ${task.title}`}
304 </Button>
305 {task.why !== undefined && <Text dimColor> {task.why}</Text>}
306 </Box>
307 ))}
308 </Box>
309 )
310 })
311}
312hooks/i18n.ts 115 lines1import type { Lang } from '../types'
2
3export const TEXT = {
4 'zh-TW': {
5 name: '繁體中文',
6 sortedBy: '依 Claude 建議優先序',
7 decisions: '待決策事項',
8 backlog: '待執行任務',
9 blocks: '阻塞:',
10 decideHint: '輸入 /decide-questions,Claude 會逐項提出決策問題。',
11 noDecisions: '目前無待決策事項。',
12 noBacklog: '目前無待執行任務。',
13 notInit: '此專案尚未啟用 To Do。輸入 /todo init 開始初始化。',
14 opened: 'To Do 面板已開啟。',
15 initStarted: '開始初始化 To Do。',
16 decideStarted: '開始處理待決策事項。',
17 nothingToDecide: '目前無待決策事項。',
18 closed: 'To Do 面板已關閉。輸入 /todo show 可再次開啟。',
19 submitFailed: '無法送出 To Do 的提示,請再輸入一次指令。',
20 usage: '用法:/todo show 開啟面板、/todo hide 關閉面板、/todo init 初始化此專案。',
21 },
22 en: {
23 name: 'English',
24 sortedBy: 'Prioritized by Claude',
25 decisions: 'Pending Decisions',
26 backlog: 'Backlog',
27 blocks: 'Blocks: ',
28 decideHint: 'Type /decide-questions and Claude will ask about each decision in turn.',
29 noDecisions: 'No pending decisions.',
30 noBacklog: 'The backlog is empty.',
31 notInit: 'To Do is not set up for this project yet. Type /todo init to start.',
32 opened: 'To Do pane opened.',
33 initStarted: 'Setting up To Do.',
34 decideStarted: 'Going through the pending decisions.',
35 nothingToDecide: 'No pending decisions.',
36 closed: 'To Do pane closed. Type /todo show to open it again.',
37 submitFailed: 'Could not send the To Do prompt. Run the command again.',
38 usage: 'Usage: /todo show opens the pane, /todo hide closes it, /todo init sets up this project.',
39 },
40 ja: {
41 name: '日本語',
42 sortedBy: 'Claude による優先順位',
43 decisions: '未決事項',
44 backlog: '未着手タスク',
45 blocks: 'ブロック中:',
46 decideHint: '/decide-questions と入力すると、Claude が一件ずつ質問します。',
47 noDecisions: '未決事項はありません。',
48 noBacklog: '未着手タスクはありません。',
49 notInit: 'このプロジェクトでは To Do が未設定です。/todo init で開始します。',
50 opened: 'To Do パネルを開きました。',
51 initStarted: 'To Do の初期化を始めます。',
52 decideStarted: '未決事項を順に確認します。',
53 nothingToDecide: '未決事項はありません。',
54 closed: 'To Do パネルを閉じました。/todo show で再び開けます。',
55 submitFailed: 'To Do のプロンプトを送信できませんでした。もう一度コマンドを入力してください。',
56 usage: '使い方:/todo show でパネルを開く、/todo hide で閉じる、/todo init でこのプロジェクトを初期化。',
57 },
58} as const satisfies Record<Lang, Record<string, string>>
59
60/** Maps a free-form language name or locale ("繁體中文", "Japanese", "en_US.UTF-8") to a panel language. */
61export function toLang(value: string | undefined): Lang | undefined {
62 const v = (value ?? '').trim().toLowerCase()
63 if (!v || v === 'c' || v === 'posix') return undefined
64 if (/日本|japan|^ja\b|^ja[-_]/.test(v)) return 'ja'
65 if (/中文|漢|汉|chinese|mandarin|^zh\b|^zh[-_]|台|臺/.test(v)) return 'zh-TW'
66 if (/english|^en\b|^en[-_]/.test(v)) return 'en'
67 return undefined
68}
69
70/** The panel language from the Claude desktop app's config.json text (its `locale`, e.g. "zh-Hant"). */
71export function fromAppConfig(text: string): Lang | undefined {
72 try {
73 const config = JSON.parse(text) as { locale?: unknown }
74 return toLang(typeof config.locale === 'string' ? config.locale : undefined)
75 } catch {
76 return undefined
77 }
78}
79
80/** What Claude is told each request once the project has a To Do file (kept short: it rides every request). */
81export function guidance(lang: Lang): string {
82 return [
83 'todo-panel: this project keeps a To Do list in .claude/todo-panel.md, shown to the user in a side pane.',
84 'Keep it current with the mcp__todo-panel__update tool (it answers with the current list; call it with no ops to read it):',
85 '- add a Pending Decision when a question needs the user\'s choice, with `blocks` naming the task it holds up;',
86 '- add Backlog tasks for work agreed but not done yet, at task level, never step level;',
87 '- remove an item as soon as it is done or decided;',
88 '- keep the Backlog in the order you recommend, each task with a one-line `why` for its place; reorder when priorities change.',
89 `Write item text in ${TEXT[lang].name}. When the user sends a Backlog task's exact title as their message, do that task.`,
90 ].join('\n')
91}
92
93export function initPrompt(lang: Lang): string {
94 return [
95 `[todo-panel] Set up the To Do list for this project. Talk to me in ${TEXT[lang].name}.`,
96 '1. Detect candidates: which skills and plugins I use that write plans or tasks (for example superpowers, mattpocock-skills, GitHub issue workflows), and which folders hold plans, specs or docs (docs/, plans/, docs/superpowers/, .github/, and so on). Look, do not guess.',
97 '2. Ask me with AskUserQuestion, two multiSelect questions, using what you detected as options: where my plans and docs live, and which skills or workflows produce my plans and tasks. I can also answer with my own paths.',
98 '3. Read only what I chose: unchecked `- [ ]` items in plan files such as superpowers plans (take the task level, not every step); mattpocock PRDs, both local PRD files and their GitHub issues (open issues if `gh` works here); TODO/FIXME comments.',
99 '4. Show me a proposed list: Pending Decisions (questions only I can answer, each with the task it blocks) and a Backlog in your recommended order with a one-line reason each. Wait for my confirmation or changes.',
100 '5. Then write it with mcp__todo-panel__update: set_sources with what I chose, then the add_decision and add_task ops (set `source` to the file a task came from).',
101 'If step 1 finds no plans, docs or skills, or I choose none, say so plainly and do not invent tasks. Instead ask me what this project is for and what I want done next, and look at the code itself (missing README, no tests, obvious gaps). Propose a starting list from that and wait for my confirmation.',
102 'If I still want nothing listed, write an empty list (set_sources with no sources) so the pane is on; it fills as we work.',
103 ].join('\n')
104}
105
106export function decidePrompt(lang: Lang, list: string): string {
107 return [
108 `[todo-panel] Go through the pending decisions with me. Talk to me in ${TEXT[lang].name}.`,
109 'First list all of them. Then ask me about each one with AskUserQuestion: one question per decision, 2 to 4 options with a short description each (I can always pick Other and type my own answer).',
110 'After I answer, remove each decided item with mcp__todo-panel__update, apply what the answer changes to the Backlog (new tasks, order), and tell me which tasks are now unblocked.',
111 '',
112 list,
113 ].join('\n')
114}
115hooks/model.ts 245 lines1import type { TodoDoc, TodoItem } from '../types'
2
3export const FILE = '.claude/todo-panel.md'
4
5const ABSOLUTE = /^(?:[A-Za-z]:[\\/]|[\\/])/
6
7/** A project path anchored at the folder the session started in, using that folder's separator. */
8export function inProject(root: string | undefined, path: string): string {
9 if (!root || ABSOLUTE.test(path)) return path
10 const sep = root.includes('\\') ? '\\' : '/'
11 return `${root.replace(/[\\/]+$/, '')}${sep}${path.replace(/[\\/]/g, sep)}`
12}
13
14const HEADER = '<!-- todo-panel: kept by the todo-panel mod; safe to edit by hand -->'
15const ITEM = /^- \[([ xX])\] (.*?)(?:\s*<!--(.*?)-->)?\s*$/
16const NOTE = /^\s{2,}- (.*)$/
17
18type Section = 'sources' | 'decisions' | 'backlog' | null
19
20export const emptyDoc = (): TodoDoc => ({ sources: [], decisions: [], backlog: [] })
21
22function readMeta(raw: string | undefined): Record<string, string> {
23 const meta: Record<string, string> = {}
24 for (const pair of (raw ?? '').trim().split(/\s+/)) {
25 const at = pair.indexOf(':')
26 if (at > 0) meta[pair.slice(0, at)] = decodeURIComponent(pair.slice(at + 1))
27 }
28 return meta
29}
30
31function writeMeta(item: TodoItem): string {
32 const pairs = [`id:${item.id}`]
33 if (item.blocks) pairs.push(`blocks:${encodeURIComponent(item.blocks)}`)
34 if (item.source) pairs.push(`src:${encodeURIComponent(item.source)}`)
35 return ` <!-- ${pairs.join(' ')} -->`
36}
37
38/** Parses the file; checked items are treated as done and dropped. */
39export function parse(text: string): TodoDoc {
40 const doc = emptyDoc()
41 let section: Section = null
42 let last: TodoItem | null = null
43
44 for (const line of text.split(/\r?\n/)) {
45 const heading = /^##\s+(.*)$/.exec(line)
46 if (heading) {
47 const name = (heading[1] ?? '').trim().toLowerCase()
48 section = name.startsWith('source') ? 'sources'
49 : name.includes('decision') ? 'decisions'
50 : name.startsWith('backlog') ? 'backlog'
51 : null
52 last = null
53 continue
54 }
55 if (section === 'sources') {
56 const src = /^- (.+)$/.exec(line)
57 if (src?.[1]) doc.sources.push(src[1].trim())
58 continue
59 }
60 if (section === null) continue
61 const item = ITEM.exec(line)
62 if (item) {
63 last = null
64 if ((item[1] ?? ' ') !== ' ') continue
65 const meta = readMeta(item[3])
66 last = {
67 id: meta.id ?? '',
68 title: (item[2] ?? '').trim(),
69 ...(meta.blocks ? { blocks: meta.blocks } : {}),
70 ...(meta.src ? { source: meta.src } : {}),
71 }
72 if (last.title) doc[section].push(last)
73 continue
74 }
75 const note = NOTE.exec(line)
76 if (note?.[1] && last && !last.why) last.why = note[1].trim()
77 }
78 return withIds(doc)
79}
80
81/** Gives every item a unique id: a missing or repeated one is replaced. */
82function withIds(doc: TodoDoc): TodoDoc {
83 const all = new Set([...doc.decisions, ...doc.backlog].map(i => i.id).filter(Boolean))
84 const seen = new Set<string>()
85 const fill = (list: TodoItem[], prefix: string) =>
86 list.map(item => {
87 if (item.id && !seen.has(item.id)) {
88 seen.add(item.id)
89 return item
90 }
91 const id = nextId(all, prefix)
92 all.add(id)
93 seen.add(id)
94 return { ...item, id }
95 })
96 return { ...doc, decisions: fill(doc.decisions, 'd'), backlog: fill(doc.backlog, 't') }
97}
98
99function nextId(used: Set<string>, prefix: string): string {
100 let n = 1
101 while (used.has(`${prefix}${n}`)) n += 1
102 return `${prefix}${n}`
103}
104
105export function serialize(doc: TodoDoc): string {
106 const lines = [HEADER, '# To Do', '', '## Sources']
107 for (const src of doc.sources) lines.push(`- ${src}`)
108 lines.push('', '## Pending Decisions')
109 for (const d of doc.decisions) lines.push(`- [ ] ${d.title}${writeMeta(d)}`)
110 lines.push('', '## Backlog')
111 for (const t of doc.backlog) {
112 lines.push(`- [ ] ${t.title}${writeMeta(t)}`)
113 if (t.why) lines.push(` - ${t.why}`)
114 }
115 return lines.join('\n') + '\n'
116}
117
118export type Op =
119 | { op: 'add_task'; title: string; why?: string; source?: string; before?: string }
120 | { op: 'add_decision'; question: string; blocks?: string; source?: string }
121 | { op: 'edit'; id: string; title?: string; why?: string; blocks?: string }
122 | { op: 'remove'; id: string }
123 | { op: 'reorder'; ids: string[] }
124 | { op: 'set_sources'; sources: string[] }
125
126const same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
127
128/** Applies ops in order; returns the new doc and one note per op that did nothing. */
129export function apply(doc: TodoDoc, ops: readonly Op[]): { doc: TodoDoc; notes: string[] } {
130 let next: TodoDoc = { sources: [...doc.sources], decisions: [...doc.decisions], backlog: [...doc.backlog] }
131 const notes: string[] = []
132 const ids = () => new Set([...next.decisions, ...next.backlog].map(i => i.id))
133
134 for (const op of ops) {
135 switch (op.op) {
136 case 'add_task': {
137 if (!op.title?.trim()) { notes.push('add_task: empty title'); break }
138 if (next.backlog.some(t => same(t.title, op.title))) { notes.push(`add_task: "${op.title}" is already listed`); break }
139 const item: TodoItem = {
140 id: nextId(ids(), 't'),
141 title: op.title.trim(),
142 ...(op.why ? { why: op.why.trim() } : {}),
143 ...(op.source ? { source: op.source } : {}),
144 }
145 const at = op.before ? next.backlog.findIndex(t => t.id === op.before) : -1
146 next.backlog = at >= 0
147 ? [...next.backlog.slice(0, at), item, ...next.backlog.slice(at)]
148 : [...next.backlog, item]
149 break
150 }
151 case 'add_decision': {
152 if (!op.question?.trim()) { notes.push('add_decision: empty question'); break }
153 if (next.decisions.some(d => same(d.title, op.question))) { notes.push(`add_decision: "${op.question}" is already listed`); break }
154 next.decisions = [...next.decisions, {
155 id: nextId(ids(), 'd'),
156 title: op.question.trim(),
157 ...(op.blocks ? { blocks: op.blocks } : {}),
158 ...(op.source ? { source: op.source } : {}),
159 }]
160 break
161 }
162 case 'edit': {
163 const patch = (item: TodoItem): TodoItem => item.id !== op.id ? item : {
164 ...item,
165 ...(op.title ? { title: op.title.trim() } : {}),
166 ...(op.why !== undefined ? { why: op.why.trim() } : {}),
167 ...(op.blocks !== undefined ? { blocks: op.blocks } : {}),
168 }
169 if (!ids().has(op.id)) { notes.push(`edit: no item ${op.id}`); break }
170 next.decisions = next.decisions.map(patch)
171 next.backlog = next.backlog.map(patch)
172 break
173 }
174 case 'remove': {
175 if (!ids().has(op.id)) { notes.push(`remove: no item ${op.id}`); break }
176 next.decisions = next.decisions.filter(i => i.id !== op.id)
177 next.backlog = next.backlog.filter(i => i.id !== op.id)
178 break
179 }
180 case 'reorder': {
181 const byId = new Map(next.backlog.map(t => [t.id, t]))
182 const first = op.ids.map(id => byId.get(id)).filter((t): t is TodoItem => t !== undefined)
183 const rest = next.backlog.filter(t => !op.ids.includes(t.id))
184 next.backlog = [...first, ...rest]
185 break
186 }
187 case 'set_sources':
188 next.sources = op.sources.map(s => s.trim()).filter(Boolean)
189 break
190 default:
191 notes.push(`unknown op ${JSON.stringify(op)}`)
192 }
193 }
194 // A blocker that names a removed task no longer blocks anything.
195 const live = new Set(next.backlog.map(t => t.id))
196 next = { ...next, decisions: next.decisions.map(d => (d.blocks && !live.has(d.blocks) ? { id: d.id, title: d.title, ...(d.source ? { source: d.source } : {}) } : d)) }
197 return { doc: next, notes }
198}
199
200/** The compact listing the update tool answers with. */
201export function summary(doc: TodoDoc): string {
202 const lines = ['Pending Decisions:']
203 if (doc.decisions.length === 0) lines.push(' (none)')
204 for (const d of doc.decisions) lines.push(` ${d.id} ${d.title}${d.blocks ? ` [blocks ${d.blocks}]` : ''}`)
205 lines.push('Backlog (in priority order):')
206 if (doc.backlog.length === 0) lines.push(' (none)')
207 for (const t of doc.backlog) lines.push(` ${t.id} ${t.title}${t.why ? ` (why: ${t.why})` : ''}`)
208 if (doc.sources.length) lines.push(`Sources: ${doc.sources.join(', ')}`)
209 return lines.join('\n')
210}
211
212/**
213 * Folds the built-in task list into the backlog: tasks still open are added
214 * (marked src:session), session tasks finished since are removed.
215 */
216export function syncSession(doc: TodoDoc, tasks: readonly { title: string; isDone: boolean }[]): TodoDoc {
217 const done = tasks.filter(t => t.isDone)
218 const open = tasks.filter(t => !t.isDone)
219 let backlog = doc.backlog.filter(t => !(t.source === 'session' && done.some(d => same(d.title, t.title))))
220 const used = new Set([...doc.decisions, ...backlog].map(i => i.id))
221 for (const task of open) {
222 if (backlog.some(t => same(t.title, task.title))) continue
223 const id = nextId(used, 't')
224 used.add(id)
225 backlog = [...backlog, { id, title: task.title.trim(), source: 'session' }]
226 }
227 return { ...doc, backlog }
228}
229
230/** Drops items whose source plan file now shows them ticked (`- [x] title`). */
231export function syncPlans(doc: TodoDoc, files: ReadonlyMap<string, string>): TodoDoc {
232 const ticked = (item: TodoItem) => {
233 const text = item.source ? files.get(item.source) : undefined
234 if (!text) return false
235 return text.split(/\r?\n/).some(line => {
236 const m = /^\s*[-*] \[[xX]\]\s+(.*)$/.exec(line)
237 return m?.[1] !== undefined && (same(m[1], item.title) || m[1].toLowerCase().includes(item.title.trim().toLowerCase()))
238 })
239 }
240 return { ...doc, decisions: doc.decisions.filter(d => !ticked(d)), backlog: doc.backlog.filter(t => !ticked(t)) }
241}
242
243export const isPlanSource = (src: string | undefined): src is string =>
244 src !== undefined && src !== 'session' && /\.(md|markdown)$/i.test(src)
245types/index.d.ts 29 lines1export type Lang = 'zh-TW' | 'en' | 'ja'
2
3export type TodoItem = {
4 id: string
5 title: string
6 why?: string
7 source?: string
8 blocks?: string
9}
10
11export type TodoDoc = {
12 sources: string[]
13 decisions: TodoItem[]
14 backlog: TodoItem[]
15}
16
17export type SessionTask = { key: string; title: string; isDone: boolean }
18
19declare module 'claude-code' {
20 interface PluginState {
21 'todo-panel': {
22 doc: TodoDoc | null
23 lang: Lang
24 tasks: SessionTask[]
25 isHidden: boolean
26 }
27 }
28}
29