SLOPSHOPPER

task-list

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

newpanebandguardcommandtool
v?no licenseupdated 2026-10-16vmlinuz82/claude-mods/plugins/task-list
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-list
│ ┃ Plan & tasks ✕ › fix the failing auth test and add an audit log call │ ┃ No superpowers plan in this folder. │ ┃ ✔ Read the responses screen ⏺ Read(src/auth.ts) │ ┃ ◼ Compute the response rate ⎿ Read 6 lines │ ┃ ◻ Add the KPI card ⏺ Update(src/auth.ts) │ ┃ ◻ Write tests ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /task-tree │ ⎿ task-list: Plan & tasks pane opened. │ │ 1/4 done · Computing the response rate… Details – × ✔ Read the responses screen ◼ Compute the response rate ◻ Add the KPI card ◻ Write tests ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
1/4 done · Computing the response rate… Details – × ✔ Read the responses screen ◼ Compute the response rate ◻ Add the KPI card ◻ Write tests
Pane · Plan & tasks
No superpowers plan in this folder. ✔ Read the responses screen ◼ Compute the response rate ◻ Add the KPI card ◻ Write tests
README

task-list

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

The task list above the prompt in the desktop app

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.

Install

claude plugin marketplace add vmlinuz82/claude-mods
claude plugin install task-list@claude-mods

Usage & expected output

  • Ask Claude for anything with a few steps. The checklist appears as it writes its todo list and ticks off as it works.
  • /task-demo fills the checklist with sample tasks, /task-demo clear empties it — handy to check the mod loaded.

Behaviour worth knowing

  • It follows the built-in TaskCreate, TaskUpdate and TodoWrite tools.
  • Where those tools are missing (the desktop app's Code tab), the mod adds its own, todo_write, so Claude can keep a todo list there too. In the terminal it adds nothing.
  • The checklist hides as soon as every task is done.
  • × closes the checklist until the list changes: a new task, or one starting or finishing, brings it back.

Superpowers plans

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.

  • It reads superpowers' own files: the plans (docs/superpowers/plans/*.md, ### Task N: headings) and each plan's ledger (.superpowers/sdd/<plan>/progress.md). Superpowers itself is unchanged.
  • The running plan is the one whose ledger was written last, found in the session's folder or the nearest parent with a .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.
  • Phases come from the plan titles: 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.
  • Superpowers' workspaces are scratch and get cleaned: a plan with no ledger left counts as done when it comes before the running plan (by file name), and to do after it. Ledgers from before superpowers' # SDD ledger — plan: first line are read the same way.
  • The band shows a plan only while it has unfinished tasks and its ledger changed in the last day; the pane keeps showing the tree after that.
  • Superpowers also makes a todo per plan task: those are left out, so each task shows once.
  • The tree is read again after a tool call that mentions the ledger and at the end of every turn.
Source 3 files
hooks/register.tsx 410 lines
1import { 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}
410
hooks/sdd.ts 317 lines
1import 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}
317
types/index.d.ts 62 lines
1export 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