Tracks a plan being executed and shows a progress bar with the time left for each task

waiter is a Claude Code mod. While Claude works through a plan, it shows a progress bar and the time left for each task.
### Task 1: / ## Phase 2 headings<task> blocksWith no plan file, it shows the session's own task list.
Task 3 or (4b Task 3)[you].git status in every working folder of the repo, worktrees included), matched to the files or modules (ops.burn → ops.py) each step names. It reads only file names, never their contents.You need Claude Code (terminal or the desktop app's Code tab). The mod was built on version 2.1.288, and the plugin system it uses is still early access. You also need git, to get the mod and so waiter can see progress from commits.
Paste this into a Claude Code session. The same prompt installs waiter, updates it, and moves an old zip install to a git clone.
Install or update the waiter mod for Claude Code from https://github.com/yarivzip/waiter.
1. Find my copy: read "env" > "CLAUDE_CODE_PLUGIN_DIRS" in ~/.claude/settings.json and
look for a folder in it whose .claude-plugin/plugin.json has "name": "waiter".
2. If that folder is a git clone of yarivzip/waiter, run `git pull --ff-only` in it and
skip to step 5.
3. Otherwise, git clone https://github.com/yarivzip/waiter into ~/tools/waiter (on Windows
%USERPROFILE%\tools\waiter) unless I name another folder. Keep it outside ~/.claude.
If my old copy was not a git clone (a zip install), ask me before deleting it.
4. Point CLAUDE_CODE_PLUGIN_DIRS at the clone's full path. Change only that value: keep
every other setting, and keep any other folders in it (separated by ; on Windows,
: on macOS/Linux). Write valid JSON, with doubled backslashes on Windows.
5. Run `claude plugin validate <folder>` and show me the result and the new commits, if any.
6. Tell me to start a new Claude Code session and type /waiter to check it loaded.
.claude:Windows:
git clone https://github.com/yarivzip/waiter D:\tools\waiter
macOS / Linux:
git clone https://github.com/yarivzip/waiter ~/tools/waiter
C:\Users\<you>\.claude\settings.json, or ~/.claude/settings.json) and add the line under "env", pointing at the folder you cloned into.Windows (note the doubled backslashes):
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "D:\\tools\\waiter"
}
}
macOS / Linux (write the full path, not ~; on Linux it starts with /home/<you>):
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/<you>/tools/waiter"
}
}
"env" block, add only the CLAUDE_CODE_PLUGIN_DIRS line inside it, with a comma after the line above it.; on Windows or : on macOS / Linux.To check it loaded, type /waiter. It answers even before there is a plan.
Update: paste the prompt above again, or run git pull in that folder yourself. Then start a new session.
Without git: you can unzip a copy instead. Put it in a folder outside .claude so that it directly contains .claude-plugin, hooks and types, then do steps 2 and 3. To update, replace the folder with a newer zip.
Most of the time you do nothing: start executing a plan and the panel appears.
| Command | What it does |
|---|---|
/waiter | open the panel |
/waiter path/to/plan.md | track that plan file |
/waiter min | minimize to one line above the prompt |
/waiter refresh | re-read the plan and the commits now |
/waiter off | stop tracking (it won't come back next session) |
The panel has the buttons Minimize, Refresh and Stop tracking. The one-line bar has Expand and Hide. If you close the panel, it turns into the one-line bar.
/waiter <path> also goes through Claude's Read tool.CLAUDE_CODE_PLUGIN_DIRS line and the folder.claude plugin test <folder>.hooks/register.tsx 523 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { WaiterDone, WaiterMode, WaiterPlan, WaiterSessionTask } from '../types'
5import {
6 DEFAULT_RATE,
7 bar,
8 clock,
9 duration,
10 estimate,
11 isPlanPath,
12 matchCommits,
13 parseGitLog,
14 parsePlan,
15 parseStatus,
16 sessionPlan,
17} from './plan'
18import type { Row, View } from './plan'
19
20const PANE = 'waiter'
21const TITLE = 'Waiter'
22
23const planA = atom({ plugin: 'waiter', key: 'plan' } as const, null)
24const doneA = atom({ plugin: 'waiter', key: 'done' } as const, {})
25const sessionA = atom({ plugin: 'waiter', key: 'session' } as const, [])
26const startedA = atom({ plugin: 'waiter', key: 'startedAt' } as const, 0)
27const rateA = atom({ plugin: 'waiter', key: 'rate' } as const, DEFAULT_RATE)
28const modeA = atom({ plugin: 'waiter', key: 'mode' } as const, 'pane')
29const noteA = atom({ plugin: 'waiter', key: 'note' } as const, '')
30const restoredA = atom({ plugin: 'waiter', key: 'isRestored' } as const, false)
31const changedA = atom({ plugin: 'waiter', key: 'changed' } as const, [])
32
33type $ = EngineInterface
34
35const norm = (p: string) => p.replace(/\\/g, '/')
36const dirOf = (p: string) => norm(p).replace(/\/[^/]*$/, '') || '/'
37const isAbsolute = (p: string) => /^[a-zA-Z]:\//.test(norm(p)) || norm(p).startsWith('/')
38const same = (a: string, b: string) => norm(a).toLowerCase() === norm(b).toLowerCase()
39
40async function absolute($: $, p: string): Promise<string> {
41 const path = norm(p.trim().replace(/^["']|["']$/g, ''))
42 return isAbsolute(path) ? path : `${norm(await $.session.cwd())}/${path.replace(/^\.\//, '')}`
43}
44
45async function git($: $, argv: string[], cwd: string): Promise<string | null> {
46 try {
47 const r = await $.process.run(['git', ...argv], { cwd, timeoutMs: 15_000 })
48 return r.exitCode === 0 ? r.stdout : null
49 } catch {
50 return null // no git, or no host processes on this surface
51 }
52}
53
54async function storeGet<T>($: $, key: string, fallback: T): Promise<T> {
55 try {
56 const v = await $.store.get(key)
57 return v === undefined || v === null ? fallback : (v as T)
58 } catch {
59 return fallback
60 }
61}
62
63async function storeSet($: $, key: string, value: unknown) {
64 try {
65 await $.store.set(key, value)
66 } catch {
67 // a store that refuses only costs the memory across sessions
68 }
69}
70
71// Waiter reads only files a tool call already opened: those calls pass the
72// session's PreToolUse guards (a project's off-limits folders), waiter's own
73// reads would not. A path enters this list once such a call succeeded.
74async function allow($: $, path: string) {
75 const opened = await storeGet<string[]>($, 'opened', [])
76 const key = norm(path).toLowerCase()
77 if (!opened.includes(key)) await storeSet($, 'opened', [...opened, key].slice(-200))
78}
79
80async function isAllowed($: $, path: string): Promise<boolean> {
81 return (await storeGet<string[]>($, 'opened', [])).includes(norm(path).toLowerCase())
82}
83
84// Opens `path` through the Read tool, so the session's guards decide.
85async function openGuarded($: $, path: string): Promise<boolean> {
86 try {
87 const r = await $.tool.call({ tool: 'Read', file_path: path, limit: 1 })
88 if (r.deny !== undefined || r.isError) return false
89 await allow($, path)
90 return true
91 } catch {
92 return false
93 }
94}
95
96// The learned rate: the mean of the last plans' measured rates.
97async function learnedRate($: $): Promise<number> {
98 const rates = await storeGet<Record<string, number>>($, 'rates', {})
99 const values = Object.values(rates).filter(v => v > 0)
100 return values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : DEFAULT_RATE
101}
102
103async function current($: $): Promise<WaiterPlan | null> {
104 return (await read($, planA)) ?? sessionPlan(await read($, sessionA))
105}
106
107async function view($: $): Promise<View | null> {
108 const plan = await current($)
109 if (!plan) return null
110 return estimate(
111 plan,
112 await read($, doneA),
113 await read($, sessionA),
114 await read($, startedA),
115 await read($, rateA),
116 await $.clock.now(),
117 plan.kind === 'session' ? [] : await read($, changedA),
118 )
119}
120
121// Shows the plan the way the person last chose: the pane, or the one-line band.
122async function show($: $) {
123 if ((await read($, modeA)) !== 'pane') return
124 const opened = await $.ui.open({ id: PANE, title: TITLE })
125 if (!opened.isPlaced) await update($, modeA, () => 'band' as WaiterMode)
126}
127
128// Stops tracking, and forgets the plan so the next session does not bring it back.
129async function stop($: $) {
130 await update($, planA, () => null)
131 await storeSet($, `last:${norm(await $.session.root())}`, '')
132 await setMode($, 'hidden')
133}
134
135async function setMode($: $, mode: WaiterMode) {
136 await update($, modeA, () => mode)
137 if (mode !== 'hidden') await storeSet($, 'mode', mode)
138 if (mode === 'pane') await $.ui.open({ id: PANE, title: TITLE })
139 else await $.ui.close({ id: PANE })
140}
141
142// Reads the plan's commits and records which tasks they finish.
143async function refreshGit($: $) {
144 const plan = await read($, planA)
145 if (!plan) return
146 const since = await read($, startedA)
147 const out = await git($, ['log', '--all', `--since=${Math.floor(since / 1000) - 60}`, '--format=%ct%x09%s'], dirOf(plan.path))
148 if (out === null) return
149 const found = matchCommits(plan, parseGitLog(out), since)
150 await update($, doneA, done => {
151 const next: WaiterDone = { ...done }
152 for (const [k, v] of Object.entries(found)) {
153 if (!next[k] || v.at < next[k].at) next[k] = v
154 }
155 return next
156 })
157 await learn($)
158 await refreshChanged($)
159}
160
161// The files changed and not yet committed, in every working folder of the repo
162// (a plan is often executed in a worktree). Names only: no file is opened.
163async function refreshChanged($: $) {
164 const plan = await read($, planA)
165 if (!plan) return
166 const list = await git($, ['worktree', 'list', '--porcelain'], dirOf(plan.path))
167 const folders = (list ?? '')
168 .split(/\r?\n/)
169 .filter(l => l.startsWith('worktree '))
170 .map(l => l.slice('worktree '.length))
171 const changed: string[] = []
172 for (const folder of folders.length > 0 ? folders : [dirOf(plan.path)]) {
173 const out = await git($, ['status', '--porcelain', '--untracked-files=all'], folder)
174 if (out !== null) changed.push(...parseStatus(out))
175 }
176 await update($, changedA, () => [...new Set(changed)].slice(0, 500))
177}
178
179// Keeps this plan's measured rate, so later plans start from it.
180async function learn($: $) {
181 const plan = await read($, planA)
182 const v = await view($)
183 if (!plan || !v || v.kind === 'session' || v.measured === 0) return
184 const rates = await storeGet<Record<string, number>>($, 'rates', {})
185 rates[plan.path] = v.rate
186 await storeSet($, 'rates', Object.fromEntries(Object.entries(rates).slice(-10)))
187}
188
189async function created($: $, path: string): Promise<number | null> {
190 const out = await git($, ['log', '--follow', '--diff-filter=A', '--format=%ct', '--', path], dirOf(path))
191 const last = out?.trim().split(/\r?\n/).filter(Boolean).pop()
192 return last ? Number(last) * 1000 : null
193}
194
195async function load($: $, path: string): Promise<WaiterPlan | null> {
196 if (!(await isAllowed($, path))) return null
197 try {
198 return parsePlan(await $.fs.read(path), path)
199 } catch {
200 return null
201 }
202}
203
204// Starts tracking the plan file at `path`. Says what happened, for the command.
205async function track($: $, path: string): Promise<string> {
206 const plan = await load($, path)
207 if (!plan) return `no tasks found in ${path}.`
208 const now = await $.clock.now()
209 const seen = await storeGet<WaiterDone>($, `seen:${plan.path}`, {})
210 const start = (await created($, plan.path)) ?? (await storeGet<number>($, `start:${plan.path}`, now))
211 await storeSet($, `start:${plan.path}`, start)
212 await update($, planA, () => plan)
213 await update($, restoredA, () => false)
214 await update($, doneA, () => seen)
215 await update($, startedA, () => start)
216 const rate = await learnedRate($)
217 await update($, rateA, () => rate)
218 await update($, noteA, () => `read from ${plan.kind}`)
219 await storeSet($, `last:${norm(await $.session.root())}`, plan.path)
220 await refreshGit($)
221 await show($)
222 return `tracking "${plan.title}" (${plan.tasks.length} tasks, read from ${plan.kind}).`
223}
224
225// The tracked file changed: read it again and time the tasks it newly marks done.
226async function reparse($: $) {
227 const old = await read($, planA)
228 if (!old) return
229 const plan = await load($, old.path)
230 if (!plan) return
231 const now = await $.clock.now()
232 const was = new Map(old.tasks.map(t => [t.key, t.isDone]))
233 const done = await update($, doneA, d => {
234 const next: WaiterDone = { ...d }
235 for (const t of plan.tasks) {
236 if (t.isDone && was.get(t.key) === false && !next[t.key]) next[t.key] = { at: now, by: 'file' }
237 }
238 return next
239 })
240 await update($, planA, () => plan)
241 const fileDone = Object.fromEntries(Object.entries(done).filter(([, v]) => v.by === 'file'))
242 await storeSet($, `seen:${plan.path}`, fileDone)
243}
244
245// A plan-like file a tool call just opened. It replaces the tracked plan when
246// nothing is tracked, the tracked one came back from an earlier session, is
247// finished, this one is being written, or this one is newer: an older plan
248// opened for reference does not take over this session's plan.
249async function consider($: $, path: string, isWrite: boolean) {
250 if (!/\.(md|markdown)$/i.test(path)) return
251 await allow($, path)
252 const tracked = await read($, planA)
253 if (tracked && same(tracked.path, path)) return reparse($)
254 const plan = await load($, path)
255 if (!plan || !(isPlanPath(path) || plan.kind === 'xml')) return
256 const v = await view($)
257 const isFinished = v !== null && v.doneCount === v.rows.length
258 const isNewer = async () => ((await created($, path)) ?? Number.MAX_SAFE_INTEGER) > (await read($, startedA))
259 const isRestored = await read($, restoredA)
260 if (!tracked || isRestored || isFinished || isWrite || !isPlanPath(tracked.path) || (await isNewer())) {
261 await track($, path)
262 return
263 }
264 const told = await storeGet<string[]>($, 'told', [])
265 if (!told.includes(plan.path)) {
266 $.ui.toast(`waiter: found plan "${plan.title}". /waiter ${plan.path} to track it`)
267 await storeSet($, 'told', [...told, plan.path].slice(-50))
268 }
269}
270
271function filePathOf(e: unknown): string | undefined {
272 const p = (e as { file_path?: unknown }).file_path
273 return typeof p === 'string' ? p : undefined
274}
275
276function commandOf(e: unknown): string {
277 const c = (e as { command?: unknown }).command
278 return typeof c === 'string' ? c : ''
279}
280
281function rowTime(r: Row): { text: string; color?: string } {
282 if (r.state === 'done') return { text: r.tookMs !== undefined ? `took ${duration(r.tookMs)}` : 'done', color: 'green' }
283 if (r.isReview) return { text: 'saved, in review', color: 'cyan' }
284 if (r.state === 'current') {
285 const left = r.leftMs ?? r.estMs
286 if (r.stepAt !== undefined) return { text: `step ${r.stepAt}/${r.steps} ~${duration(left)}`, color: 'yellow' }
287 return left >= 0 ? { text: `~${duration(left)} left`, color: 'yellow' } : { text: `over ${duration(left)}`, color: 'red' }
288 }
289 return { text: `~${duration(r.estMs)}` }
290}
291
292function glyph(r: Row): { text: string; color?: string } {
293 if (r.state === 'done') return { text: '✓', color: 'green' }
294 if (r.state === 'current') return { text: '▶', color: 'yellow' }
295 return { text: '·' }
296}
297
298function headline(v: View): string {
299 const pct = Math.round(v.fraction * 100)
300 return `${pct}% · ${v.doneCount}/${v.rows.length} tasks · ~${duration(v.leftMs)} left · done by ${clock(v.etaAt)}`
301}
302
303async function onTasks($: $, tool: string, e: unknown, result: unknown) {
304 const now = await $.clock.now()
305 if (tool === 'TaskCreate') {
306 const task = (result as { task?: { id?: string; subject?: string } } | undefined)?.task
307 if (!task?.id) return
308 const id = String(task.id)
309 await update($, sessionA, list => [
310 ...list.filter(s => s.id !== id),
311 { id, subject: task.subject ?? '', status: 'pending' as const },
312 ])
313 return
314 }
315 if (tool === 'TaskUpdate') {
316 const u = e as { taskId?: string; status?: string; subject?: string }
317 if (!u.taskId) return
318 await update($, sessionA, list =>
319 u.status === 'deleted'
320 ? list.filter(s => s.id !== u.taskId)
321 : list.map(s => (s.id === u.taskId ? moved(s, u.status, u.subject, now) : s)),
322 )
323 return
324 }
325 const todos = (e as { todos?: { content: string; status: string }[] }).todos ?? []
326 await update($, sessionA, list => {
327 const by = new Map(list.map(s => [s.id, s]))
328 return todos.map(t => {
329 const was: WaiterSessionTask = by.get(t.content) ?? { id: t.content, subject: t.content, status: 'pending' }
330 return moved(was, t.status, undefined, now)
331 })
332 })
333}
334
335function moved(s: WaiterSessionTask, status: string | undefined, subject: string | undefined, now: number): WaiterSessionTask {
336 const next: WaiterSessionTask = { ...s, subject: subject ?? s.subject }
337 if (status === 'in_progress' || status === 'completed' || status === 'pending') next.status = status
338 if (status === 'in_progress' && next.startedAt === undefined) next.startedAt = now
339 if (status === 'completed' && next.doneAt === undefined) next.doneAt = now
340 return next
341}
342
343export const register: Register = on => {
344 on('session.start', async ($, e, next) => {
345 await $.command.register({
346 name: 'waiter',
347 description: 'Plan progress and time left per task. /waiter [plan path | min | off | refresh]',
348 argumentHint: '[plan path | min | off | refresh]',
349 })
350 const started = await next(e)
351
352 // A redraw now and then moves the clocks; a re-read picks up work done elsewhere.
353 $.clock.every(30_000, () => $.ui.invalidate('ui.render'))
354 $.clock.every(60_000, () => {
355 void reparse($).then(() => refreshGit($))
356 })
357
358 if ((await read($, modeA)) !== 'hidden') {
359 const mode = await storeGet<WaiterMode>($, 'mode', 'pane')
360 await update($, modeA, () => mode)
361 }
362 if (!(await read($, planA))) {
363 // A new session in the same project picks its last plan back up.
364 const last = await storeGet<string>($, `last:${norm(await $.session.root())}`, '')
365 if (last) {
366 void (async () => {
367 if (!(await load($, last))) return
368 await track($, last)
369 await update($, restoredA, () => true)
370 const v = await view($)
371 if (v && v.doneCount === v.rows.length) await $.ui.close({ id: PANE })
372 })()
373 }
374 }
375
376 return started
377 })
378
379 on('command.run', { command: 'waiter' }, async ($, e) => {
380 const arg = e.args.trim()
381 if (arg === '' || arg === 'show' || arg === 'max') {
382 if (!(await current($))) {
383 return { text: 'no plan yet. Run /waiter <plan.md>, or read or execute a plan and it is picked up.' }
384 }
385 await setMode($, 'pane')
386 return { text: 'pane opened.' }
387 }
388 if (arg === 'min') {
389 await setMode($, 'band')
390 return { text: 'minimized above the prompt.' }
391 }
392 if (arg === 'off') {
393 await stop($)
394 return { text: 'stopped tracking.' }
395 }
396 if (arg === 'refresh') {
397 await reparse($)
398 await refreshGit($)
399 return { text: 'refreshed.' }
400 }
401 const path = await absolute($, arg)
402 if (!(await openGuarded($, path))) return { text: `could not open ${path} (missing, or refused by a guard).` }
403 if ((await read($, modeA)) === 'hidden') await update($, modeA, () => 'pane' as WaiterMode)
404 return { text: await track($, path) }
405 })
406
407 on('tool.call', async ($, e, next) => {
408 const ran = await next(e)
409 if (ran.deny !== undefined || ran.isError) return ran
410 const tool = String(e.tool)
411 try {
412 if (tool === 'Read' || tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') {
413 const p = filePathOf(e)
414 if (p) await consider($, await absolute($, p), tool !== 'Read')
415 } else if ((tool === 'Bash' || tool === 'PowerShell') && /\bgit\b[\s\S]*\b(commit|merge|cherry-pick|rebase)\b/.test(commandOf(e))) {
416 await refreshGit($)
417 } else if (e.agentId === undefined && (tool === 'TaskCreate' || tool === 'TaskUpdate' || tool === 'TodoWrite')) {
418 await onTasks($, tool, e, ran.result)
419 if (!(await read($, planA))) await show($)
420 }
421 } catch (err) {
422 // tracking never gets in the way of the tool
423 $.ui.log(`waiter: ${String(err)}`, { to: 'debug' })
424 }
425 return ran
426 })
427
428 on('ui.close', { id: PANE }, async ($, e, next) => {
429 // The person closed the pane: keep the plan as the one-line band.
430 if (e.origin.kind === 'person') await update($, modeA, () => 'band' as WaiterMode)
431 return next(e)
432 })
433
434 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
435 const { Box, Text, Button } = $.ui.resolve(e)
436 const v = await view($)
437 const cols = Math.max(30, e.props.bodyColumns)
438 if (!v) {
439 return (
440 <Box flexDirection="column">
441 <Text dimColor>No plan tracked. /waiter path/to/plan.md, or start executing a plan.</Text>
442 <Button key="minimize" label="Minimize" hotkey="m" onPress={() => setMode($, 'band')} />
443 </Box>
444 )
445 }
446 const note = await read($, noteA)
447 const barW = Math.min(12, Math.max(6, Math.floor(cols / 6)))
448 const titleW = Math.max(8, cols - barW - 18)
449 return (
450 <Box flexDirection="column">
451 <Text bold wrap="truncate-end">
452 {v.title}
453 </Text>
454 <Text color="green">{bar(v.fraction, Math.max(10, cols - 2))}</Text>
455 <Text wrap="truncate-end">{headline(v)}</Text>
456 <Text dimColor wrap="truncate-end">
457 {note || v.kind} · {v.kind === 'session' ? `${duration(v.rate)} per task` : `${duration(v.rate * 10)} per 10 plan lines`}
458 {v.measured > 0 ? ` (learned from ${v.measured} done)` : ' (first guess)'}
459 </Text>
460 <Box flexDirection="column" marginTop={1}>
461 {v.rows.map(r => {
462 const g = glyph(r)
463 const t = rowTime(r)
464 return (
465 <Box key={r.key} flexDirection="column">
466 <Box flexDirection="row">
467 <Text color={g.color}>{g.text} </Text>
468 <Box width={titleW}>
469 <Text wrap="truncate-end" dimColor={r.state === 'done'} bold={r.state === 'current'}>
470 {r.title}
471 {r.isManual ? ' [you]' : ''}
472 </Text>
473 </Box>
474 <Text color={r.state === 'todo' ? undefined : g.color} dimColor={r.state === 'todo'}>
475 {' '}
476 {bar(r.fraction, barW)}{' '}
477 </Text>
478 <Text color={t.color} dimColor={r.state === 'todo'}>
479 {t.text}
480 </Text>
481 </Box>
482 {r.stepTitle !== undefined && (
483 <Text dimColor wrap="truncate-end">
484 {' '}step {r.stepAt} of {r.steps}: {r.stepTitle}
485 </Text>
486 )}
487 </Box>
488 )
489 })}
490 </Box>
491 <Box flexDirection="row" gap={1} marginTop={1}>
492 <Button key="minimize" label="Minimize" hotkey="m" onPress={() => setMode($, 'band')} />
493 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => reparse($).then(() => refreshGit($))} />
494 <Button key="stop" label="Stop tracking" onPress={() => stop($)} />
495 </Box>
496 </Box>
497 )
498 })
499
500 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
501 if (e.props.hasSurvey || (await read($, modeA)) !== 'band') return next(e)
502 const v = await view($)
503 if (!v) return next(e)
504 const { Box, Text, Button } = $.ui.resolve(e)
505 const cur = v.rows.find(r => r.state === 'current')
506 const curText = cur ? ` · ▶ ${cur.title.slice(0, 40)} ${rowTime(cur).text}` : ''
507 return (
508 <Box flexDirection="row" gap={1}>
509 <Text bold>waiter</Text>
510 <Text color="green">{bar(v.fraction, 16)}</Text>
511 <Box flexGrow={1} flexShrink={1}>
512 <Text wrap="truncate-end">
513 {Math.round(v.fraction * 100)}% {v.doneCount}/{v.rows.length}
514 {curText} · all ~{duration(v.leftMs)}, by {clock(v.etaAt)}
515 </Text>
516 </Box>
517 <Button key="expand" label="Expand" hotkey="e" onPress={() => setMode($, 'pane')} />
518 <Button key="hide" label="Hide" onPress={() => setMode($, 'hidden')} />
519 </Box>
520 )
521 })
522}
523hooks/plan.ts 526 lines1// Pure plan logic: parse a plan's structure, match commits to its tasks, and
2// estimate the time left. No `$` here, so the tests drive it with fixtures.
3
4import type {
5 WaiterDone,
6 WaiterKind,
7 WaiterPlan,
8 WaiterSessionTask,
9 WaiterTask,
10} from '../types'
11
12export const DEFAULT_RATE = 6_000 // ms per non-blank plan line, before anything is learned
13export const ITEM_MS = 10 * 60_000 // a session task with no history
14export const MANUAL_MIN_MS = 20 * 60_000 // floor for a hands-on task
15export const GAP_MS = 4 * 60 * 60_000 // a longer gap is a break, not work
16// A task's commit is often followed by review and fix commits: the newest
17// committed task counts as in review until it has been quiet this long.
18export const REVIEW_MS = 20 * 60_000
19
20const NOUNS = 'task|step|phase|milestone|stage|part|chunk|story|epic|deliverable|wave|sprint|item'
21const TASK_HEADING = new RegExp(
22 `^(#{1,6})\\s+(?:\\[[ xX]\\]\\s+)?(?:~~)?\\s*(${NOUNS})\\s*#?\\s*(\\d+[a-z]?(?:\\.\\d+)*)\\b\\s*[:.)\\-–—]?\\s*(.*)$`,
23 'i',
24)
25const NUMBERED_HEADING = /^(#{2,6})\s+(\d+(?:\.\d+)*)[.)]?\s+(.+)$/
26const ANY_HEADING = /^(#{1,6})\s+(.+)$/
27const CHECKBOX = /^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$/
28const NUMBERED_ITEM = /^\s*\d+[.)]\s+\S/
29// Explicit marks only: "done" as a plain word in a title is prose, not a mark.
30const DONE_MARK = /✅|✔|☑|\[[xX]\]|\((done|DONE)\)|\bDONE\b|\bCOMPLETED?\b|^\s*~~/
31const MANUAL_MARK = /CONTROLLER|\bmanual\b|by hand|\bhuman\b|checkpoint|acceptance|with (the )?user/i
32const TOKEN = /\b(?:delivery|phase|milestone|sprint|release|iteration|stage|plan)\s+([0-9]+[a-z]?(?:\.[0-9]+)?)\b/i
33// A path that says it holds a plan: a plans/ folder, PLAN.md, *-plan.md, todo.md ...
34const PLAN_PATH = /(^|[/\\._-])(plans?|todos?|tasks|roadmap|backlog|checklist)([/\\._-]|$)/i
35
36type Line = { text: string; isCode: boolean }
37
38function lines(text: string): Line[] {
39 let isFence = false
40 return text.split(/\r?\n/).map(raw => {
41 const fence = /^\s*(```|~~~)/.test(raw)
42 const isCode = isFence || fence
43 if (fence) isFence = !isFence
44 return { text: raw, isCode }
45 })
46}
47
48function clean(title: string): string {
49 return title.replace(/\*\*|__|`|~~/g, '').replace(/\s+/g, ' ').trim()
50}
51
52function baseName(path: string): string {
53 return path.replace(/\\/g, '/').split('/').pop()?.replace(/\.md$/i, '') ?? path
54}
55
56const EXT = 'py|pyi|ts|tsx|js|jsx|mjs|cjs|json|md|yml|yaml|toml|java|kt|kts|swift|go|rs|rb|cs|cpp|cc|c|h|hpp|sql|sh|ps1|html|css|scss|xml|gradle|ini|cfg|txt'
57// A file a step names: `tools/desfire/ops.py`, burn_wizard.py, src/app.ts ...
58const FILE_NAME = new RegExp(`(?:^|[\\s(\`'"])((?:[\\w.-]+/)*[\\w-][\\w.-]*\\.(?:${EXT}))(?=$|[\\s)\`'",.:;])`, 'g')
59
60// Code a step names by module, `ops.burn` or `ev1fake.burn_script`: the module
61// is kept as "~ops", which matches a changed file named ops.<anything>.
62const MODULE_REF = new RegExp(`\`([A-Za-z_]\\w*)\\.(?!(?:${EXT})\`)[A-Za-z_][\\w.]*(?:\\(\\))?\``, 'g')
63
64function filesIn(text: string): string[] {
65 const files = [...text.matchAll(FILE_NAME)].map(m => m[1].replace(/^\.\//, '').toLowerCase())
66 const modules = [...text.matchAll(MODULE_REF)].map(m => `~${m[1].toLowerCase()}`)
67 return [...files, ...modules]
68}
69
70// The section of a task: its own line to the line before `end`. Its steps are
71// its checkboxes (else its numbered items); each step keeps the files it names
72// first, so a later "commit these files" step does not claim them.
73function section(all: Line[], start: number, end: number) {
74 let weight = 0
75 let ticked = 0
76 const boxes: number[] = []
77 const numbered: number[] = []
78 for (let i = Math.max(start, 0); i < end; i += 1) {
79 const { text, isCode } = all[i]
80 if (text.trim() !== '') weight += 1
81 if (isCode || i === start) continue
82 const box = CHECKBOX.exec(text)
83 if (box) {
84 boxes.push(i)
85 if (box[2] !== ' ') ticked += 1
86 } else if (NUMBERED_ITEM.test(text)) {
87 numbered.push(i)
88 }
89 }
90 const starts = boxes.length > 0 ? boxes : numbered
91 // A step's own line names what it works on; its notes may mention files of
92 // other steps. So the step lines claim first, then the notes, each in order.
93 const seen = new Set<string>()
94 const stepTitles = starts.map(from => clean(all[from].text.replace(CHECKBOX, '$3').replace(/^\s*\d+[.)]\s+/, '')).slice(0, 80))
95 const stepFiles: string[][] = starts.map(() => [])
96 const claim = (n: number, text: string) => {
97 for (const f of filesIn(text)) {
98 if (!seen.has(f)) {
99 seen.add(f)
100 stepFiles[n].push(f)
101 }
102 }
103 }
104 starts.forEach((from, n) => claim(n, all[from].text))
105 starts.forEach((from, n) => {
106 const to = n + 1 < starts.length ? starts[n + 1] : end
107 for (let i = from + 1; i < to; i += 1) if (!all[i].isCode) claim(n, all[i].text)
108 })
109 return {
110 weight: Math.max(1, weight),
111 steps: starts.length,
112 stepsDone: boxes.length > 0 ? ticked : 0,
113 stepTitles,
114 stepFiles,
115 }
116}
117
118// The furthest step (1-based) that names a file now being changed; 0 for none.
119export function stepReached(t: WaiterTask, changed: readonly string[]): number {
120 const files = t.stepFiles ?? []
121 const hit = (named: string) =>
122 changed.some(c => {
123 const path = c.replace(/\\/g, '/').toLowerCase()
124 if (named.startsWith('~')) return (path.split('/').pop() ?? '').replace(/\.[^.]*$/, '') === named.slice(1)
125 return path === named || path.endsWith(`/${named}`)
126 })
127 let reached = 0
128 files.forEach((names, i) => {
129 if (names.some(hit)) reached = i + 1
130 })
131 return reached
132}
133
134// `git status --porcelain` lines to the paths they name.
135export function parseStatus(stdout: string): string[] {
136 return stdout
137 .split(/\r?\n/)
138 .filter(line => line.length > 3)
139 .map(line => {
140 const path = line.slice(3)
141 const to = path.includes(' -> ') ? path.split(' -> ').pop()! : path
142 return to.replace(/^"|"$/g, '')
143 })
144}
145
146function task(key: string, title: string, s: ReturnType<typeof section>, extra: Partial<WaiterTask>): WaiterTask {
147 const t = clean(title)
148 const isDone = DONE_MARK.test(title) || (s.steps > 0 && s.stepsDone === s.steps)
149 return { key, title: t, ...s, isDone, isManual: MANUAL_MARK.test(t), ...extra }
150}
151
152function byHeadings(all: Line[], match: (text: string) => { level: number; num?: string; noun?: string; title: string } | null) {
153 const found = all
154 .map((l, i) => (l.isCode ? null : { i, m: match(l.text) }))
155 .filter((x): x is { i: number; m: NonNullable<ReturnType<typeof match>> } => x !== null && x.m !== null)
156 if (found.length < 2) return null
157 // The level that holds most of them is the task level.
158 const counts = new Map<number, number>()
159 for (const f of found) counts.set(f.m.level, (counts.get(f.m.level) ?? 0) + 1)
160 const level = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0])[0][0]
161 const picked = found.filter(f => f.m.level === level)
162 if (picked.length < 2) return null
163 return picked.map(f => {
164 let end = all.length
165 for (let j = f.i + 1; j < all.length; j += 1) {
166 const h = all[j].isCode ? null : ANY_HEADING.exec(all[j].text)
167 if (h && h[1].length <= level) {
168 end = j
169 break
170 }
171 }
172 const key = f.m.num !== undefined ? `${(f.m.noun ?? '').toLowerCase()}-${f.m.num}` : `l${f.i}`
173 return task(key, f.m.title, section(all, f.i, end), { num: f.m.num, noun: f.m.noun })
174 })
175}
176
177function byXml(text: string): WaiterTask[] | null {
178 const blocks = [...text.matchAll(/<task\b([^>]*)>([\s\S]*?)<\/task>/gi)]
179 if (blocks.length < 2) return null
180 return blocks.map((b, n) => {
181 const name = /<name>([\s\S]*?)<\/name>/i.exec(b[2])?.[1] ?? b[2].trim().split(/\r?\n/)[0] ?? `Task ${n + 1}`
182 const body = lines(b[2])
183 const s = section(body, -1, body.length)
184 const isDone = /status\s*=\s*["']?(done|complete|completed)/i.test(b[1])
185 const t = task(`task-${n + 1}`, name, s, { num: String(n + 1), noun: 'Task' })
186 return { ...t, isDone: t.isDone || isDone }
187 })
188}
189
190function byCheckboxes(all: Line[]): WaiterTask[] | null {
191 const top = all
192 .map((l, i) => ({ i, m: l.isCode ? null : CHECKBOX.exec(l.text) }))
193 .filter(x => x.m !== null && x.m[1].length === 0)
194 if (top.length < 2) return null
195 return top.map((x, n) => {
196 let end = all.length
197 for (let j = x.i + 1; j < all.length; j += 1) {
198 const l = all[j]
199 if (l.isCode) continue
200 const box = CHECKBOX.exec(l.text)
201 if ((box && box[1].length === 0) || ANY_HEADING.test(l.text)) {
202 end = j
203 break
204 }
205 }
206 const s = section(all, x.i, end)
207 const own = x.m![2] !== ' '
208 const t = task(`box-${n + 1}`, x.m![3], s, {})
209 // A ticked top-level box is done whatever its children say.
210 return { ...t, isDone: own || t.isDone }
211 })
212}
213
214// Reads a plan's structure. Null when the text holds no recognizable list of tasks.
215export function parsePlan(text: string, path: string): WaiterPlan | null {
216 const all = lines(text)
217 const h1 = all.find(l => !l.isCode && /^#\s+\S/.test(l.text))
218 const title = clean(h1 ? h1.text.replace(/^#\s+/, '') : baseName(path))
219 const token = (TOKEN.exec(title) ?? TOKEN.exec(baseName(path).replace(/[-_]/g, ' ')))?.[1]?.toLowerCase()
220
221 const tries: [WaiterKind, () => WaiterTask[] | null][] = [
222 ['xml', () => byXml(text)],
223 [
224 'headings',
225 () =>
226 byHeadings(all, t => {
227 const m = TASK_HEADING.exec(t)
228 return m ? { level: m[1].length, noun: m[2], num: m[3], title: `${m[2]} ${m[3]}: ${m[4] || ''}`.replace(/:\s*$/, '') } : null
229 }),
230 ],
231 [
232 'numbered',
233 () =>
234 byHeadings(all, t => {
235 const m = NUMBERED_HEADING.exec(t)
236 return m && !m[2].includes('.') ? { level: m[1].length, num: m[2], title: `${m[2]}. ${m[3]}` } : null
237 }),
238 ],
239 ['checkboxes', () => byCheckboxes(all)],
240 [
241 'sections',
242 () => {
243 if (!isPlanPath(path)) return null
244 const found = byHeadings(all, t => {
245 const m = ANY_HEADING.exec(t)
246 return m && m[1].length >= 2 && m[1].length <= 4 ? { level: m[1].length, title: m[2] } : null
247 })
248 return found && found.length >= 3 ? found : null
249 },
250 ],
251 ]
252 for (const [kind, run] of tries) {
253 const tasks = run()
254 if (tasks && tasks.length >= 2) return { title, path, kind, token, tasks }
255 }
256 return null
257}
258
259export function isPlanPath(path: string): boolean {
260 return PLAN_PATH.test(path)
261}
262
263// The session's own task list as a plan of equal-weight tasks.
264export function sessionPlan(list: readonly WaiterSessionTask[]): WaiterPlan | null {
265 if (list.length === 0) return null
266 return {
267 title: 'Session tasks',
268 path: '',
269 kind: 'session',
270 tasks: list.map(s => ({
271 key: `s-${s.id}`,
272 title: s.subject,
273 weight: 1,
274 steps: 0,
275 stepsDone: 0,
276 isDone: s.status === 'completed',
277 isManual: false,
278 })),
279 }
280}
281
282export type Commit = { at: number; subject: string }
283
284// Which plan tasks the commits finish: "Task 3", "(4b Task 3)", "Phase 2: ...".
285// Commits before the plan existed, commits about the plan itself, and commits
286// tagged with another delivery's token ("4b" against a "4c" plan) do not count.
287export function matchCommits(plan: WaiterPlan, commits: readonly Commit[], since: number): WaiterDone {
288 const done: WaiterDone = {}
289 const shape = /\b\d+[a-z]\b/gi
290 for (const c of commits) {
291 if (c.at < since) continue
292 if (/^\s*(plan|spec|design|memo|notes?|wip)\b/i.test(c.subject)) continue
293 if (plan.token) {
294 const tokens = (c.subject.match(shape) ?? []).map(t => t.toLowerCase())
295 if (tokens.length > 0 && !tokens.includes(plan.token)) continue
296 }
297 for (const t of plan.tasks) {
298 if (t.num === undefined || t.noun === undefined) continue
299 const re = new RegExp(`\\b${t.noun}\\s*#?${t.num.replace(/\./g, '\\.')}(?![.\\d])`, 'i')
300 if (!re.test(c.subject)) continue
301 // The last one counts: "Task 5", then "Task 5 fix 1" after the review.
302 const was = done[t.key]
303 if (!was || c.at > was.at) done[t.key] = { at: c.at, by: 'git' }
304 }
305 }
306 return done
307}
308
309// Session tasks that name a plan task ("Task 3: ...") lend it their times.
310export function sessionFor(plan: WaiterPlan, list: readonly WaiterSessionTask[]): Map<string, WaiterSessionTask> {
311 const out = new Map<string, WaiterSessionTask>()
312 if (plan.kind === 'session') {
313 for (const s of list) out.set(`s-${s.id}`, s)
314 return out
315 }
316 for (const s of list) {
317 const m = new RegExp(`\\b(${NOUNS})\\s*#?(\\d+[a-z]?(?:\\.\\d+)*)\\b`, 'i').exec(s.subject)
318 const hit = m
319 ? plan.tasks.find(t => t.num === m[2] && (t.noun ?? '').toLowerCase() === m[1].toLowerCase())
320 : plan.tasks.find(t => t.title.toLowerCase().includes(s.subject.toLowerCase()))
321 if (hit) out.set(hit.key, s)
322 }
323 return out
324}
325
326export type RowState = 'done' | 'current' | 'todo'
327export type Row = {
328 key: string
329 title: string
330 state: RowState
331 isManual: boolean
332 steps: number
333 stepsDone: number
334 estMs: number
335 tookMs?: number
336 leftMs?: number // current only; negative = over the estimate
337 isReview?: boolean // committed, review and fixes may still follow
338 stepAt?: number // current only: the step its changed files have reached (1-based)
339 stepTitle?: string
340 fraction: number // 0..1 for this task's bar
341}
342export type View = {
343 title: string
344 path: string
345 kind: WaiterKind
346 rows: Row[]
347 doneCount: number
348 fraction: number
349 leftMs: number
350 etaAt: number
351 rate: number
352 measured: number // tasks whose real duration fed the rate
353}
354
355// Puts times on the plan: what is done, which task runs, how long each takes.
356export function estimate(
357 plan: WaiterPlan,
358 done: WaiterDone,
359 session: readonly WaiterSessionTask[],
360 startedAt: number,
361 learnedRate: number,
362 now: number,
363 changed: readonly string[] = [],
364): View {
365 const linked = sessionFor(plan, session)
366 const perItem = plan.kind === 'session'
367 const finished = plan.tasks.map(t => {
368 const s = linked.get(t.key)
369 const at = s?.doneAt ?? done[t.key]?.at
370 const isDone = t.isDone || s?.status === 'completed' || done[t.key] !== undefined
371 return { t, s, isDone, at }
372 })
373 // Plans run in order: a finished task means the ones before it are finished too
374 // (an older plan whose commits never named its tasks still reads as done).
375 const lastDoneIdx = finished.map(f => f.isDone).lastIndexOf(true)
376 for (let i = 0; i < lastDoneIdx; i += 1) finished[i].isDone = true
377 // The newest task finished only by a recent commit is still in review, unless
378 // the session already marked it done or works on a later task.
379 let reviewIdx = -1
380 const newest = finished[lastDoneIdx]
381 if (newest) {
382 const g = done[newest.t.key]
383 const next = finished[lastDoneIdx + 1]
384 const isLaterRunning =
385 finished.slice(lastDoneIdx + 1).some(f => f.s?.status === 'in_progress') ||
386 (next !== undefined && stepReached(next.t, changed) > 0)
387 if (
388 !newest.t.isDone &&
389 newest.s?.status !== 'completed' &&
390 g?.by === 'git' &&
391 now - g.at < REVIEW_MS &&
392 !isLaterRunning
393 ) {
394 newest.isDone = false
395 reviewIdx = lastDoneIdx
396 }
397 }
398
399 // Real durations, in plan order: each task from the end of the one before.
400 let prev = startedAt
401 let spent = 0
402 let weighed = 0
403 let measured = 0
404 const took = new Map<string, number>()
405 for (const f of finished) {
406 if (!f.isDone || f.at === undefined) continue
407 const from = f.s?.startedAt ?? prev
408 const ms = f.at - from
409 if (ms > 0 && ms <= GAP_MS) {
410 took.set(f.t.key, ms)
411 if (!f.t.isManual) {
412 spent += ms
413 weighed += f.t.weight
414 measured += 1
415 }
416 }
417 prev = Math.max(prev, f.at)
418 }
419 const rate = perItem
420 ? measured > 0
421 ? spent / weighed
422 : ITEM_MS
423 : measured > 0
424 ? spent / weighed
425 : learnedRate
426 const est = (t: WaiterTask) => {
427 const ms = t.weight * rate
428 return t.isManual ? Math.max(ms, MANUAL_MIN_MS) : ms
429 }
430
431 const lastDone = finished.reduce((m, f) => (f.isDone && f.at !== undefined ? Math.max(m, f.at) : m), startedAt)
432 const currentIdx = (() => {
433 if (reviewIdx >= 0) return reviewIdx
434 const running = finished.findIndex(f => !f.isDone && f.s?.status === 'in_progress')
435 return running >= 0 ? running : finished.findIndex(f => !f.isDone)
436 })()
437
438 let total = 0
439 let doneWeight = 0
440 let partWeight = 0
441 let leftMs = 0
442 const rows: Row[] = finished.map((f, i) => {
443 const { t } = f
444 total += t.weight
445 const e = est(t)
446 const base = { key: t.key, title: t.title, isManual: t.isManual, steps: t.steps, stepsDone: t.stepsDone, estMs: e }
447 if (f.isDone) {
448 doneWeight += t.weight
449 return { ...base, state: 'done', tookMs: took.get(t.key), fraction: 1 }
450 }
451 if (i === reviewIdx) {
452 partWeight += t.weight * 0.95
453 return { ...base, state: 'current', isReview: true, fraction: 0.95 }
454 }
455 if (i === currentIdx) {
456 const from = f.s?.startedAt ?? lastDone
457 const elapsed = now - from >= 0 && now - from <= GAP_MS ? now - from : 0
458 const byTime = Math.min(elapsed / e, 0.95)
459 const ticked = t.steps > 0 ? t.stepsDone / t.steps : 0
460 // The files being changed say which step the work is on: halfway through it.
461 const reached = stepReached(t, changed)
462 if (reached > 0 && t.steps > 0) {
463 const fraction = Math.max((reached - 0.5) / t.steps, ticked)
464 partWeight += t.weight * fraction
465 const left = e * (1 - fraction)
466 leftMs += left
467 return { ...base, state: 'current', leftMs: left, fraction, stepAt: reached, stepTitle: t.stepTitles?.[reached - 1] }
468 }
469 const fraction = Math.max(byTime, ticked)
470 partWeight += t.weight * fraction
471 const left = e - elapsed
472 leftMs += Math.max(left, 0)
473 return { ...base, state: 'current', leftMs: left, fraction }
474 }
475 leftMs += e
476 return { ...base, state: 'todo', fraction: t.steps > 0 ? t.stepsDone / t.steps : 0 }
477 })
478
479 return {
480 title: plan.title,
481 path: plan.path,
482 kind: plan.kind,
483 rows,
484 doneCount: rows.filter(r => r.state === 'done').length,
485 fraction: total > 0 ? (doneWeight + partWeight) / total : 0,
486 leftMs,
487 etaAt: now + leftMs,
488 rate,
489 measured,
490 }
491}
492
493export function bar(fraction: number, width: number): string {
494 const w = Math.max(1, Math.floor(width))
495 const full = Math.round(Math.min(Math.max(fraction, 0), 1) * w)
496 return '█'.repeat(full) + '░'.repeat(w - full)
497}
498
499export function duration(ms: number): string {
500 const m = Math.round(Math.abs(ms) / 60_000)
501 if (m < 1) return '<1m'
502 if (m < 60) return `${m}m`
503 const h = Math.floor(m / 60)
504 const r = m % 60
505 return r === 0 ? `${h}h` : `${h}h ${r}m`
506}
507
508export function clock(at: number): string {
509 const d = new Date(at)
510 const hh = String(d.getHours()).padStart(2, '0')
511 const mm = String(d.getMinutes()).padStart(2, '0')
512 return `${hh}:${mm}`
513}
514
515export function parseGitLog(stdout: string): Commit[] {
516 return stdout
517 .split(/\r?\n/)
518 .map(line => {
519 const tab = line.indexOf('\t')
520 if (tab < 0) return null
521 const at = Number(line.slice(0, tab)) * 1000
522 return Number.isFinite(at) ? { at, subject: line.slice(tab + 1) } : null
523 })
524 .filter((c): c is Commit => c !== null)
525}
526types/index.d.ts 73 lines1// What a plan's structure came from: task headings, numbered headings, GSD
2// <task> blocks, a top-level checkbox list, plain sections, or the
3// session's own task list (TaskCreate / TodoWrite).
4export type WaiterKind =
5 | 'headings'
6 | 'numbered'
7 | 'xml'
8 | 'checkboxes'
9 | 'sections'
10 | 'session'
11
12export type WaiterTask = {
13 key: string
14 // The task's number as the plan writes it ("6", "2.1"), when it has one.
15 num?: string
16 // The word in front of the number ("Task", "Phase"): what commits say.
17 noun?: string
18 title: string
19 // Effort proxy: non-blank lines of the task's section (1 per session task).
20 weight: number
21 steps: number
22 stepsDone: number
23 // Each step's title, and the files it is the first to name.
24 stepTitles?: string[]
25 stepFiles?: string[][]
26 isDone: boolean
27 // A hands-on task (CONTROLLER, manual, acceptance): flat estimate, not learned from.
28 isManual: boolean
29}
30
31export type WaiterPlan = {
32 title: string
33 // Absolute path with forward slashes; '' for the session task list.
34 path: string
35 kind: WaiterKind
36 // Delivery / phase token from the title ("4b"), used to filter commits.
37 token?: string
38 tasks: WaiterTask[]
39}
40
41export type WaiterSessionTask = {
42 id: string
43 subject: string
44 status: 'pending' | 'in_progress' | 'completed'
45 startedAt?: number
46 doneAt?: number
47}
48
49// When each task was seen finished (epoch ms), by task key, and by what.
50export type WaiterDone = Record<string, { at: number; by: 'git' | 'file' | 'session' }>
51
52export type WaiterMode = 'pane' | 'band' | 'hidden'
53
54declare module 'claude-code' {
55 interface PluginState {
56 waiter: {
57 plan: WaiterPlan | null
58 done: WaiterDone
59 session: WaiterSessionTask[]
60 // When tracking of this plan started (plan file creation, or first seen).
61 startedAt: number
62 // Learned milliseconds per weight unit (lines), across plans and projects.
63 rate: number
64 mode: WaiterMode
65 note: string
66 // The plan came back from an earlier session, not from this one's work.
67 isRestored: boolean
68 // Files changed and not yet committed, in every working folder of the plan's repo.
69 changed: string[]
70 }
71 }
72}
73