Plan and execute work: resolve consequential design decisions before implementation, then break them into tasks

A cross-agent plugin for planning and executing work: resolve consequential decisions first, then break them into tasks and carry them out.
decision-driven-designInspects an existing system, interviews for high-leverage product and engineering decisions, reconciles revisions, and produces dependency-ordered implementation tasks without speculative abstractions.
tasklistCreates and maintains <DocDir>/TODO.md with unique phase/group/task IDs, dependencies, minimum-model tiers, and standard markers (OBSOLETE, INVALID, SUPERSEDED, DEFERRED, BLOCKED) so any model can pick up a task.
tasklist-runExecutes the open tasks in a tasklist-format TODO.md, one reviewable commit at a time: a subagent on the cheapest adequate model implements each batch, an Opus reviewer checks it against the task's intent, corrections loop back to the implementer, and the passing change is committed before the next batch. Supports one-task runs, named task/group/phase scope, no-commit, confirm-before-commit, and parallel worktrees, and queues token-efficiency fixes in the TODO.
tasklist-monitor (Claude Code mod)Opens a pane during /tasklist-run showing each task's status, tokens, estimated cost, runtime, and completion time, with phase and group subtotals. It polls the TODO from a local timer and never calls the model, so it adds no token cost. Reopen it with /tasklist-monitor.
See the repository installation guide for the complete supported-tool matrix and direct skill installation options.
hooks/register.tsx 169 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { TaskRow, TasklistRun } from '../types'
5import { costOf, fmtClock, fmtCost, fmtDuration, fmtTokens, layout, mergeTodo, summarize, taskIdsIn } from './todo'
6
7const PANE = 'tasklist-monitor'
8const run = atom({ plugin: 'task-workflow', key: 'run' } as const, null)
9const DIRS = ['.', 'docs', 'Docs', 'doc', 'Documentation']
10const ICON = { open: '[ ]', running: '[~]', done: '[x]', closed: '[-]', blocked: '[!]', user: '[u]' } as const
11
12async function findTodo($: any): Promise<string | undefined> {
13 let best: { path: string; mtimeMs: number } | undefined
14 for (const dir of DIRS) {
15 const entries = await $.fs.list(dir).catch(() => [])
16 for (const f of entries) {
17 if (f.kind !== 'file' || !/^TODO.*\.md$/.test(f.name)) continue
18 if (!best || f.mtimeMs > best.mtimeMs) best = { path: dir === '.' ? f.name : `${dir}/${f.name}`, mtimeMs: f.mtimeMs }
19 }
20 }
21 return best?.path
22}
23
24// Runs in the mod's own process on a timer: no model call, so it costs no tokens.
25async function refresh($: any) {
26 const now = await $.clock.now()
27 const current = await read($, run)
28 if (!current?.isActive) return
29 const text = await $.fs.read(current.file).catch(() => undefined)
30 await update($, run, (r: TasklistRun | null) =>
31 r ? (typeof text === 'string' ? mergeTodo(r, text, now) : { ...r, now }) : r,
32 )
33}
34
35export const register: Register = on => {
36 on('session.start', async ($, e, next) => {
37 await $.command.register({
38 name: 'tasklist-monitor',
39 description: 'Show the tasklist-run progress pane',
40 })
41 $.clock.every(5000, () => void refresh($))
42 return next(e)
43 })
44
45 on('command.run', { command: 'tasklist-monitor' }, async $ => {
46 await $.ui.open({ id: PANE, title: 'Tasklist run' })
47 return { text: 'Tasklist pane opened.' }
48 })
49
50 on('skill.prompt', async ($, e, next) => {
51 if (!e.skill.endsWith('tasklist-run')) return next(e)
52 const file = await findTodo($)
53 if (file) {
54 const now = await $.clock.now()
55 const text = await $.fs.read(file).catch(() => '')
56 const base: TasklistRun = {
57 isActive: true, file, startedAt: now, now, tasks: [], agents: {},
58 doneAtStart: [], orchestratorTokens: 0, orchestratorCostUsd: 0,
59 }
60 const first = mergeTodo(base, text, now)
61 const doneAtStart = first.tasks.filter(t => t.status === 'done' || t.status === 'closed').map(t => t.id)
62 await update($, run, () => ({ ...first, doneAtStart }))
63 void $.ui.open({ id: PANE, title: 'Tasklist run' })
64 }
65 return next(e)
66 })
67
68 on('agent.spawn', async ($, e, next) => {
69 const result = await next(e)
70 const ids = taskIdsIn(e.prompt)
71 const current = await read($, run)
72 if (!current?.isActive || ids.length === 0 || result.agentId === undefined) return result
73 const now = await $.clock.now()
74 const role: 'review' | 'implement' = /review/i.test(e.description) ? 'review' : 'implement'
75 const agentId = result.agentId
76 await update($, run, (r: TasklistRun | null) =>
77 r && {
78 ...r,
79 now,
80 agents: { ...r.agents, [agentId]: { taskIds: ids, role } },
81 tasks: r.tasks.map(t =>
82 ids.includes(t.id) && t.status === 'open'
83 ? { ...t, status: 'running' as const, startedAt: t.startedAt ?? now }
84 : t,
85 ),
86 },
87 )
88 return result
89 }).catch(($, e, next) => next(e))
90
91 on('turn.complete', async ($, e, next) => {
92 const current = await read($, run)
93 if (current?.isActive) {
94 const u = e.usage
95 const tokens = u ? u.input_tokens + u.output_tokens + u.cache_creation_input_tokens : 0
96 const cost = u ? costOf(u) : 0
97 const agent = e.agentId === undefined ? undefined : current.agents[e.agentId]
98 if (agent) {
99 await update($, run, (r: TasklistRun | null) =>
100 r && {
101 ...r,
102 tasks: r.tasks.map((t: TaskRow) =>
103 agent.taskIds.includes(t.id)
104 ? { ...t, tokens: t.tokens + tokens / agent.taskIds.length, costUsd: t.costUsd + cost / agent.taskIds.length }
105 : t,
106 ),
107 },
108 )
109 await refresh($)
110 } else if (e.agentId === undefined) {
111 await update($, run, (r: TasklistRun | null) =>
112 r && { ...r, orchestratorTokens: r.orchestratorTokens + tokens, orchestratorCostUsd: r.orchestratorCostUsd + cost },
113 )
114 await refresh($)
115 // the orchestrator's turn ended: the run is over
116 await update($, run, (r: TasklistRun | null) => r && { ...r, isActive: false })
117 }
118 }
119 return next(e)
120 })
121
122 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
123 const { Box, Text } = $.ui.resolve(e)
124 const r = await read($, run)
125 if (!r) return <Text dimColor>No /tasklist-run yet. Run it and this fills in.</Text>
126 const s = summarize(r)
127 const width = (e as any).props?.bodyColumns ?? 80
128 const cols = (tok: number, usd: number, ms: number | undefined, at?: number) =>
129 `${(tok > 0 ? fmtTokens(tok) : '').padStart(7)} ${fmtCost(usd).padStart(7)} ${(ms === undefined ? '' : fmtDuration(ms)).padStart(7)} ${at ? fmtClock(at) : ' '}`
130 const row = (head: string, tail: string) => {
131 const room = Math.max(10, width - tail.length - 1)
132 return head.slice(0, room).padEnd(room) + ' ' + tail
133 }
134 const lines = layout(r.tasks)
135 return (
136 <Box flexDirection="column">
137 <Text bold>
138 {r.isActive ? 'Running' : 'Finished'} {s.done}/{s.total} done · {fmtTokens(s.tokens)} tokens · {fmtCost(s.costUsd) || '$0'} est. · {fmtDuration(s.elapsedMs)}
139 </Text>
140 <Text dimColor>{row('', `${'tokens'.padStart(7)} ${'cost'.padStart(7)} ${'time'.padStart(7)} done `)}</Text>
141 {lines.map(l => {
142 if (l.kind === 'heading') {
143 const ts = l.tasks
144 const started = ts.map(t => t.startedAt).filter((x): x is number => x !== undefined)
145 const isEnded = ts.every(t => t.endedAt !== undefined || t.status === 'closed')
146 const end = isEnded ? Math.max(...ts.map(t => t.endedAt ?? 0)) : r.now
147 const ms = started.length === 0 ? undefined : end - Math.min(...started)
148 return (
149 <Text bold>
150 {row(' '.repeat(l.depth) + l.title, cols(ts.reduce((n, t) => n + t.tokens, 0), ts.reduce((n, t) => n + t.costUsd, 0), ms))}
151 </Text>
152 )
153 }
154 const t = l.task
155 const ms = t.startedAt === undefined ? undefined : (t.endedAt ?? r.now) - t.startedAt
156 return (
157 <Text
158 dimColor={t.status === 'done' || t.status === 'closed'}
159 color={t.status === 'blocked' ? 'red' : t.status === 'running' ? 'yellow' : undefined}
160 >
161 {row(' '.repeat(l.depth) + `${ICON[t.status]} ${t.id} ${t.title}`, cols(t.tokens, t.costUsd, ms, t.endedAt))}
162 </Text>
163 )
164 })}
165 </Box>
166 )
167 })
168}
169hooks/todo.ts 154 lines1import type { TaskRow, TaskStatus, TasklistRun } from '../types'
2
3type Parsed = Pick<TaskRow, 'id' | 'title' | 'phase' | 'group' | 'status'>
4
5const TASK = /^- \[([ x-])\] (T\d+)\s+(.*)$/
6const HEADING = /^#{2,3} ([PG])\d+\b.*$/
7
8export function parseTodo(text: string): Parsed[] {
9 const rows: Parsed[] = []
10 let phase = ''
11 let group = ''
12 for (const line of text.split('\n')) {
13 const heading = HEADING.exec(line)
14 if (heading) {
15 const title = line.replace(/^#+ /, '')
16 if (heading[1] === 'P') {
17 phase = title
18 group = ''
19 } else group = title
20 continue
21 }
22 const task = TASK.exec(line)
23 if (!task) continue
24 const mark = task[1]!, id = task[2]!, rest = task[3]!
25 let status: TaskStatus = 'open'
26 if (mark === 'x') status = 'done'
27 else if (mark === '-') status = 'closed'
28 else if (/^BLOCKED\b/.test(rest)) status = 'blocked'
29 else if (/^USER\b/.test(rest)) status = 'user'
30 rows.push({
31 id,
32 title: rest.replace(/^(BLOCKED\([^)]*\)|IN-PROGRESS|USER)\s*(—|-)?\s*/, ''),
33 phase,
34 group,
35 status,
36 })
37 }
38 return rows
39}
40
41export function taskIdsIn(prompt: string): string[] {
42 const blocks = [...prompt.matchAll(/^- \[[ x-]\] (T\d+)\b/gm)].map(m => m[1]!)
43 return [...new Set(blocks)]
44}
45
46/** Folds a fresh read of the TODO into the run, stamping tasks as they finish. */
47export function mergeTodo(run: TasklistRun, text: string, now: number): TasklistRun {
48 const old = new Map(run.tasks.map(t => [t.id, t]))
49 const running = new Set(Object.values(run.agents).flatMap(a => a.taskIds))
50 const tasks = parseTodo(text).map(p => {
51 const prev = old.get(p.id)
52 const task: TaskRow = { tokens: 0, costUsd: 0, ...prev, ...p }
53 if (p.status === 'done' || p.status === 'closed') {
54 if (prev && prev.status !== p.status && task.endedAt === undefined && !run.doneAtStart.includes(p.id)) task.endedAt = now
55 } else if (p.status === 'open' && running.has(p.id) && task.startedAt !== undefined) {
56 task.status = 'running'
57 }
58 return task
59 })
60 return { ...run, tasks, now }
61}
62
63// USD per million tokens, from the published first-party prices: [input, output, cache read].
64// Cache writes are billed at 1.25x input (5-minute cache); a model not listed costs nothing here.
65const RATES: [RegExp, [number, number, number]][] = [
66 [/fable-5|mythos-5/, [10, 50, 0.25]],
67 [/opus-5-5/, [4, 20, 0.2]],
68 [/opus-(5|4)/, [5, 25, 0.5]],
69 [/sonnet-5/, [2, 10, 0.2]],
70 [/sonnet-4/, [3, 15, 0.3]],
71 [/haiku-5/, [0.1, 0.5, 0.01]],
72 [/haiku-4/, [1, 5, 0.1]],
73]
74
75export type Usage = {
76 model: string
77 input_tokens: number
78 output_tokens: number
79 cache_read_input_tokens: number
80 cache_creation_input_tokens: number
81}
82
83export function costOf(u: Usage): number {
84 const rate = RATES.find(([re]) => re.test(u.model))?.[1]
85 if (!rate) return 0
86 const [inp, out, read] = rate
87 return (
88 (u.input_tokens * inp + u.output_tokens * out + u.cache_read_input_tokens * read + u.cache_creation_input_tokens * inp * 1.25) /
89 1e6
90 )
91}
92
93export type Summary = { done: number; total: number; tokens: number; costUsd: number; elapsedMs: number }
94
95export function summarize(run: TasklistRun): Summary {
96 return {
97 done: run.tasks.filter(t => t.status === 'done').length,
98 total: run.tasks.filter(t => t.status !== 'closed').length,
99 tokens: run.tasks.reduce((n, t) => n + t.tokens, 0) + run.orchestratorTokens,
100 costUsd: run.tasks.reduce((n, t) => n + t.costUsd, 0) + run.orchestratorCostUsd,
101 elapsedMs: run.now - run.startedAt,
102 }
103}
104
105export type Line =
106 | { kind: 'heading'; depth: number; title: string; tasks: TaskRow[] }
107 | { kind: 'task'; depth: number; task: TaskRow }
108
109/** Lays tasks out by whatever hierarchy the list has: phases, groups, both or neither. */
110export function layout(tasks: TaskRow[]): Line[] {
111 const hasPhases = tasks.some(t => t.phase)
112 const hasGroups = tasks.some(t => t.group)
113 const taskDepth = (hasPhases ? 1 : 0) + (hasGroups ? 1 : 0)
114 const lines: Line[] = []
115 let phase: string | undefined
116 let group: string | undefined
117 for (const t of tasks) {
118 if (hasPhases && t.phase !== phase) {
119 phase = t.phase
120 group = undefined
121 lines.push({ kind: 'heading', depth: 0, title: t.phase || 'Other', tasks: tasks.filter(x => x.phase === t.phase) })
122 }
123 if (hasGroups && t.group !== group) {
124 group = t.group
125 lines.push({
126 kind: 'heading',
127 depth: hasPhases ? 1 : 0,
128 title: t.group || 'Other',
129 tasks: tasks.filter(x => x.phase === t.phase && x.group === t.group),
130 })
131 }
132 lines.push({ kind: 'task', depth: taskDepth, task: t })
133 }
134 return lines
135}
136
137export const fmtTokens = (n: number) =>
138 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(Math.round(n))
139
140export const fmtCost = (n: number) => (n === 0 ? '' : n < 0.01 ? '<$0.01' : `$${n.toFixed(2)}`)
141
142export function fmtDuration(ms: number) {
143 const s = Math.max(0, Math.round(ms / 1000))
144 const h = Math.floor(s / 3600)
145 const m = Math.floor((s % 3600) / 60)
146 return h > 0 ? `${h}h${String(m).padStart(2, '0')}m` : m > 0 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
147}
148
149export function fmtClock(ms: number) {
150 const d = new Date(ms)
151 const p = (n: number) => String(n).padStart(2, '0')
152 return `${p(d.getHours())}:${p(d.getMinutes())}`
153}
154types/index.d.ts 36 lines1export type TaskStatus = 'open' | 'running' | 'done' | 'closed' | 'blocked' | 'user'
2
3export type TaskRow = {
4 id: string
5 title: string
6 /** Phase heading (`## P1 ...`), empty when the list has none. */
7 phase: string
8 /** Group heading (`### G1 ...`), empty when the list has none. */
9 group: string
10 status: TaskStatus
11 tokens: number
12 costUsd: number
13 startedAt?: number
14 endedAt?: number
15}
16
17export type AgentRow = { taskIds: string[]; role: 'implement' | 'review' }
18
19export type TasklistRun = {
20 isActive: boolean
21 file: string
22 startedAt: number
23 now: number
24 tasks: TaskRow[]
25 agents: Record<string, AgentRow>
26 doneAtStart: string[]
27 orchestratorTokens: number
28 orchestratorCostUsd: number
29}
30
31declare module 'claude-code' {
32 interface PluginState {
33 'task-workflow': { run: TasklistRun | null }
34 }
35}
36