SLOPSHOPPER

waiter

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

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-07yarivzip/waiter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · waiter
│ ┃ waiter ✕ › fix the failing auth test and add an audit log call │ ┃ No plan tracked. /waiter path/to/plan.md, or │ ┃ start executing a plan. ⏺ Read(src/auth.ts) │ ┃ [ Minimize ] ⎿ 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 │ │ › /waiter │ ⎿ waiter: no plan yet. Run /waiter <plan.md>, or read or execute a │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · waiter
No plan tracked. /waiter path/to/plan.md, or start executing a plan. [ Minimize ]
README

waiter: plan progress for Claude Code

waiter is a Claude Code mod. While Claude works through a plan, it shows a progress bar and the time left for each task.

  • It finds the plan by itself. When Claude opens or edits a plan file, waiter picks it up. It reads many plan layouts:
  • ### Task 1: / ## Phase 2 headings
  • numbered headings
  • checkbox lists
  • GSD <task> blocks

With no plan file, it shows the session's own task list.

  • It knows what is finished from:
  • the session's task list
  • git commits that name a task, like Task 3 or (4b Task 3)
  • ticked checkboxes
  • The time estimates come from how much text each task has in the plan. They improve as tasks finish, and what it learns carries over to later plans. Hands-on tasks (CONTROLLER, manual, acceptance) get a flat 20 minutes and are marked [you].
  • It shows which step the current task is on, like "step 3/5 ~8m". It finds the step from the files being changed right now (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.
  • It works across sessions. Plan work done in another Claude session of the same project shows up within 2 minutes, through that session's git commits.
  • Minimize turns it into one line above the prompt; Expand brings the full panel back.

Install

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.

The short way: hand it to Claude

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.

By hand

  1. Clone the repo into any folder outside .claude:

Windows:

   git clone https://github.com/yarivzip/waiter D:\tools\waiter

macOS / Linux:

   git clone https://github.com/yarivzip/waiter ~/tools/waiter
  1. Tell Claude Code to load it. Open your personal settings file (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"
     }
   }
  • If the file already has an "env" block, add only the CLAUDE_CODE_PLUGIN_DIRS line inside it, with a comma after the line above it.
  • If you already load other plugin folders this way, list them all in one value, separated by ; on Windows or : on macOS / Linux.
  1. Start a new Claude Code session. Sessions that are already running don't pick it up.

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.

Use

Most of the time you do nothing: start executing a plan and the panel appears.

CommandWhat it does
/waiteropen the panel
/waiter path/to/plan.mdtrack that plan file
/waiter minminimize to one line above the prompt
/waiter refreshre-read the plan and the commits now
/waiter offstop 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.

Good to know

  • Each session shows one plan. It switches when the session opens a newer plan, starts writing one, or finishes the current one. An older plan opened only to look something up does not take over.
  • Waiter only reads files that Claude already opened through its normal tools. Any guard hooks you have (for example, folders Claude must never open) still apply. /waiter <path> also goes through Claude's Read tool.
  • "saved, in review": the newest task with a commit stays in this state for 20 minutes, because review and fix commits often follow. It also ends as soon as the next task starts.
  • Remove it: delete the CLAUDE_CODE_PLUGIN_DIRS line and the folder.
  • Run its tests: claude plugin test <folder>.
  • License: MIT, see LICENSE.
Source 3 files
hooks/register.tsx 523 lines
1import { 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}
523
hooks/plan.ts 526 lines
1// 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}
526
types/index.d.ts 73 lines
1// 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