SLOPSHOPPER

implement-plan-tracker

Live pane with every step of the current plan, the current step marked, and a done/total count

newpaneguardcommandtoaststatus
v0.2.0no licenseupdated 2026-10-03hoangbits/claude-implement-plan-tracker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · implement-plan-tracker
│ ┃ Plan ✕ › fix the failing auth test and add an audit log call │ ┃ No plan yet. │ ┃ It shows the task list, or the newest ⏺ Read(src/auth.ts) │ ┃ OpenSpec tasks.md. ⎿ 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 │ │ › /plan │ ⎿ implement-plan-tracker: Plan pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Plan
No plan yet. It shows the task list, or the newest OpenSpec tasks.md.
README

claude-implement-plan-tracker

A Claude Code plugin that always shows the whole plan in a pane:

2/5 done · ~45 min left · task list
✓ 1. Read ticket (~5 min)
✓ 2. Write OpenSpec proposal (~15 min)
▶ 3. Implement context functions (~20 min)
○ 4. Add tests (~10 min)
○ 5. Commit and open PR (~5 min)
> Add a step after the current one
  • Every step, with ✓ done, ▶ current, ○ pending
  • A done/total count and the time left
  • A text box to add a step, also while Claude is working
  • A status line entry: Plan 2/5 · ~45 min left ▶ Implement context functions

Time left

The plugin asks Claude to end each step with an estimate, for example (~10 min).

  • Before any step is done, the time left is the sum of Claude's estimates.
  • After steps are done, the plugin measures how long each one took. It scales Claude's estimates by actual time / estimated time.
  • Steps without an estimate use the average measured time.
  • The current step's elapsed time is subtracted.

Add a step while Claude works

Press ctrl+x tab to focus the pane, Tab to the text box, type the step, and press Enter. The step goes after the current step.

  • Task list: the plugin rewrites the task list (or creates a task) with the new step.
  • OpenSpec: the plugin writes a - [ ] line into tasks.md after the current step.
  • Claude gets a note about the new step with the result of its next tool call, so it sees the edit without stopping. If Claude is idle, the note goes with your next message.

Plan source

  1. Claude Code's task list (TodoWrite, or TaskCreate / TaskUpdate / TaskList) for the main conversation.
  2. When there is no task list: the checkboxes in the newest openspec/changes/<change>/tasks.md (the archive folder is skipped). The pane refreshes when that file changes and at the end of each turn.

Install

/plugin marketplace add hoangbits/claude-implement-plan-tracker
/plugin install implement-plan-tracker@implement-plan-tracker

The plugin uses Claude Code function hooks (early access). It needs a Claude Code build with plugin function hooks.

Use

  • The pane opens at session start. In a terminal narrower than 144 columns, it waits until you open it.
  • Type /plan to open the pane at any time.
  • ctrl+x tab moves focus to the pane. Esc or ctrl+x x closes it.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .
Source 3 files
hooks/register.tsx 317 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { OpenSpecPlan, Plan, Step } from '../types'
5import {
6  addTask,
7  currentIndex,
8  doneCount,
9  formatDuration,
10  fromTaskList,
11  fromTodos,
12  insertAfterCurrent,
13  insertTaskLine,
14  isTasksMd,
15  parseTasksMd,
16  patchTask,
17  remainingMs,
18  trackTiming,
19} from './plan'
20
21const PANE = 'plan'
22const TITLE = 'Plan'
23const CHANGES = 'openspec/changes'
24const TICK_MS = 30_000
25
26const ESTIMATE_RULE = [
27  'When you write a task list (TodoWrite or TaskCreate) or OpenSpec tasks.md checkboxes,',
28  'end each step with a time estimate in the form `(~N min)` or `(~N h)`,',
29  'for example `Add migration (~5 min)`.',
30].join(' ')
31
32const plan = atom({ plugin: 'implement-plan-tracker', key: 'plan' } as const, null)
33const openspec = atom({ plugin: 'implement-plan-tracker', key: 'openspec' } as const, null)
34const timing = atom({ plugin: 'implement-plan-tracker', key: 'timing' } as const, {})
35const tick = atom({ plugin: 'implement-plan-tracker', key: 'tick' } as const, 0)
36
37const MARK: Record<Step['status'], string> = {
38  completed: '✓',
39  in_progress: '▶',
40  pending: '○',
41}
42
43type Shown = { label: string; steps: Step[] }
44
45const shown = (live: Plan | null, spec: OpenSpecPlan | null): Shown | null => {
46  if (live && live.steps.length > 0) {
47    return { label: live.source === 'todos' ? 'task list' : 'tasks', steps: live.steps }
48  }
49  if (spec && spec.steps.length > 0) {
50    return { label: `openspec: ${spec.change}`, steps: spec.steps }
51  }
52
53  return null
54}
55
56const readShown = async ($: EngineInterface): Promise<Shown | null> =>
57  shown(await read($, plan), await read($, openspec))
58
59const timeLeft = async ($: EngineInterface, steps: Step[]): Promise<string> => {
60  if (doneCount(steps) === steps.length) return ''
61  const ms = remainingMs(steps, await read($, timing), await $.clock.now())
62
63  return ms === null ? 'estimating…' : `~${formatDuration(ms)} left`
64}
65
66const newestChange = async ($: EngineInterface): Promise<string | null> => {
67  if (!(await $.fs.exists(CHANGES))) return null
68  const dirs = (await $.fs.list(CHANGES)).filter(
69    entry => entry.kind === 'dir' && entry.name !== 'archive',
70  )
71  const stamped = await Promise.all(
72    dirs.map(async dir => {
73      const stat = await $.fs.stat(`${CHANGES}/${dir.name}/tasks.md`).catch(() => undefined)
74
75      return { name: dir.name, mtimeMs: stat?.mtimeMs ?? -1 }
76    }),
77  )
78  const newest = stamped
79    .filter(one => one.mtimeMs >= 0)
80    .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
81
82  return newest?.name ?? null
83}
84
85const refresh = async ($: EngineInterface): Promise<void> => {
86  const view = await readShown($)
87  if (!view) {
88    $.ui.status(undefined)
89
90    return
91  }
92  const now = await $.clock.now()
93  await update($, timing, old => trackTiming(view.steps, old, now))
94  const step = view.steps[currentIndex(view.steps)]
95  const left = await timeLeft($, view.steps)
96  const current = step ? ` ▶ ${step.text.slice(0, 40)}` : ' ✓ all done'
97  $.ui.status(`Plan ${doneCount(view.steps)}/${view.steps.length}${left ? ` · ${left}` : ''}${current}`)
98}
99
100const refreshOpenSpec = async ($: EngineInterface): Promise<void> => {
101  const change = await newestChange($).catch(() => null)
102  const steps = change ? parseTasksMd(await $.fs.read(`${CHANGES}/${change}/tasks.md`)) : []
103  await update($, openspec, () => (change ? { change, steps } : null))
104  await refresh($)
105}
106
107const setSteps = async (
108  $: EngineInterface,
109  source: Plan['source'],
110  fn: (steps: Step[]) => Step[],
111): Promise<void> => {
112  await update($, plan, old => ({
113    source,
114    steps: fn(old?.source === source ? old.steps : []),
115  }))
116  await refresh($)
117}
118
119let notes: string[] = []
120let isWriting = false
121
122function takeNotes(): string[] {
123  const taken = notes
124  notes = []
125
126  return taken
127}
128
129async function addStep($: EngineInterface, raw: string): Promise<void> {
130  const text = raw.trim()
131  const live = await read($, plan)
132  const spec = await read($, openspec)
133  const view = shown(live, spec)
134  if (!text || !view) return
135
136  const current = view.steps[currentIndex(view.steps)]
137  const after = current ? ` Do it right after the current step "${current.text}".` : ''
138  isWriting = true
139  try {
140    if (live && live.steps.length > 0 && live.source === 'todos') {
141      const steps = insertAfterCurrent(live.steps, { id: 'new', text, status: 'pending' })
142      await $.tool.call({
143        tool: 'TodoWrite',
144        todos: steps.map(step => ({
145          content: step.text,
146          status: step.status,
147          activeForm: step.activeForm ?? step.text,
148        })),
149      })
150      await setSteps($, 'todos', () => steps)
151      notes = [
152        ...notes,
153        `The user added a step to your task list from the plan pane: "${text}".${after} Keep it in the list when you update it.`,
154      ]
155    } else if (live && live.steps.length > 0) {
156      const made = await $.tool.call({
157        tool: 'TaskCreate',
158        subject: text,
159        description: 'Added by the user from the plan pane.',
160      })
161      if (made.deny === undefined && made.isError !== true) {
162        const { id, subject } = made.result.task
163        await setSteps($, 'tasks', steps => addTask(steps, id, subject))
164      }
165      notes = [...notes, `The user added a task from the plan pane: "${text}".${after}`]
166    } else if (spec) {
167      const path = `${CHANGES}/${spec.change}/tasks.md`
168      await $.fs.write(path, insertTaskLine(await $.fs.read(path), spec.steps, text))
169      await refreshOpenSpec($)
170      notes = [...notes, `The user added a step to ${path} from the plan pane: "${text}".${after}`]
171    }
172  } finally {
173    isWriting = false
174  }
175  $.ui.toast(`Step added: ${text}`)
176}
177
178export const register: Register = on => {
179  on('session.start', async ($, e, next) => {
180    await $.command.register({
181      name: 'plan',
182      description: 'Show every step of the current plan in a pane',
183    })
184    $.clock.every(TICK_MS, () => void update($, tick, n => n + 1))
185    await refreshOpenSpec($)
186    void $.ui.open({ id: PANE, title: TITLE })
187
188    return next(e)
189  })
190
191  on('command.run', { command: 'plan' }, async $ => {
192    await refreshOpenSpec($)
193    await $.ui.open({ id: PANE, title: TITLE })
194
195    return { text: 'Plan pane opened.' }
196  })
197
198  on('prompt.compose', async ($, e, next) => {
199    const { sections } = await next(e)
200
201    return {
202      sections: [
203        ...sections,
204        { id: 'implement-plan-tracker:estimates', text: ESTIMATE_RULE, scope: 'session' },
205      ],
206    }
207  })
208
209  on('prompt.submit', ($, e, next) => {
210    const taken = takeNotes()
211
212    return taken.length === 0 ? next(e) : next({ ...e, context: [...(e.context ?? []), ...taken] })
213  })
214
215  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
216    const ran = await next(e)
217    if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
218      await setSteps($, 'todos', () => fromTodos(e.todos))
219    }
220
221    return ran
222  })
223
224  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
225    const ran = await next(e)
226    if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
227      const { id, subject } = ran.result.task
228      await setSteps($, 'tasks', steps => addTask(steps, id, subject))
229    }
230
231    return ran
232  })
233
234  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
235    const ran = await next(e)
236    if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
237      await setSteps($, 'tasks', steps => patchTask(steps, e))
238    }
239
240    return ran
241  })
242
243  on('tool.call', { tool: 'TaskList' }, async ($, e, next) => {
244    const ran = await next(e)
245    if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
246      const { tasks } = ran.result
247      await setSteps($, 'tasks', () => fromTaskList(tasks))
248    }
249
250    return ran
251  })
252
253  on('tool.call', async ($, e, next) => {
254    const ran = await next(e)
255    const path = 'file_path' in e && typeof e.file_path === 'string' ? e.file_path : ''
256    if (isTasksMd(path)) await refreshOpenSpec($)
257    const canCarry = !isWriting && e.agentId === undefined && ran.deny === undefined
258    if (!canCarry || notes.length === 0) return ran
259
260    return { ...ran, context: [...(ran.context ?? []), ...takeNotes()] }
261  })
262
263  on('turn.complete', async ($, e, next) => {
264    await refreshOpenSpec($)
265
266    return next(e)
267  })
268
269  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
270    const table = $.ui.resolve(e)
271    const { Box, Text } = table
272    const Input = 'Input' in table ? table.Input : undefined
273    await read($, tick)
274    const view = await readShown($)
275
276    if (!view) {
277      return (
278        <Box flexDirection="column">
279          <Text dimColor>No plan yet.</Text>
280          <Text dimColor>It shows the task list, or the newest OpenSpec tasks.md.</Text>
281        </Box>
282      )
283    }
284
285    const at = currentIndex(view.steps)
286    const left = await timeLeft($, view.steps)
287
288    return (
289      <Box flexDirection="column">
290        <Text bold>
291          {doneCount(view.steps)}/{view.steps.length} done{left ? ` · ${left}` : ''}{' '}
292          <Text dimColor>· {view.label}</Text>
293        </Text>
294        {view.steps.map((step, i) => (
295          <Text
296            key={`step_${step.id}`}
297            wrap="truncate-end"
298            bold={i === at}
299            color={i === at ? 'cyan' : undefined}
300            dimColor={step.status === 'completed'}
301          >
302            {MARK[i === at ? 'in_progress' : step.status]} {i + 1}. {step.text}
303          </Text>
304        ))}
305        {Input && (
306          <Input
307            key="add"
308            placeholder="Add a step after the current one"
309            submitLabel="Add"
310            onSubmit={(value: string) => void addStep($, value)}
311          />
312        )}
313      </Box>
314    )
315  })
316}
317
hooks/plan.ts 146 lines
1import type { Step, StepStatus, Timing } from '../types'
2
3type Todo = { content: string; status: StepStatus; activeForm?: string }
4type TaskRow = { id: string; subject: string; status: StepStatus }
5type TaskPatch = { taskId: string; subject?: string; status?: StepStatus | 'deleted' }
6
7const CHECKBOX = /^\s*[-*]\s+\[([ xX])\]\s+(.+?)\s*$/
8
9export const fromTodos = (todos: readonly Todo[]): Step[] =>
10  todos.map((todo, i) => ({
11    id: String(i),
12    text: todo.content,
13    status: todo.status,
14    activeForm: todo.activeForm,
15  }))
16
17export const fromTaskList = (tasks: readonly TaskRow[]): Step[] =>
18  tasks.map(task => ({ id: task.id, text: task.subject, status: task.status }))
19
20export const addTask = (steps: Step[], id: string, subject: string): Step[] => [
21  ...steps.filter(step => step.id !== id),
22  { id, text: subject, status: 'pending' },
23]
24
25export const patchTask = (steps: Step[], { taskId, subject, status }: TaskPatch): Step[] =>
26  status === 'deleted'
27    ? steps.filter(step => step.id !== taskId)
28    : steps.map(step =>
29        step.id === taskId
30          ? { ...step, text: subject ?? step.text, status: status ?? step.status }
31          : step,
32      )
33
34export const parseTasksMd = (text: string): Step[] =>
35  text.split('\n').flatMap((line, i) => {
36    const match = CHECKBOX.exec(line)
37    const label = match?.[2]
38    if (!match || label === undefined) return []
39    const status: StepStatus = match[1] === ' ' ? 'pending' : 'completed'
40
41    return [{ id: String(i), text: label, status }]
42  })
43
44export const currentIndex = (steps: readonly Step[]): number => {
45  const active = steps.findIndex(step => step.status === 'in_progress')
46
47  return active >= 0 ? active : steps.findIndex(step => step.status === 'pending')
48}
49
50export const doneCount = (steps: readonly Step[]): number =>
51  steps.filter(step => step.status === 'completed').length
52
53export const isTasksMd = (path: string): boolean =>
54  /(^|\/)openspec\/changes\/(?!archive\/)[^/]+\/tasks\.md$/.test(path)
55
56const ESTIMATE = /\(~\s*(\d+(?:\.\d+)?)\s*(min|m|h|hr|hrs|hours?)\)/i
57const MINUTE = 60_000
58const MIN_MEASURED = 5_000
59
60export const estimateMs = (text: string): number | null => {
61  const match = ESTIMATE.exec(text)
62  if (!match?.[1] || !match[2]) return null
63  const amount = Number(match[1])
64
65  return match[2].toLowerCase().startsWith('h') ? amount * 60 * MINUTE : amount * MINUTE
66}
67
68export const stepKey = (step: Step): string => step.text.replace(ESTIMATE, '').trim()
69
70export const trackTiming = (steps: readonly Step[], timing: Timing, now: number): Timing => {
71  const at = currentIndex(steps)
72  const next: Timing = {}
73  for (const [i, step] of steps.entries()) {
74    const key = stepKey(step)
75    const old = timing[key]
76    if (step.status === 'completed') {
77      next[key] = { startedAt: old?.startedAt ?? now, finishedAt: old?.finishedAt ?? now }
78    } else if (i === at) {
79      next[key] = { startedAt: old?.startedAt ?? now }
80    }
81  }
82
83  return next
84}
85
86const sum = (values: readonly number[]): number => values.reduce((a, b) => a + b, 0)
87
88export const remainingMs = (steps: readonly Step[], timing: Timing, now: number): number | null => {
89  const measured = steps.flatMap(step => {
90    const time = timing[stepKey(step)]
91    const took = time?.finishedAt === undefined ? 0 : time.finishedAt - time.startedAt
92    if (step.status !== 'completed' || took < MIN_MEASURED) return []
93
94    return [{ took, estimate: estimateMs(step.text) }]
95  })
96  const left = steps.filter(step => step.status !== 'completed')
97  const estimates = left.map(step => estimateMs(step.text))
98  const known = estimates.filter((ms): ms is number => ms !== null)
99  const average =
100    measured.length > 0
101      ? sum(measured.map(one => one.took)) / measured.length
102      : known.length > 0
103        ? sum(known) / known.length
104        : null
105  if (average === null) return null
106
107  const isCalibrated = measured.length > 0 && measured.every(one => one.estimate !== null)
108  const ratio = isCalibrated
109    ? sum(measured.map(one => one.took)) / sum(measured.map(one => one.estimate ?? 0))
110    : 1
111  const current = steps[currentIndex(steps)]
112
113  return sum(
114    left.map((step, i) => {
115      const estimate = estimates[i] ?? null
116      const full = estimate !== null && (isCalibrated || measured.length === 0) ? estimate * ratio : average
117      const startedAt = step === current ? timing[stepKey(step)]?.startedAt : undefined
118
119      return startedAt === undefined ? full : Math.max(0, full - (now - startedAt))
120    }),
121  )
122}
123
124export const formatDuration = (ms: number): string => {
125  const minutes = Math.max(1, Math.round(ms / MINUTE))
126  const hours = Math.floor(minutes / 60)
127
128  return hours === 0 ? `${minutes} min` : `${hours} h ${minutes % 60} min`
129}
130
131export const insertAfterCurrent = (steps: readonly Step[], step: Step): Step[] => {
132  const at = currentIndex(steps)
133  const cut = at < 0 ? steps.length : at + 1
134
135  return [...steps.slice(0, cut), step, ...steps.slice(cut)]
136}
137
138export const insertTaskLine = (markdown: string, steps: readonly Step[], text: string): string => {
139  const lines = markdown.split('\n')
140  const current = steps[currentIndex(steps)]
141  const anchor = current ? Number(current.id) : Number(steps.at(-1)?.id ?? lines.length - 1)
142  const indent = /^\s*/.exec(lines[anchor] ?? '')?.[0] ?? ''
143
144  return [...lines.slice(0, anchor + 1), `${indent}- [ ] ${text}`, ...lines.slice(anchor + 1)].join('\n')
145}
146
types/index.d.ts 25 lines
1export type StepStatus = 'pending' | 'in_progress' | 'completed'
2
3export type Step = { id: string; text: string; status: StepStatus; activeForm?: string }
4
5export type PlanSource = 'todos' | 'tasks'
6
7export type Plan = { source: PlanSource; steps: Step[] }
8
9export type OpenSpecPlan = { change: string; steps: Step[] }
10
11export type StepTiming = { startedAt: number; finishedAt?: number }
12
13export type Timing = { [stepKey: string]: StepTiming }
14
15declare module 'claude-code' {
16  interface PluginState {
17    'implement-plan-tracker': {
18      plan: Plan | null
19      openspec: OpenSpecPlan | null
20      timing: Timing
21      tick: number
22    }
23  }
24}
25