Shows the session's tasks as a CLI-style checklist above the prompt

Draws the session's tasks as a checklist above the prompt:

The first line counts the finished tasks and says what Claude is doing now. A finished task is dimmed and struck through, the one in progress is bold, and the rest wait with an empty box.
claude plugin marketplace add vmlinuz82/claude-mods
claude plugin install task-list@claude-mods
/task-demo fills the checklist with sample tasks, /task-demo clear empties it — handy to check the mod loaded.TaskCreate, TaskUpdate and TodoWrite tools.todo_write, so Claude can keep a todo list there too. In the terminal it adds nothing.× closes the checklist until the list changes: a new task, or one starting or finishing, brings it back.While superpowers runs a plan with subagent-driven development, the band shows that plan, its tasks and the todos of the task in progress:
◼ Plan 3 — Frontend Foundation · 1/7 tasks Details – ×
✔ Task 1 Workspace, shared tooling and packages/ui
◼ Task 2 packages/api — OpenAPI types and the client · fix round 1
◼ Run the type generation
◻ Wire the session client
◻ Task 3 The three app shells
– folds the band to one line, where the work is: □ unfolds it. It stays folded for the rest of the session; every new session starts unfolded.
Phase 7 (7/7) \ Plan (3/3) \ Task (3/7) The three app shells · fix round 1 Details □ ×
Without a phase the line starts at Plan; with only a todo list it reads Todo (2/4) Build.
Details (or /task-tree) opens a pane with the whole tree: every phase and plan, and the running plan's phase down to each task, done, current and to do.
docs/superpowers/plans/*.md, ### Task N: headings) and each plan's ledger (.superpowers/sdd/<plan>/progress.md). Superpowers itself is unchanged..superpowers folder. A task is done at its Task N: complete line; a task in progress shows its latest step (implementing, review, fix round 2…). A task left open while a later one has started (its fixes deferred) shows ◐ … · on hold; the bold ◼ stays on the task being worked on.Phase 7 · Plan 3 — …, … Phase 4 — … and … (Phase 1.5) all work. A sub-phase joins its main phase (3a–3e are Phase 3, 1.5 is Phase 1) and keeps its label in the plan name: 3e · Affiliate Links. Plans without a phase are listed together.# SDD ledger — plan: first line are read the same way.hooks/register.tsx 410 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput } from 'claude-code'
3
4import type { PlanTask, PlanView, Task, Todo } from '../types'
5import { breadcrumb, isPlanTodo, loadPlanView } from './sdd'
6
7const tasks = atom({ plugin: 'task-list', key: 'tasks' } as const, [])
8const closedOn = atom({ plugin: 'task-list', key: 'closedOn' } as const, null)
9const plan = atom({ plugin: 'task-list', key: 'plan' } as const, null)
10const cwd = atom({ plugin: 'task-list', key: 'cwd' } as const, null)
11// The band folded to one line, for this session only: every new session starts expanded
12const isMinimized = atom({ plugin: 'task-list', key: 'isMinimized' } as const, false)
13
14const snapshot = (running: PlanView | null, list: readonly Task[]): string => JSON.stringify({ running, list })
15
16// A tool call that may have written a superpowers ledger: superpowers appends to it from Bash
17const touchesLedger = (input: unknown): boolean => /\.superpowers|progress\.md|sdd-workspace/.test(JSON.stringify(input))
18
19const refreshPlan = async ($: EngineInterface): Promise<void> => {
20 const dir = await read($, cwd)
21 if (dir === null) {
22 return
23 }
24
25 const running = await loadPlanView(
26 {
27 read: path => $.fs.read(path).catch(() => null),
28 list: path => $.fs.list(path).catch(() => []),
29 exists: path => $.fs.exists(path).catch(() => false),
30 },
31 dir,
32 await $.clock.now(),
33 )
34 await update($, plan, () => running)
35}
36
37const MARK = { pending: '◻', in_progress: '◼', completed: '✔', on_hold: '◐' } as const
38
39const PANE = 'task-list'
40const PANE_TITLE = 'Plan & tasks'
41
42// Sessions without the built-in task tools (the desktop app's Code tab) get this one instead
43const BUILT_IN_TASK_TOOLS = ['TaskCreate', 'TodoWrite']
44const OWN_TOOL = 'mcp__task-list__todo_write'
45
46const isTodo = (value: unknown): value is Todo => {
47 if (typeof value !== 'object' || value === null) {
48 return false
49 }
50
51 const { content, status, activeForm } = value as Record<string, unknown>
52
53 return (
54 typeof content === 'string' &&
55 typeof status === 'string' &&
56 status in MARK &&
57 (activeForm === undefined || typeof activeForm === 'string')
58 )
59}
60
61const fromTodos = (todos: readonly Todo[]): Task[] =>
62 todos.map((todo, index) => ({
63 id: `todo-${index}`,
64 subject: todo.content,
65 activeForm: todo.activeForm,
66 status: todo.status,
67 }))
68
69const DEMO: Task[] = [
70 { id: 'demo-1', subject: 'Read the responses screen', status: 'completed' },
71 { id: 'demo-2', subject: 'Compute the response rate', activeForm: 'Computing the response rate', status: 'in_progress' },
72 { id: 'demo-3', subject: 'Add the KPI card', status: 'pending' },
73 { id: 'demo-4', subject: 'Write tests', status: 'pending' },
74]
75
76export const register: Register = on => {
77 on('session.start', async ($, e, next) => {
78 await $.command.register({
79 name: 'task-tree',
80 description: 'Show the whole superpowers plan tree (phases, plans, tasks, todos) in a pane',
81 })
82 await $.command.register({
83 name: 'task-demo',
84 description: 'Fill the task band with sample tasks; `/task-demo clear` empties it',
85 })
86
87 const tools = await $.tool.list()
88 if (!tools.some(one => BUILT_IN_TASK_TOOLS.includes(one.name))) {
89 await $.tool.register({
90 name: 'todo_write',
91 description:
92 'Create and update the todo list for the current work; the person sees it as a checklist above the prompt. ' +
93 'Use it for any task with three or more steps: write the whole list at the start, keep exactly one item ' +
94 'in_progress, and send the whole list again each time an item starts or finishes. Every call replaces the list.',
95 inputSchema: {
96 type: 'object',
97 properties: {
98 todos: {
99 type: 'array',
100 items: {
101 type: 'object',
102 properties: {
103 content: { type: 'string', description: 'What to do, in the imperative: "Run the tests"' },
104 activeForm: { type: 'string', description: 'The same, while it runs: "Running the tests"' },
105 status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
106 },
107 required: ['content', 'status', 'activeForm'],
108 },
109 },
110 },
111 required: ['todos'],
112 },
113 })
114 }
115
116 await update($, cwd, () => e.cwd)
117 await refreshPlan($)
118
119 return next(e)
120 })
121
122 // A superpowers plan's progress lives in its ledger: read it again after a call that may have
123 // written it, and at the end of every turn
124 on('tool.call', async ($, e, next) => {
125 const ran = await next(e)
126 if (touchesLedger(e)) {
127 await refreshPlan($)
128 }
129
130 return ran
131 })
132
133 on('turn.complete', async ($, e, next) => {
134 await refreshPlan($)
135
136 return next(e)
137 })
138
139 on('tool.call', { tool: OWN_TOOL }, async ($, e) => {
140 // The model's input is not checked against the schema on the way in
141 const todos: unknown = e.todos
142 if (!Array.isArray(todos) || !todos.every(isTodo)) {
143 return { deny: 'todos must be a list of { content, status, activeForm }, status one of pending, in_progress, completed' }
144 }
145
146 await update($, tasks, () => fromTodos(todos))
147 const done = todos.filter(todo => todo.status === 'completed').length
148
149 return { result: `Todo list updated: ${done}/${todos.length} done.` }
150 })
151
152 on('command.run', { command: 'task-demo' }, async ($, e) => {
153 if (e.args.trim() === 'clear') {
154 await update($, tasks, () => [])
155
156 return { text: 'Task band cleared.' }
157 }
158
159 await update($, tasks, () => DEMO)
160
161 return { text: 'Sample tasks added above the prompt.' }
162 })
163
164 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
165 const ran = await next(e)
166 if (ran.isError || !ran.result) {
167 return ran
168 }
169
170 const task: Task = {
171 id: ran.result.task.id,
172 subject: e.subject,
173 activeForm: e.activeForm,
174 status: 'pending',
175 }
176 await update($, tasks, list => [...list.filter(one => one.id !== task.id), task])
177
178 return ran
179 })
180
181 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
182 const ran = await next(e)
183 if (ran.isError || !ran.result?.success) {
184 return ran
185 }
186
187 const { taskId, status, subject, activeForm } = e
188 if (status === 'deleted') {
189 await update($, tasks, list => list.filter(one => one.id !== taskId))
190
191 return ran
192 }
193
194 await update($, tasks, list =>
195 list.map(one =>
196 one.id !== taskId
197 ? one
198 : {
199 ...one,
200 status: status ?? one.status,
201 subject: subject ?? one.subject,
202 activeForm: activeForm ?? one.activeForm,
203 },
204 ),
205 )
206
207 return ran
208 })
209
210 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
211 const ran = await next(e)
212 if (ran.isError) {
213 return ran
214 }
215
216 await update($, tasks, () => fromTodos(e.todos))
217
218 return ran
219 })
220
221 on('command.run', { command: 'task-tree' }, async $ => {
222 await refreshPlan($)
223 await $.ui.open({ id: PANE, title: PANE_TITLE })
224
225 return { text: `${PANE_TITLE} pane opened.` }
226 })
227
228 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
229 const view = await read($, plan)
230 // The band follows a plan only while it runs; a finished one stays in the pane
231 const running = view?.isRunning ? view : null
232 const list = withoutPlanTodos(await read($, tasks), running)
233 const done = list.filter(one => one.status === 'completed').length
234 // A finished list is history, not current work, so it hides even while Claude keeps working
235 const hasTodos = list.length > 0 && done < list.length
236
237 // Closed by the person: hidden until the plan or the list changes
238 const isClosed = (await read($, closedOn)) === snapshot(running, list)
239
240 if (e.props.hasSurvey || (running === null && !hasTodos) || isClosed) {
241 return next(e)
242 }
243
244 const { Box, Button, Text } = $.ui.resolve(e)
245 const active = list.find(one => one.status === 'in_progress')
246 const minimized = await read($, isMinimized)
247 const setMinimized = (value: boolean) => update($, isMinimized, () => value)
248 // Minimized: one line naming where the work is; the todo in progress, else the next to do
249 const current = active ?? list.find(one => one.status !== 'completed')
250 const summary = minimized
251 ? running === null
252 ? `Todo (${current ? list.indexOf(current) + 1 : 0}/${list.length}) ${current?.subject ?? ''}`
253 : breadcrumb(running)
254 : running === null
255 ? `${done}/${list.length} done${active?.activeForm ? ` · ${active.activeForm}…` : ''}`
256 : `${MARK.in_progress} ${planSummary(running.name, running.tasks)}`
257
258 const mine = (
259 <Box key="task-list" flexDirection="column">
260 <Box key="head" flexDirection="row" justifyContent="space-between" columnGap={2}>
261 <Text key="summary" bold={running !== null} dimColor={running === null} wrap="truncate-end">
262 {summary}
263 </Text>
264 <Box key="actions" flexDirection="row" columnGap={2}>
265 <Button key="details" label="Details" plain dimColor onPress={() => $.ui.open({ id: PANE, title: PANE_TITLE })} />
266 {/* Minimize and close sit together at the right edge, as a window's controls do */}
267 <Box key="window" flexDirection="row" columnGap={1}>
268 {minimized ? (
269 <Button key="restore" label="□" plain dimColor onPress={() => setMinimized(false)} />
270 ) : (
271 <Button key="minimize" label="–" plain dimColor onPress={() => setMinimized(true)} />
272 )}
273 <Button
274 key="close"
275 label="×"
276 role="dismiss"
277 plain
278 dimColor
279 onPress={() => update($, closedOn, () => snapshot(running, list))}
280 />
281 </Box>
282 </Box>
283 </Box>
284 {!minimized && (running === null ? todoLines($, e, list) : taskTree($, e, running.tasks, hasTodos ? list : []))}
285 </Box>
286 )
287
288 // Plugins of one tier nest in no fixed order: another mod's band beneath this one (skill-usage)
289 // stays, under the task list; the engine's own placeholder is left out
290 const below = await next(e)
291 if (below.type === 'engine') {
292 return mine
293 }
294
295 // Two bands stacked: each framed like the prompt box, a row apart, so they read as two
296 return (
297 <Box flexDirection="column" rowGap={1}>
298 <Box key="upper" flexDirection="column" borderStyle="round" borderColor="promptBorder" paddingX={1}>
299 {mine}
300 </Box>
301 <Box key="lower" flexDirection="column" borderStyle="round" borderColor="promptBorder" paddingX={1}>
302 {below}
303 </Box>
304 </Box>
305 )
306 })
307
308 // The whole tree: every phase and plan; the running plan's phase with each plan's tasks, done and to do
309 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
310 const { Box, Text } = $.ui.resolve(e)
311 const view = await read($, plan)
312 const list = withoutPlanTodos(await read($, tasks), view)
313
314 if (view === null) {
315 return (
316 <Box flexDirection="column">
317 <Text key="none" dimColor>
318 No superpowers plan in this folder.
319 </Text>
320 {todoLines($, e, list)}
321 </Box>
322 )
323 }
324
325 return (
326 <Box flexDirection="column">
327 {view.groups.map((group, index) => {
328 const plansDone = group.plans.filter(row => row.status === 'completed').length
329 const isCurrentGroup = group.plans.some(row => row.isCurrent)
330
331 return (
332 <Box key={`group-${index}`} flexDirection="column" marginBottom={1}>
333 <Text key="head" bold>
334 {group.phase === null ? 'Plans' : `Phase ${group.phase}`} · {plansDone}/{group.plans.length} plans done
335 </Text>
336 {group.plans.map((row, at) => (
337 <Box key={`plan-${at}`} flexDirection="column" paddingLeft={2}>
338 <Text
339 key="line"
340 bold={row.isCurrent}
341 dimColor={row.status === 'completed'}
342 wrap="truncate-end"
343 >
344 {MARK[row.status]} {planSummary(row.name, row.tasks)}
345 </Text>
346 {isCurrentGroup && taskTree($, e, row.tasks, row.isCurrent ? list : [])}
347 </Box>
348 ))}
349 </Box>
350 )
351 })}
352 </Box>
353 )
354 })
355}
356
357// The plan's todos repeat its tasks (superpowers makes one per task): the tree shows them once
358const withoutPlanTodos = (list: readonly Task[], view: PlanView | null): Task[] =>
359 view === null ? [...list] : list.filter(task => !isPlanTodo(task.subject, view.tasks))
360
361const planSummary = (name: string, planned: readonly PlanTask[]): string =>
362 `${name} · ${planned.filter(task => task.status === 'completed').length}/${planned.length} tasks`
363
364const todoLines = ($: EngineInterface, e: RenderInput, list: readonly Task[]) => {
365 const { Text } = $.ui.resolve(e)
366
367 return list.map(task => (
368 <Text
369 key={task.id}
370 bold={task.status === 'in_progress'}
371 dimColor={task.status === 'completed'}
372 strikethrough={task.status === 'completed'}
373 >
374 {MARK[task.status]} {task.subject}
375 </Text>
376 ))
377}
378
379// A plan's tasks, the todos under the task in progress (after the tasks when none is)
380const taskTree = ($: EngineInterface, e: RenderInput, planned: readonly PlanTask[], todos: readonly Task[]) => {
381 const { Box, Text } = $.ui.resolve(e)
382 const activeTask = planned.find(task => task.status === 'in_progress')
383 const todoBox = todos.length > 0 && (
384 <Box key="todos" flexDirection="column" paddingLeft={2}>
385 {todoLines($, e, todos)}
386 </Box>
387 )
388
389 return (
390 <Box key="tasks" flexDirection="column" paddingLeft={2}>
391 {planned.map(task => (
392 <Box key={`task-${task.n}`} flexDirection="column">
393 <Text
394 key="line"
395 bold={task.status === 'in_progress'}
396 dimColor={task.status === 'completed'}
397 wrap="truncate-end"
398 >
399 {MARK[task.status]} Task {task.n} {task.title}
400 {task.step ? ` · ${task.step}` : ''}
401 {task.status === 'on_hold' ? ' · on hold' : ''}
402 </Text>
403 {task === activeTask && todoBox}
404 </Box>
405 ))}
406 {activeTask === undefined && todoBox}
407 </Box>
408 )
409}
410hooks/sdd.ts 317 lines1import type { PhaseGroup, PlanRow, PlanTask, PlanView, TaskStatus } from '../types'
2
3// Reads superpowers' subagent-driven-development files: the plan
4// (docs/superpowers/plans/*.md, `### Task N: title` headings) and the plan's ledger
5// (.superpowers/sdd/<plan>/progress.md, `Task N: …` lines, `Task N: complete` when done)
6
7export type PlanHeading = { n: number; title: string }
8export type LedgerTask = { isComplete: boolean; step?: string }
9export type Ledger = { plan: string | null; tasks: Record<number, LedgerTask> }
10
11// What the loader reads the disk with; never rejects: a missing path is null, [] or false
12export type Files = {
13 read: (path: string) => Promise<string | null>
14 list: (path: string) => Promise<{ name: string; kind: string; mtimeMs: number }[]>
15 exists: (path: string) => Promise<boolean>
16}
17
18const TASK_HEADING = /^#+\s+Task\s+(\d+)\b[:.\s—–-]*(.*)$/
19const LEDGER_IDENTITY = /^# SDD ledger — plan:\s*(.+?)\s*$/
20// A ledger line, bare or as a list item (`- Task 3: …`): controllers write it both ways
21const LEDGER_TASK = /^\s*(?:[-*+]\s+)?Task\s+(\d+)\b(.*)$/
22const PHASE_IN_PARENS = /\s*\(Phase\s+([0-9][\w.]*)\)/
23const PHASE_PREFIX = /^.*?\bPhase\s+([0-9][\w.]*)\s*[·—–:-]?\s*(.*)$/
24const IMPLEMENTATION_PLAN = /[\s—–-]*Implementation Plan\s*$/
25
26// A ledger line's step, most specific first; null for a line that is no step (minor, ruling, carry)
27const STEPS: [RegExp, (match: RegExpMatchArray) => string][] = [
28 [/\bfix round (\d+)/i, match => `fix round ${match[1]}`],
29 [/\bre-review\b/i, () => 're-review'],
30 [/\breview\b/i, () => 'review'],
31 [/\b(dispatched|started)\b/i, () => 'implementing'],
32 [/\bimplement(er|ed)\b/i, () => 'implemented'],
33]
34
35const stepOf = (rest: string): string | null => {
36 for (const [pattern, name] of STEPS) {
37 const match = rest.match(pattern)
38 if (match) {
39 return name(match)
40 }
41 }
42
43 return null
44}
45
46const plainTitle = (title: string): string => title.replace(/`/g, '').trim()
47
48export const parsePlan = (text: string): { title: string; tasks: PlanHeading[] } => {
49 let title = ''
50 let isInFence = false
51 const tasks: PlanHeading[] = []
52
53 for (const line of text.split('\n')) {
54 if (line.startsWith('```')) {
55 isInFence = !isInFence
56 continue
57 }
58 if (isInFence) {
59 continue
60 }
61 if (title === '' && line.startsWith('# ')) {
62 title = line.slice(2).trim()
63 continue
64 }
65
66 const heading = line.match(TASK_HEADING)
67 if (heading) {
68 tasks.push({ n: Number(heading[1]), title: plainTitle(heading[2] ?? '') })
69 }
70 }
71
72 return { title, tasks }
73}
74
75export const parseLedger = (text: string): Ledger => {
76 const lines = text.split('\n')
77 const plan = lines[0]?.match(LEDGER_IDENTITY)?.[1] ?? null
78 const tasks: Record<number, LedgerTask> = {}
79
80 for (const line of lines) {
81 const match = line.match(LEDGER_TASK)
82 // `Task 1 → Task 2 carry: …` is a note between tasks, not a step of either
83 if (!match || line.includes('→')) {
84 continue
85 }
86
87 const n = Number(match[1])
88 const rest = match[2] ?? ''
89 const task = tasks[n] ?? { isComplete: false }
90 if (/^\s*:\s*complete\b/i.test(rest)) {
91 tasks[n] = { ...task, isComplete: true }
92 continue
93 }
94
95 const step = stepOf(rest)
96 tasks[n] = step === null ? task : { ...task, step }
97 }
98
99 return { plan, tasks }
100}
101
102// A sub-phase (3e, 1.5) belongs to its main phase (3, 1); its own label stays at the front of the name
103const inMainPhase = (label: string, name: string): { phase: string; name: string } => {
104 const main = label.match(/^\d+/)?.[0] ?? label
105
106 return { phase: main, name: label === main ? name : `${label} · ${name}` }
107}
108
109export const splitTitle = (title: string): { phase: string | null; name: string } => {
110 const bare = title.replace(IMPLEMENTATION_PLAN, '')
111 const inParens = bare.match(PHASE_IN_PARENS)
112 if (inParens?.[1]) {
113 return inMainPhase(inParens[1], bare.replace(PHASE_IN_PARENS, '').trim())
114 }
115
116 const prefixed = bare.match(PHASE_PREFIX)
117 if (prefixed?.[1]) {
118 return inMainPhase(prefixed[1], (prefixed[2] ?? '').trim())
119 }
120
121 return { phase: null, name: bare.trim() }
122}
123
124export const planTasks = (headings: readonly PlanHeading[], ledger: Ledger): PlanTask[] => {
125 const tasks = headings.map(({ n, title }): PlanTask => {
126 const entry = ledger.tasks[n]
127 if (entry?.isComplete) {
128 return { n, title, status: 'completed' }
129 }
130 if (entry === undefined) {
131 return { n, title, status: 'pending' }
132 }
133
134 return entry.step === undefined ? { n, title, status: 'in_progress' } : { n, title, status: 'in_progress', step: entry.step }
135 })
136 // A controller may leave a task open and start the next (its fixes deferred): the newest started
137 // task is the one being worked on, an earlier open one is on hold
138 const newest = Math.max(...tasks.filter(task => task.status === 'in_progress').map(task => task.n))
139
140 return tasks.map(task => (task.status === 'in_progress' && task.n < newest ? { ...task, status: 'on_hold' } : task))
141}
142
143const normalized = (text: string): string => text.trim().toLowerCase()
144
145// Superpowers makes a todo per plan task; those repeat the tree and are left out of it
146export const isPlanTodo = (content: string, tasks: readonly PlanTask[]): boolean =>
147 /\btask\s+\d+\b/i.test(content) || tasks.some(task => normalized(task.title) === normalized(content))
148
149const planStatus = (tasks: readonly PlanTask[], ledger: Ledger | null): TaskStatus => {
150 if (tasks.length > 0 && tasks.every(task => task.status === 'completed')) {
151 return 'completed'
152 }
153
154 return ledger !== null && Object.keys(ledger.tasks).length > 0 ? 'in_progress' : 'pending'
155}
156
157const parentOf = (path: string): string => path.slice(0, Math.max(path.lastIndexOf('/'), 0)) || '/'
158const nameOf = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
159
160// The folder holding .superpowers/sdd: the working directory or the nearest parent with one
161const findRoot = async (files: Files, cwd: string): Promise<string | null> => {
162 let dir = cwd
163 for (;;) {
164 if (await files.exists(`${dir}/.superpowers/sdd`)) {
165 return dir
166 }
167 const parent = parentOf(dir)
168 if (parent === dir) {
169 return null
170 }
171 dir = parent
172 }
173}
174
175// The ledger written last that names its plan: superpowers runs one plan at a time, and a ledger
176// without the `# SDD ledger — plan:` line predates the format, its tasks unreadable
177const newestLedger = async (files: Files, sdd: string): Promise<{ ledger: Ledger; mtimeMs: number } | null> => {
178 const dirs = (await files.list(sdd)).filter(entry => entry.kind === 'dir')
179 const ledgers: { path: string; mtimeMs: number }[] = []
180 for (const dir of dirs) {
181 const entries = await files.list(`${sdd}/${dir.name}`)
182 const ledger = entries.find(file => file.name === 'progress.md' && file.kind === 'file')
183 if (ledger) {
184 ledgers.push({ path: `${sdd}/${dir.name}/progress.md`, mtimeMs: ledger.mtimeMs })
185 }
186 }
187
188 for (const { path, mtimeMs } of ledgers.sort((a, b) => b.mtimeMs - a.mtimeMs)) {
189 const text = await files.read(path)
190 const ledger = text === null ? null : parseLedger(text)
191 if (ledger?.plan) {
192 return { ledger, mtimeMs }
193 }
194 }
195
196 return null
197}
198
199// Every plan next to the running one, in file name (date) order, each with its own ledger's tasks
200const allPlans = async (
201 files: Files,
202 root: string,
203 current: { path: string; ledger: Ledger },
204): Promise<(PlanRow & { phase: string | null })[]> => {
205 const dir = parentOf(current.path)
206 const names = (await files.list(dir))
207 .filter(entry => entry.kind === 'file' && entry.name.endsWith('.md'))
208 .map(entry => entry.name)
209 .sort()
210 const rows: (PlanRow & { phase: string | null })[] = []
211 // Superpowers' workspaces are scratch and often cleaned: a plan without a ledger before the
212 // running one was run before it, one after it is still to do
213 let isBeforeCurrent = names.some(file => `${dir}/${file}` === current.path)
214
215 for (const file of names) {
216 const text = await files.read(`${dir}/${file}`)
217 if (text === null) {
218 continue
219 }
220 const plan = parsePlan(text)
221 const { phase, name } = splitTitle(plan.title)
222 const isCurrent = `${dir}/${file}` === current.path
223 if (isCurrent) {
224 isBeforeCurrent = false
225 }
226 // The running plan's ledger is known; another plan's sits in the workspace named after its file
227 const ledgerText = isCurrent ? null : await files.read(`${root}/.superpowers/sdd/${file.replace(/\.md$/, '')}/progress.md`)
228 const parsed = ledgerText === null ? null : parseLedger(ledgerText)
229 // A ledger from before the identity line can't be read: such a plan counts as having none
230 const ledger = isCurrent ? current.ledger : parsed?.plan ? parsed : null
231 const tasks =
232 ledger === null && isBeforeCurrent
233 ? plan.tasks.map(({ n, title }): PlanTask => ({ n, title, status: 'completed' }))
234 : planTasks(plan.tasks, ledger ?? { plan: null, tasks: {} })
235 rows.push({ phase, name: name || file, status: planStatus(tasks, ledger), isCurrent, tasks })
236 }
237
238 return rows
239}
240
241// Plans grouped by phase; plans without one share a group
242const byPhase = (rows: readonly (PlanRow & { phase: string | null })[]): PhaseGroup[] => {
243 const groups: PhaseGroup[] = []
244 for (const { phase, ...row } of rows) {
245 const group = groups.find(one => one.phase === phase)
246 if (group) {
247 group.plans.push(row)
248 continue
249 }
250 groups.push({ phase, plans: [row] })
251 }
252
253 // Phases in number order whatever the files' dates; the plans without a phase last
254 return groups.sort((a, b) => (a.phase === null ? Infinity : Number(a.phase)) - (b.phase === null ? Infinity : Number(b.phase)))
255}
256
257// A plan whose ledger hasn't changed for a day is left, not running
258const RUNNING_WINDOW_MS = 24 * 60 * 60 * 1000
259
260// The newest ledger's plan and every plan beside it, or null when there is no superpowers plan
261export const loadPlanView = async (files: Files, cwd: string, now: number): Promise<PlanView | null> => {
262 const root = await findRoot(files, cwd)
263 if (root === null) {
264 return null
265 }
266
267 const newest = await newestLedger(files, `${root}/.superpowers/sdd`)
268 const named = newest?.ledger.plan
269 if (newest === null || !named) {
270 return null
271 }
272
273 const { ledger, mtimeMs } = newest
274 const planPath = named.startsWith('/') ? named : `${root}/${named}`
275 const planText = await files.read(planPath)
276 if (planText === null) {
277 return null
278 }
279
280 const plan = parsePlan(planText)
281 const tasks = planTasks(plan.tasks, ledger)
282 const { phase, name } = splitTitle(plan.title)
283 const groups = byPhase(await allPlans(files, root, { path: planPath, ledger }))
284
285 return {
286 // A finished or left plan is history, as a finished todo list is: the band hides it, the pane keeps it
287 isRunning: tasks.length > 0 && planStatus(tasks, ledger) !== 'completed' && now - mtimeMs < RUNNING_WINDOW_MS,
288 phase,
289 name: name || nameOf(planPath),
290 tasks,
291 groups,
292 }
293}
294
295// The running task in one line: Phase 7 (13/13) \ Plan (3/4) \ Task (3/7) title · step. The task is
296// the one in progress, else the next to do; a plan without a phase starts at Plan
297export const breadcrumb = (view: PlanView): string => {
298 const phases = view.groups.filter(group => group.phase !== null)
299 const group = view.groups.find(one => one.plans.some(row => row.isCurrent))
300 const planAt = group ? group.plans.findIndex(row => row.isCurrent) + 1 : 1
301 const planCount = group ? group.plans.length : 1
302 const task =
303 view.tasks.find(one => one.status === 'in_progress') ??
304 view.tasks.find(one => one.status === 'pending') ??
305 view.tasks[view.tasks.length - 1]
306 const taskAt = task ? view.tasks.indexOf(task) + 1 : 0
307 const crumbs = [
308 `Plan (${planAt}/${planCount})`,
309 `Task (${taskAt}/${view.tasks.length})${task ? ` ${task.title}${task.step ? ` · ${task.step}` : ''}` : ''}`,
310 ]
311 if (view.phase !== null && group) {
312 crumbs.unshift(`Phase ${view.phase} (${phases.indexOf(group) + 1}/${phases.length})`)
313 }
314
315 return crumbs.join(' \\ ')
316}
317types/index.d.ts 62 lines1export type TaskStatus = 'pending' | 'in_progress' | 'completed'
2
3export type Task = {
4 id: string
5 subject: string
6 activeForm?: string
7 status: TaskStatus
8}
9
10export type Todo = {
11 content: string
12 status: TaskStatus
13 activeForm?: string
14}
15
16// One task of a superpowers plan, its status from the subagent-driven-development ledger
17export type PlanTask = {
18 n: number
19 title: string
20 // on_hold: started and unfinished while a later task is already under way
21 status: TaskStatus | 'on_hold'
22 // The latest ledger step of a task in progress: implementing, review, fix round 2…
23 step?: string
24}
25
26// One plan in the plans folder, with its tasks as its own ledger has them
27export type PlanRow = {
28 name: string
29 status: TaskStatus
30 isCurrent: boolean
31 tasks: PlanTask[]
32}
33
34// The plans of one phase, in file name order; phase null holds the plans without a phase
35export type PhaseGroup = {
36 phase: string | null
37 plans: PlanRow[]
38}
39
40// The plan of the newest superpowers ledger and every plan beside it
41export type PlanView = {
42 // false once every task of the plan is complete: the band hides it, the pane keeps it
43 isRunning: boolean
44 phase: string | null
45 name: string
46 tasks: PlanTask[]
47 groups: PhaseGroup[]
48}
49
50declare module 'claude-code' {
51 interface PluginState {
52 // closedOn: what the band showed when it was closed; the band stays hidden until it changes
53 // isMinimized: the band folded to one breadcrumb line
54 'task-list': { tasks: Task[]; closedOn: string | null; plan: PlanView | null; cwd: string | null; isMinimized: boolean }
55 }
56
57 interface McpToolInputs {
58 /** The mod's own todo tool, registered where the built-in task tools are missing */
59 'mcp__task-list__todo_write': { todos: Todo[] }
60 }
61}
62