One board for the todos of every agent working in this project, with the items only you can do pinned on top. Search + priority.

A Claude Code mod that puts the todos of every agent working in this project on one board: your main session, its subagents, and any other Claude Code session open in the same folder tree. Todos that only you can do are pinned to the top.
☑ Agent Todos AiSetup · 6 open
◆ You 2 ⚑ Urgent 3 ◐ Active 1 ⊘ Blocked 1 ≡ All 6
⌕: search titles, notes, agents, p0…
◆ Waiting on you 2
◆ Add STRIPE_KEY to Vercel env ⚑ P0
◆ Approve the new pricing copy P2
● main agent session 8c2209 · 2
◐ Wire checkout webhook ⚑ P1 ⇢1
⊘ Deploy preview build ⚑ P0
╭──────────────────────────────────────────╮
│ ◐ Wire checkout webhook │
│ Status in progress │
│ Priority P1 · high │
│ [ ▶ Kick off ] [ ✓ Done ] [ Close ] │
│ Change to P0 P1 P2 P3 │
╰──────────────────────────────────────────╯
Inspired by OmniFocus 4: perspectives, todos grouped by agent like projects, a colored bullet per kind of todo, and an inspector for the selected todo.
| Bullet | Meaning |
|---|---|
| ◆ purple | only you can do it |
| ◐ green | an agent is working on it |
| ⊘ gray | blocked by another task |
| ◉ red / ○ orange·blue / ◌ gray | P0 / P1·P2 / P3 |
| ⚑ | P0 or P1 |
| ⇢n | finishing it unblocks n other tasks |
| ☐ / ✎ | found by ⟳ Scan repo: an open checkbox / a code comment |
| ✝ | its session has ended |
/plugin install agent-todos --marketplace XyndoX/agent-todos
Answer y to add the marketplace, then pick a scope. User scope puts it in every session.
/todos opens the board. /todos stripe p0 opens it with a search already filled in.★ N on you.p0–p3, you, blocked or in_progress./todos scan) collects todos from the project into a "Found in repo" group:- [ ] checkboxes in Markdown. ROADMAP / NEXT-TASKS / TODO / BACKLOG files keep their priority, and checkboxes in other docs (plans, notes) drop to P3.TODO / FIXME / HACK / XXX at the start of a comment. FIXME counts as P1.Chris:, Chris-only:, taken from git config user.name and your home folder) is marked as yours (◆).… lines are skipped. A new scan replaces the last one, and at most 300 items are kept (roadmap first, then code, then other docs).git grep, which respects .gitignore. Outside a git repo it falls back to grep -r, skipping node_modules, dist and build.file:line.Each session running the mod copies its agents' TaskCreate, TaskUpdate and TodoWrite calls into ~/.claude/agent-todos/<sessionId>.json. Every session reads all of those files. It shows the ones whose folder is inside its project root, or contains it.
Yours (◆): any of
human_todo tool (the mod tells every agent to do this for logins, secrets, purchases, decisions and approvals)[you], you:, @me, …TaskUpdate sets owner to user / you / me / humanTaskCreate with metadata: { human: true }Priority: P0…P3 in a subject, or metadata: { priority: 0..3 }. The default is P2.
Order: yours first, then unblocked before blocked, then by priority, then in-progress items, then by how many other items each one unblocks.
An agent's items disappear when its session ends. Yours stay until you tick them off.
claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT
hooks/register.tsx 408 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Item, Row, SessionFile } from '../types'
5import { VIEWS, inView, isHumanOwner, matches, parseScan, parseTitle, rank, rowKey } from './board'
6
7const PANE = 'agent-todos'
8const rowsAtom = atom({ plugin: 'agent-todos', key: 'rows' } as const, [] as Row[])
9const queryAtom = atom({ plugin: 'agent-todos', key: 'query' } as const, '')
10const rootAtom = atom({ plugin: 'agent-todos', key: 'root' } as const, '')
11const viewAtom = atom({ plugin: 'agent-todos', key: 'view' } as const, 'all')
12const selAtom = atom({ plugin: 'agent-todos', key: 'selected' } as const, '')
13
14const GUIDE = `## Agent todo board
15The person sees one board of every agent's todos in this project. When something can only be done by the person (a login, a secret, a purchase, a decision, a physical action, an approval), call \`mcp__agent-todos__human_todo\` with a short imperative title and a priority (0 urgent … 3 someday) instead of only mentioning it in prose. Mark a task as the person's with TaskUpdate owner "user", or prefix its subject with "[you]". "P0".."P3" in a subject sets its priority.`
16
17type $ = EngineInterface
18
19async function paths($: $) {
20 const home = (await $.env.get('HOME')) ?? ''
21 return { dir: `${home}/.claude/agent-todos`, sessions: `${home}/.claude/sessions` }
22}
23
24async function readJson<T>($: $, path: string): Promise<T | undefined> {
25 try { return JSON.parse(await $.fs.read(path)) as T } catch { return undefined }
26}
27
28// This session's own file: read, change, write. Serialized so parallel tool calls don't clobber each other.
29let chain: Promise<unknown> = Promise.resolve()
30function editMine($: $, fn: (items: Item[]) => Item[]) {
31 chain = chain.then(async () => {
32 const { dir } = await paths($)
33 const sessionId = await $.session.id()
34 const path = `${dir}/${sessionId}.json`
35 const old = await readJson<SessionFile>($, path)
36 const file: SessionFile = {
37 sessionId,
38 cwd: await $.session.root(),
39 updatedAt: await $.clock.now(),
40 items: fn(old?.items ?? []),
41 }
42 await $.fs.write(path, JSON.stringify(file, null, 1))
43 }).then(() => refresh($)).catch(() => undefined)
44 return chain
45}
46
47async function refresh($: $) {
48 const { dir, sessions } = await paths($)
49 const root = await $.session.root()
50 const live = new Set<string>()
51 for (const f of await $.fs.list(sessions).catch(() => [])) {
52 if (!f.name.endsWith('.json')) continue
53 const s = await readJson<{ sessionId?: string }>($, `${sessions}/${f.name}`)
54 if (s?.sessionId) live.add(s.sessionId)
55 }
56 const files: SessionFile[] = []
57 for (const f of await $.fs.list(dir).catch(() => [])) {
58 if (!f.name.endsWith('.json')) continue
59 const s = await readJson<SessionFile>($, `${dir}/${f.name}`)
60 if (s?.items) files.push(s)
61 }
62 const done = new Set(((await $.store.get('done')) as string[] | undefined) ?? [])
63 const prio = ((await $.store.get('prio')) as Record<string, number> | undefined) ?? {}
64 const rows = rank(files, root, live, done, prio)
65 await update($, rowsAtom, () => rows)
66 await update($, rootAtom, () => root)
67 const mine = rows.filter(r => r.isHuman).length
68 await $.ui.status(mine ? `★ ${mine} on you` : undefined)
69}
70
71async function tickDone($: $, row: Row) {
72 const done = ((await $.store.get('done')) as string[] | undefined) ?? []
73 // ponytail: done-list grows forever; prune by age if it ever matters
74 await $.store.set('done', [...done, rowKey(row)].slice(-2000))
75 await update($, selAtom, () => '')
76 await refresh($)
77 await $.ui.toast(`✓ ${row.title}`)
78}
79
80async function setPrio($: $, row: Row, priority: number) {
81 const prio = ((await $.store.get('prio')) as Record<string, number> | undefined) ?? {}
82 await $.store.set('prio', { ...prio, [rowKey(row)]: priority })
83 await refresh($)
84}
85
86const SCAN_RE = '(TODO|FIXME|HACK|XXX)|^[[:space:]]*([-*+]|[0-9]+\\.)[[:space:]]+\\[ \\]'
87
88/** Find TODO/FIXME/HACK/XXX comments and open `- [ ]` checkboxes in the project; they replace the last scan. */
89async function scanRepo($: $) {
90 const root = await $.session.root()
91 await $.ui.toast('⟳ Scanning repo for todos…')
92 try {
93 const git = await $.process.run(['git', 'grep', '-n', '-I', '-E', SCAN_RE, '--', '.', ':!*.lock', ':!*.min.*', ':!*.map'], { cwd: root, timeoutMs: 60_000 })
94 const out = git.exitCode <= 1 && !git.stderr.includes('not a git repository')
95 ? git.stdout
96 : (await $.process.run(['grep', '-rnI', '-E', SCAN_RE, '--exclude-dir=node_modules', '--exclude-dir=.git', '--exclude-dir=dist', '--exclude-dir=build', '.'], { cwd: root, timeoutMs: 60_000 })).stdout
97 const name = (await $.process.run(['git', 'config', 'user.name'], { cwd: root }).catch(() => undefined))?.stdout.trim() ?? ''
98 const home = ((await $.env.get('HOME')) ?? '').split('/').pop() ?? ''
99 const found = parseScan(out, { me: [...name.split(/\s+/), home] })
100 await editMine($, items => [...items.filter(i => !i.id.startsWith('scan:')), ...found])
101 await update($, viewAtom, () => 'all')
102 await $.ui.toast(found.length ? `⟳ Found ${found.length} todo${found.length > 1 ? 's' : ''} in the repo` : '⟳ No TODOs or open checkboxes found')
103 } catch (err) {
104 await $.ui.toast(`Scan failed: ${String(err).slice(0, 120)}`)
105 }
106}
107
108/** Hand the task to the agent that owns it: a turn here, or a message to the other session. */
109async function kickOff($: $, row: Row) {
110 const where = row.agent === 'repo' ? `\n\nFound at ${row.note} — remove the TODO / tick the checkbox when done.` : row.note ? `\n\n${row.note}` : ''
111 const text = `Start on this task now${row.priority <= 1 ? ' (high priority)' : ''}: "${row.title}"${where}`
112 try {
113 if (row.agent === 'repo' || row.sessionId === (await $.session.id())) await $.prompt.submit({ text })
114 else await $.session.send({ to: { sessionId: row.sessionId }, text })
115 await $.ui.toast(`▶ Kicked off: ${row.title}`)
116 } catch (err) {
117 await $.ui.toast(`Could not reach that agent: ${String(err).slice(0, 120)}`)
118 }
119}
120
121const label = (e: { agentId?: string }) => e.agentId ?? 'main'
122
123export const register: Register = on => {
124 on('session.start', async ($, e, next) => {
125 await $.command.register({ name: 'todos', description: 'Open the agent todo board (a search query, or "scan" to collect TODOs from the repo)' })
126 await $.tool.register({
127 name: 'human_todo',
128 description:
129 'Put a todo on the person\'s board that only they can complete (login, secret, purchase, decision, approval, physical action). Short imperative title.',
130 inputSchema: {
131 type: 'object',
132 properties: {
133 title: { type: 'string', description: 'What the person must do, imperative, < 80 chars' },
134 priority: { type: 'integer', minimum: 0, maximum: 3, description: '0 urgent, 1 high, 2 normal, 3 someday' },
135 why: { type: 'string', description: 'What it unblocks / extra context' },
136 },
137 required: ['title'],
138 },
139 isDeferred: false,
140 })
141 await editMine($, items => items) // announce this session
142 await $.clock.every(5000, () => refresh($))
143 return next(e)
144 })
145
146 on('prompt.compose', async ($, e, next) => {
147 const r = await next(e)
148 return { sections: [...r.sections, { id: 'agent-todos:guide', text: GUIDE, scope: 'session' }] }
149 })
150
151 on('command.run', { command: 'todos' }, async ($, e) => {
152 const isScan = e.args.trim() === 'scan'
153 await $.state.set({ plugin: 'agent-todos', key: 'query' }, isScan ? '' : e.args.trim())
154 if (isScan) await scanRepo($)
155 else await refresh($)
156 await $.ui.open({ id: PANE, title: 'Agent todos' })
157 return { text: 'Agent todo board opened.' }
158 })
159
160 on('tool.call', { tool: 'mcp__agent-todos__human_todo' }, async ($, e) => {
161 const args = e as unknown as { title?: unknown; priority?: unknown; why?: unknown; agentId?: string }
162 if (typeof args.title !== 'string' || !args.title.trim()) return { deny: 'title is required' }
163 const p = Number(args.priority)
164 const id = `human:${await $.clock.now()}`
165 await editMine($, items => [...items, {
166 id,
167 title: args.title as string,
168 status: 'pending',
169 priority: Number.isInteger(p) && p >= 0 && p <= 3 ? p : 2,
170 isHuman: true,
171 blockedBy: [],
172 agent: label(args),
173 note: typeof args.why === 'string' ? args.why : undefined,
174 }])
175 return { result: `Added to the person's board (${id}). They tick it off there.` }
176 }).catch(() => ({ deny: 'agent-todos could not save the item' }))
177
178 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
179 const r = await next(e)
180 if ('deny' in r && r.deny) return r
181 const id = (r.result as { task?: { id?: string } } | undefined)?.task?.id
182 if (!id) return r
183 const parsed = parseTitle(e.subject)
184 const meta = (e.metadata ?? {}) as { priority?: unknown; human?: unknown }
185 const p = Number(meta.priority)
186 await editMine($, items => [...items.filter(i => i.id !== `task:${id}`), {
187 id: `task:${id}`,
188 title: parsed.title,
189 status: 'pending',
190 priority: Number.isInteger(p) && p >= 0 && p <= 3 ? p : parsed.priority,
191 isHuman: parsed.isHuman || meta.human === true,
192 blockedBy: [],
193 agent: label(e),
194 note: e.description,
195 }])
196 return r
197 }).catch(($, e, next) => next(e))
198
199 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
200 const r = await next(e)
201 if ('deny' in r && r.deny) return r
202 const id = `task:${e.taskId}`
203 await editMine($, items => {
204 if (e.status === 'deleted') return items.filter(i => i.id !== id)
205 return items.map(i => {
206 if (i.id !== id) return i
207 const parsed = e.subject ? parseTitle(e.subject) : undefined
208 return {
209 ...i,
210 ...(parsed && { title: parsed.title, priority: parsed.priority, isHuman: i.isHuman || parsed.isHuman }),
211 ...(e.status && e.status !== 'deleted' && { status: e.status }),
212 ...(e.description && { note: e.description }),
213 ...(e.owner !== undefined && { isHuman: isHumanOwner(e.owner) }),
214 blockedBy: [...new Set([...i.blockedBy, ...(e.addBlockedBy ?? []).map(b => `task:${b}`)])],
215 }
216 })
217 })
218 return r
219 }).catch(($, e, next) => next(e))
220
221 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
222 const r = await next(e)
223 if ('deny' in r && r.deny) return r
224 const agent = label(e)
225 await editMine($, items => [
226 ...items.filter(i => !(i.agent === agent && i.id.startsWith('todo:'))),
227 ...e.todos.map((t, n) => {
228 const parsed = parseTitle(t.content)
229 return { id: `todo:${agent}:${n}`, ...parsed, status: t.status, blockedBy: [], agent }
230 }),
231 ])
232 return r
233 }).catch(($, e, next) => next(e))
234
235
236 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
237 const ui = $.ui.resolve(e)
238 const { Box, Text, Button } = ui
239 const Input = 'Input' in ui ? ui.Input : undefined // mobile has no Input
240 const all = await read($, rowsAtom)
241 const query = await read($, queryAtom)
242 const view = await read($, viewAtom)
243 const selected = await read($, selAtom)
244 const root = await read($, rootAtom)
245 const me = await $.session.id()
246 const width = Math.max(30, (e.props as { bodyColumns?: number }).bodyColumns ?? e.viewport?.columns ?? 80)
247 const rows = all.filter(r => inView(r, view) && matches(r, query))
248 const sel = all.find(r => rowKey(r) === selected)
249 const project = root.split('/').filter(Boolean).pop() ?? root
250 let room = Math.max(3, (e.viewport?.rows ?? 30) - 8 - (sel ? 11 : 0))
251
252 // Agent groups, OmniFocus-project style; your items first as their own group.
253 const groups = new Map<string, Row[]>()
254 for (const r of rows) {
255 const g = r.isHuman ? 'you' : r.agent === 'repo' ? 'repo' : `${r.sessionId}|${r.agent}`
256 groups.set(g, [...(groups.get(g) ?? []), r])
257 }
258
259 const tab = (v: string) => {
260 const n = all.filter(r => inView(r, v)).length
261 const isOn = v === view
262 const { glyph, name, color } = TABS[v as keyof typeof TABS]
263 return (
264 <Button key={`view:${v}`} plain onPress={() => void update($, viewAtom, () => v)}>
265 <Text color={isOn ? '#1c1c1e' : color} backgroundColor={isOn ? color : undefined} bold={isOn}>{` ${glyph} ${name} ${n} `}</Text>
266 </Button>
267 )
268 }
269
270 const row = (r: Row) => {
271 const b = bullet(r)
272 const pc = PRIO[r.priority] ?? PRIO[2]!
273 const isSel = rowKey(r) === selected
274 const tail = `${r.priority <= 1 ? ' ⚑' : ''} P${r.priority}${r.blocks ? ` ⇢${r.blocks}` : ''}${r.isLive ? '' : ' ✝'}`
275 const title = cut(r.title, width - tail.length - 5)
276 return (
277 <Button key={`row:${rowKey(r)}`} plain onPress={() => void update($, selAtom, s => (s === rowKey(r) ? '' : rowKey(r)))}>
278 <Text color={b.color} bold>{` ${b.glyph} `}</Text>
279 <Text bold={isSel} inverse={isSel} strikethrough={false} dimColor={r.isBlocked && !isSel}>{title}</Text>
280 {r.priority <= 1 && <Text color={pc.color}>{' ⚑'}</Text>}
281 <Text color="#1c1c1e" backgroundColor={pc.color}>{` P${r.priority} `}</Text>
282 {r.blocks > 0 && <Text color="#64d2ff">{` ⇢${r.blocks}`}</Text>}
283 {!r.isLive && <Text dimColor>{' ✝'}</Text>}
284 </Button>
285 )
286 }
287
288 const header = (g: string, list: Row[]) => {
289 if (g === 'you') return <Text key="h:you" color={TABS.you.color} bold>{`◆ Waiting on you ${list.length}`}</Text>
290 if (g === 'repo') return <Text key="h:repo"><Text color={REPO_COLOR} bold>▤ Found in repo</Text><Text dimColor>{` TODO comments & open checkboxes · ${list.length}`}</Text></Text>
291 const r = list[0]!
292 const c = agentColor(`${r.sessionId}${r.agent}`)
293 const where = r.sessionId === me ? 'this session' : `session ${r.sessionId.slice(0, 6)}`
294 return (
295 <Text key={`h:${g}`}>
296 <Text color={c} bold>{`● ${r.agent === 'main' ? 'main agent' : `agent ${r.agent.slice(0, 8)}`}`}</Text>
297 <Text dimColor>{` ${where}${r.isLive ? '' : ' · ended'} · ${list.length}`}</Text>
298 </Text>
299 )
300 }
301
302 const inspector = (r: Row) => {
303 const pc = PRIO[r.priority] ?? PRIO[2]!
304 const blockers = r.blockedBy.map(id => all.find(o => o.sessionId === r.sessionId && o.id === id)?.title).filter(Boolean)
305 const field = (k: string, v: string, color?: string) => (
306 <Text key={`f:${k}`}><Text dimColor>{k.padEnd(10)}</Text><Text color={color}>{v}</Text></Text>
307 )
308 return (
309 <Box key="inspector" flexDirection="column" borderStyle="round" borderColor={bullet(r).color} paddingX={1} marginTop={1}>
310 <Text bold color={bullet(r).color}>{`${bullet(r).glyph} ${r.title}`}</Text>
311 {r.note && r.agent !== 'repo' ? <Text dimColor wrap="wrap">{r.note}</Text> : null}
312 {field('Status', r.isBlocked ? 'blocked' : r.status.replace('_', ' '), bullet(r).color)}
313 {field('Priority', `P${r.priority} · ${pc.name}`, pc.color)}
314 {r.agent === 'repo' && field('Found at', r.note ?? '', REPO_COLOR)}
315 {field('Owner', r.isHuman ? 'you' : r.agent === 'repo' ? 'repo · kick off assigns it here' : r.agent, r.isHuman ? TABS.you.color : agentColor(`${r.sessionId}${r.agent}`))}
316 {field('Session', `${r.sessionId.slice(0, 8)}${r.sessionId === me ? ' (this one)' : ''}${r.isLive ? '' : ' · ended'}`)}
317 {blockers.length > 0 && field('Waits on', blockers.join(', '), '#8e8e93')}
318 {r.blocks > 0 && field('Unblocks', `${r.blocks} task${r.blocks > 1 ? 's' : ''}`, '#64d2ff')}
319 <Box flexDirection="row" gap={1} marginTop={1}>
320 {!r.isHuman && (r.isLive || r.agent === 'repo') && <Button key="act:kick" hotkey="k" variant="primary" label="▶ Kick off" onPress={() => void kickOff($, r)} />}
321 <Button key="act:done" hotkey="d" label="✓ Done" onPress={() => void tickDone($, r)} />
322 <Button key="act:close" hotkey="x" role="dismiss" label="Close" onPress={() => void update($, selAtom, () => '')} />
323 </Box>
324 <Box flexDirection="row" gap={1}>
325 <Text dimColor>{'Change to'.padEnd(10)}</Text>
326 {PRIO.map((p, n) => (
327 <Button key={`act:p${n}`} plain onPress={() => void setPrio($, r, n)}>
328 <Text color={n === r.priority ? '#1c1c1e' : p.color} backgroundColor={n === r.priority ? p.color : undefined} bold={n === r.priority}>{` P${n} `}</Text>
329 </Button>
330 ))}
331 </Box>
332 </Box>
333 )
334 }
335
336 const body: unknown[] = []
337 for (const [g, list] of groups) {
338 if (room <= 1) break
339 body.push(header(g, list))
340 room--
341 for (const r of list.slice(0, room)) body.push(row(r))
342 room -= Math.min(list.length, room)
343 }
344 const shown = body.length - groups.size
345
346 return (
347 <Box flexDirection="column">
348 <Box flexDirection="row" justifyContent="space-between">
349 <Text bold color={TABS.you.color}>☑ Agent Todos</Text>
350 <Box flexDirection="row" gap={1}>
351 <Text dimColor>{`${project} · ${all.length} open`}</Text>
352 <Button key="scan" plain onPress={() => void scanRepo($)}>
353 <Text color="#1c1c1e" backgroundColor={REPO_COLOR}>{' ⟳ Scan repo '}</Text>
354 </Button>
355 </Box>
356 </Box>
357 <Box flexDirection="row" flexWrap="wrap">{VIEWS.map(tab)}</Box>
358 {Input && (
359 <Input key="q" label="⌕" placeholder="search titles, notes, agents, p0…" value={query} submitLabel="filter"
360 onInput={v => void update($, queryAtom, () => v)} onSubmit={v => void update($, queryAtom, () => v)} />
361 )}
362 {rows.length === 0 && (
363 <Text dimColor>{all.length ? ' Nothing in this perspective.' : ' All clear — agents add todos with TaskCreate / human_todo.'}</Text>
364 )}
365 {body as never}
366 {rows.length > shown && shown >= 0 && <Text dimColor>{` +${rows.length - Math.max(0, shown)} more — search or switch perspective`}</Text>}
367 {sel && inspector(sel)}
368 </Box>
369 )
370 })
371}
372
373const TABS = {
374 you: { glyph: '◆', name: 'You', color: '#bf5af2' },
375 urgent: { glyph: '⚑', name: 'Urgent', color: '#ff9f0a' },
376 active: { glyph: '◐', name: 'Active', color: '#30d158' },
377 blocked: { glyph: '⊘', name: 'Blocked', color: '#8e8e93' },
378 all: { glyph: '≡', name: 'All', color: '#0a84ff' },
379}
380
381const PRIO = [
382 { name: 'urgent', color: '#ff453a' },
383 { name: 'high', color: '#ff9f0a' },
384 { name: 'normal', color: '#0a84ff' },
385 { name: 'someday', color: '#8e8e93' },
386]
387
388/** Each kind of todo gets its own bullet: yours, running, blocked, then by priority. */
389function bullet(r: Row) {
390 if (r.isHuman) return { glyph: '◆', color: TABS.you.color }
391 if (r.status === 'in_progress') return { glyph: '◐', color: TABS.active.color }
392 if (r.isBlocked) return { glyph: '⊘', color: TABS.blocked.color }
393 const p = PRIO[r.priority] ?? PRIO[2]!
394 if (r.agent === 'repo') return { glyph: r.id.startsWith('scan:md:') ? '☐' : '✎', color: p.color }
395 return { glyph: r.priority === 0 ? '◉' : r.priority === 3 ? '◌' : '○', color: p.color }
396}
397
398const REPO_COLOR = '#ac8e68'
399
400const AGENT_COLORS = ['#64d2ff', '#ffd60a', '#ff375f', '#66d4cf', '#5e5ce6', '#ac8e68', '#30d158']
401function agentColor(s: string) {
402 let h = 0
403 for (const ch of s) h = (h * 31 + ch.charCodeAt(0)) | 0
404 return AGENT_COLORS[Math.abs(h) % AGENT_COLORS.length]!
405}
406
407const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s)
408hooks/board.ts 136 lines1import type { Item, Row, SessionFile } from '../types'
2
3const HUMAN_RE = /^\s*(\[(you|human|me|user)\]|@(you|me|human|user)\b|(you|human|user):)\s*/i
4const PRIO_RE = /\bP([0-3])\b/i
5const HUMAN_OWNER_RE = /^(you|me|human|user)$/i
6
7/** "[you] P0 rotate the API key" → human, priority 0, "rotate the API key" */
8export function parseTitle(raw: string) {
9 const isHuman = HUMAN_RE.test(raw)
10 const m = raw.match(PRIO_RE)
11 const title = raw.replace(HUMAN_RE, '').replace(PRIO_RE, '').replace(/\s+/g, ' ').trim()
12 return { title: title || raw.trim(), priority: m ? Number(m[1]) : 2, isHuman }
13}
14
15export const isHumanOwner = (owner: unknown) => typeof owner === 'string' && HUMAN_OWNER_RE.test(owner)
16
17const isUnder = (a: string, b: string) => a === b || a.startsWith(b.endsWith('/') ? b : b + '/')
18
19/** Relevant = same project tree, either direction. */
20export const isRelevant = (cwd: string, root: string) => isUnder(cwd, root) || isUnder(root, cwd)
21
22export function matches(row: Row, query: string) {
23 const hay = [
24 row.title, row.note ?? '', row.agent, `p${row.priority}`, row.status,
25 row.isHuman ? 'you human me' : '', row.isBlocked ? 'blocked' : '',
26 ].join(' ').toLowerCase()
27 return query.toLowerCase().split(/\s+/).filter(Boolean).every(t => hay.includes(t))
28}
29
30const open = (i: Item) => i.status !== 'completed'
31
32/**
33 * Open items of relevant sessions, ranked: yours first, then unblocked before
34 * blocked, priority, in-progress, how much each one unblocks.
35 * Agent items die with their session; your items stay until you tick them.
36 */
37/** Scan items belong to the repo, not the session that found them: ticking one off sticks across sessions. */
38export const rowKey = (r: { sessionId: string; id: string }) => (r.id.startsWith('scan:') ? r.id : `${r.sessionId}:${r.id}`)
39
40// The marker must open the line or a comment, so prose like "closes the INTEGRATION TODO in x" is skipped.
41const MARK_RE = /(?:^|\/\/|\/\*|<!--|#|--|;|\*|\{)\s*(TODO|FIXME|HACK|XXX)\b\s*(?:\([^)]*\))?\s*[:\-]?\s*(.*)$/
42const BOX_RE = /^\s*(?:[-*+]|\d+\.)\s+\[ \]\s+(.*)$/
43const CLOSER_RE = /\s*(\*\/|-->|#\}|%>|\}\}|\*\/\})\s*$/
44
45const ROADMAP_RE = /(^|\/)(ROADMAP|NEXT[-_]?TASKS|TODOS?|BACKLOG|TASKS|CHANGELOG-TODO)\.(md|txt)$/i
46
47/**
48 * `git grep -n` output → items: open markdown checkboxes and TODO/FIXME/HACK/XXX comments.
49 * Roadmap-type files keep their priority; checkboxes in other docs (plans, notes) drop to P3.
50 * A line that starts with one of `me` ("Chris:") is the person's.
51 */
52export function parseScan(out: string, opts: { max?: number; me?: string[] } = {}): Item[] {
53 const max = opts.max ?? 300
54 const me = (opts.me ?? []).filter(n => n.length > 1).map(n => n.toLowerCase())
55 const items = new Map<string, Item>()
56 for (const line of out.split('\n')) {
57 const m = line.match(/^(.+?):(\d+):(.*)$/)
58 if (!m) continue
59 const [, rawPath, ln, text] = m as unknown as [string, string, string, string]
60 const path = rawPath.replace(/^\.\//, '')
61 const isMd = /\.(md|markdown|txt)$/i.test(path)
62 const box = isMd ? text.match(BOX_RE) : null
63 const mark = isMd ? null : text.match(MARK_RE) // in prose only checkboxes count
64 const raw = (box?.[1] ?? mark?.[2] ?? '').replace(CLOSER_RE, '').replace(/\*\*|__|`/g, '').trim()
65 if (raw.length < 3 || raw.startsWith('~~')) continue // struck through = done
66 const owner = raw.match(/^([\p{L}][\p{L}'-]*)\s*:\s*/u)
67 const isMine = !!owner && me.includes(owner[1]!.toLowerCase().split(/[-']/)[0]!) // "Chris:", "Chris-only:"
68 const parsed = parseTitle(isMine ? raw.slice(owner![0].length) : raw)
69 if (isMine) parsed.isHuman = true
70 if (box && !ROADMAP_RE.test(path) && parsed.priority === 2) parsed.priority = 3
71 const kind = box ? 'md' : 'code'
72 const id = `scan:${kind}:${path}:${raw.slice(0, 80)}`
73 if (items.has(id)) continue
74 items.set(id, {
75 id,
76 ...parsed,
77 priority: mark?.[1] === 'FIXME' && parsed.priority === 2 ? 1 : parsed.priority,
78 status: 'pending',
79 blockedBy: [],
80 agent: 'repo',
81 note: `${path}:${ln}`,
82 })
83 }
84 // Cap after sorting so a docs/ folder full of plan checkboxes can't crowd out the roadmap and the code.
85 const tier = (i: Item) => (ROADMAP_RE.test(i.note!.replace(/:\d+$/, '')) ? 0 : i.id.startsWith('scan:code:') ? 1 : 2)
86 return [...items.values()].map((i, n) => ({ i, n })).sort((a, b) => tier(a.i) - tier(b.i) || a.n - b.n).slice(0, max).map(x => x.i)
87}
88
89export const VIEWS = ['you', 'urgent', 'active', 'blocked', 'all'] as const
90export type View = (typeof VIEWS)[number]
91
92/** OmniFocus-style perspectives. */
93export function inView(r: Row, view: string) {
94 if (view === 'you') return r.isHuman
95 if (view === 'urgent') return r.priority <= 1
96 if (view === 'active') return r.status === 'in_progress'
97 if (view === 'blocked') return r.isBlocked
98 return true
99}
100
101export function rank(
102 files: SessionFile[], root: string, live: Set<string>, done: Set<string>, prio: Record<string, number> = {},
103): Row[] {
104 const rows: Row[] = []
105 const seen = new Set<string>()
106 for (const f of files) {
107 if (!isRelevant(f.cwd, root)) continue
108 const isLive = live.has(f.sessionId)
109 const items = f.items.filter(i => open(i) && !done.has(rowKey({ sessionId: f.sessionId, id: i.id })))
110 const openIds = new Set(items.map(i => i.id))
111 for (const i of items) {
112 if (!isLive && !i.isHuman) continue
113 const k = rowKey({ sessionId: f.sessionId, id: i.id })
114 if (seen.has(k)) continue // the same repo scanned from two sessions
115 seen.add(k)
116 rows.push({
117 ...i,
118 priority: prio[k] ?? i.priority,
119 sessionId: f.sessionId,
120 cwd: f.cwd,
121 isLive,
122 isBlocked: i.blockedBy.some(id => openIds.has(id)),
123 blocks: items.filter(o => o.blockedBy.includes(i.id)).length,
124 })
125 }
126 }
127 const key = (r: Row) => [
128 r.isHuman ? 0 : 1, r.isBlocked ? 1 : 0, r.priority, r.status === 'in_progress' ? 0 : 1, -r.blocks,
129 ]
130 return rows.sort((a, b) => {
131 const ka = key(a), kb = key(b)
132 for (let n = 0; n < ka.length; n++) if (ka[n] !== kb[n]) return (ka[n] ?? 0) - (kb[n] ?? 0)
133 return a.title.localeCompare(b.title)
134 })
135}
136types/index.d.ts 34 lines1export type Status = 'pending' | 'in_progress' | 'completed'
2
3export type Item = {
4 id: string
5 title: string
6 status: Status
7 /** 0 (urgent) … 3 (someday); 2 when unset */
8 priority: number
9 /** only the human can complete it */
10 isHuman: boolean
11 blockedBy: string[]
12 /** 'main' or the subagent's id */
13 agent: string
14 note?: string
15}
16
17/** ~/.claude/agent-todos/<sessionId>.json — each session writes only its own */
18export type SessionFile = { sessionId: string; cwd: string; updatedAt: number; items: Item[] }
19
20export type Row = Item & {
21 sessionId: string
22 cwd: string
23 isLive: boolean
24 isBlocked: boolean
25 /** open items in the same session waiting on this one */
26 blocks: number
27}
28
29declare module 'claude-code' {
30 interface PluginState {
31 'agent-todos': { rows: Row[]; query: string; root: string; view: string; selected: string }
32 }
33}
34