SLOPSHOPPER

task-workflow

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

newpanecommandprompttimeragents
v0.5.0GPL-3.0-or-laterupdated 2026-10-09dannys42/agent-skills/plugins/task-workflow
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-workflow
│ ┃ Tasklist run ✕ › fix the failing auth test and add an audit log call │ ┃ No /tasklist-run yet. Run it and this fills │ ┃ in. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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 │ │ › /tasklist-monitor │ ⎿ task-workflow: Tasklist pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tasklist run
No /tasklist-run yet. Run it and this fills in.
README

task-workflow

A cross-agent plugin for planning and executing work: resolve consequential decisions first, then break them into tasks and carry them out.

Skills

decision-driven-design

Inspects an existing system, interviews for high-leverage product and engineering decisions, reconciles revisions, and produces dependency-ordered implementation tasks without speculative abstractions.

tasklist

Creates 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-run

Executes 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.

Install

See the repository installation guide for the complete supported-tool matrix and direct skill installation options.

Source 3 files
hooks/register.tsx 169 lines
1import { 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}
169
hooks/todo.ts 154 lines
1import 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}
154
types/index.d.ts 36 lines
1export 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