A tool any spec-driven flow calls to report the progress of a feature and its tasks; a band above the prompt shows the stage and the count, and a pane lists…

<h1 align="center">📋 feature-progress</h1>
A Claude Code mod that shows the progress of a feature and its tasks while a spec-driven flow runs: a band above the prompt with the stage and the count, and a pane with every task.
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fdougcunha%2Fclaude-mods%2Fmain%2Ffeature-progress%2F.claude-plugin%2Fplugin.json&query=%24.version&label=version&color=blue" alt="Version"> <a href="../LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a> <img src="https://img.shields.io/badge/Claude%20Code-2.1.310-d97757.svg" alt="Tested on Claude Code 2.1.310">
<a href="#-install">Install</a> · <a href="#-report-progress-from-your-flow">Report progress</a> · <a href="#-settings">Settings</a> · <a href="#-limitations">Limitations</a> · <a href="#-development">Development</a>
The mod does not read the files of any flow. It registers 1 tool, mcp__feature-progress__report, and the flow calls it at each change of state. Any flow can report: SDD skills, other skills with other artifact names, or a prompt.
✎ Checkout fixing #1 ━━━━━─── 12/17. A stale feature ends with ⚠ stale. On a narrow band the meter goes first, then the title shortens. Press the band to open the pane. With no open feature, the band shows nothing. /feature-progress band hides or shows it./feature-progress): a ↻ Refresh button and the counts of open and done features, then 1 card per open feature, the latest first and expanded, the others collapsed. A card has a rounded frame that lights in the state's color under the pointer, the stage chip, a meter with the count and the percent, and updated X ago. Each task row shows a glyph and a word for its state, the round, a meter for its steps (━━━━──── 2/5), how long it ran, an ↗ open button for its link, and its note under it. Tasks without a group come first, then 1 block per group, such as ── Review #1 1/3. Done features wait in a collapsed Done section. Below 64 columns the details of a task go under its title; below 44 columns the cards lose their frame..claude/feature-progress/ by default). The progress survives /clear, /resume and a new session, and another session in the same repository sees it after Refresh.Every state has a glyph and a word; the color only adds to them. Nothing animates. Set paneBackground to paint the pane body, and icons to pick Nerd Font or emoji glyphs.
<img src="images/band.png" alt="The band above the prompt: the waiting glyph, the feature title, a waiting chip, a meter and 26/29" width="560">
<img src="images/pane-unicode.png" alt="The pane with the unicode glyphs: a fixing feature card with its meter, done, pending and blocked tasks and a Review #1 group" width="260"> <img src="images/pane-nerd.png" alt="The same pane with the nerd glyphs" width="260"> <img src="images/pane-emoji.png" alt="The same pane with the emoji glyphs" width="260">
At the Claude Code prompt:
/plugin install feature-progress --marketplace dougcunha/claude-mods
Answer y to add the marketplace and choose a scope. If you already added the marketplace:
/plugin install feature-progress@claude-mods
Add the storage folder to the repository's .gitignore, unless you want the progress files in Git:
.claude/feature-progress/
Tell the session that runs your flow to call mcp__feature-progress__report at each change of state. Only the main session reports; a call from a subagent is answered Ignored: only the main session reports progress. and stores nothing. A skill that reports should skip the step when the tool is not there, so the flow also runs without the mod.
| Field | Type | Required | Rule |
|---|---|---|---|
feature.id | string | always | Lower case letters, digits and -, 1 to 64 characters, such as the slug of the feature folder. It names the file. |
feature.title | string | first call | Not empty. |
feature.state | state | no | One of the 9 states below; a new feature without one is pending. |
feature.label | string | no | Shown instead of the state word in the band and the feature chip, for example Revisão. "" clears it. |
feature.round | integer or null | no | The review, correction or QA round, 1 or more; shown as #2. null clears it. |
feature.note | string | no | A short reason or remark. "" clears it. |
tasks[] | array | no | The tasks that changed, each as below. |
tasks[].id | string | always | 1 to 64 characters, no control character. |
tasks[].title | string | new task | Not empty. |
tasks[].state, .label, .round, .note | no | As for the feature, except that a task label shows next to the state word, for example review #1 · Revisão, so the word for the state stays. | |
tasks[].group | string | no | The pane groups tasks by it, for example Review #1. "" clears it. |
tasks[].steps | { done, total } or null | no | Whole numbers, total 1 or more, done from 0 to total. null clears it. |
tasks[].link | string | no | A file path, relative to the session's working folder or absolute. "" clears it. |
remove | string[] | no | Ids of tasks to delete; an unknown id is ignored. |
feature.id always, feature.title on the first call, and only what changed.id. A new id adds the task at the end of the list; a known id changes only the fields the call sends. One call may not send the same task id twice.updatedAt on every change, startedAt the first time a feature or task leaves pending, finishedAt when it enters done (cleared when it leaves done). Every report also moves the feature's updatedAt.done tasks out of all tasks, correction tasks included.feature-progress: tasks[0].state must be one of: pending, planning, active, review, fixing, qa, waiting, blocked, done, and nothing is written. A valid call answers the stage and the count, for example feature-progress: review #2 · 12/17.| State | Glyph (unicode) | Emoji | Color | Use it for |
|---|---|---|---|---|
pending | ○ | ⚪ | inactive | Not started |
planning | ◇ | 📝 | suggestion | Writing the requirements, the design or the plan |
active | ● | 🚀 | claude | Implementing |
review | ◎ | 🔍 | permission | In code review, with the review number as round |
fixing | ✎ | 🔧 | warning | Correcting what a review or QA found, with the round |
qa | ◆ | 🧪 | merged | In QA, with the QA run number as round |
waiting | ◷ | ⏳ | planMode | Waiting on a person, such as an approval |
blocked | ✕ | ⛔ | error | Blocked, with the reason as note |
done | ✓ | ✅ | success | Finished |
The colors are names of the Claude Code theme, so they follow the theme you pick in /config. The nerd set draws Font Awesome glyphs from a Nerd Font: circle, compass, play, eye, wrench, flask, hourglass, ban and check.
The first call creates the feature and its plan:
{
"feature": { "id": "02-checkout", "title": "Checkout", "state": "active" },
"tasks": [
{ "id": "T01", "title": "Cart totals", "state": "active", "steps": { "done": 0, "total": 3 }, "link": "tasks/prd-02-checkout/task_01.md" },
{ "id": "T02", "title": "Payment step" }
]
}
A later call moves 1 task to done and starts the next:
{
"feature": { "id": "02-checkout" },
"tasks": [
{ "id": "T01", "state": "done", "steps": { "done": 3, "total": 3 } },
{ "id": "T02", "state": "active" }
]
}
A review round, its correction tasks, and the next stage:
{
"feature": { "id": "02-checkout", "state": "fixing", "round": 1 },
"tasks": [{ "id": "T08", "title": "Fix CR-01", "group": "Review #1", "link": "tasks/prd-02-checkout/codereview_01/task_08.md" }]
}
<folder>/<id>.json holds schemaVersion (1), the feature fields, its timestamps and tasks. A file the mod cannot read as a feature (broken JSON, another schemaVersion, a missing field, or a name that is not <id>.json) shows as a warning row in the pane, and a report for that id is refused until you fix or delete the file. A report reads the file before it writes, so it keeps what another session wrote since this session last read the folder.
| Command | What it does |
|---|---|
/feature-progress | Opens the pane, or closes it when it is open. |
/feature-progress band | Hides the band, or shows it when it is hidden. It changes the showBand setting as /config does, so the choice stays after /clear, /resume and in new sessions. The pane and the reports still work while the band is hidden. |
/feature-progress clean | Deletes the files of every done feature in the storage folder, and toasts Deleted N done features. The files of open features stay. |
The pane opens at any width when you ask for it. When the terminal is too narrow, a toast says feature-progress: widen the terminal to open the pane.
Set them in /config, under the mod's name.
| Option | Default | What it does |
|---|---|---|
folder | .claude/feature-progress/ | The folder of the feature files, relative to the session's working folder or absolute. Empty uses the default. |
staleMinutes | 30 | Minutes without a report before an open feature shows ⚠ stale. A waiting or done feature is never stale. 0 turns the marker off. |
editor | auto | The program the open button starts: auto (VS Code when its code command is on the PATH, else the system's default program), vscode, or system. |
showBand | true | Shows the band above the prompt. false hides it; /feature-progress band flips it. |
paneBackground | empty | A color for the body of the pane in the terminal: hex such as #2e3440, or a color name. Empty keeps the color Claude Code draws. The frame and the close row keep Claude Code's color, and the desktop app ignores it. |
icons | unicode | The glyphs of the states: unicode works in every terminal, nerd needs a Nerd Font, emoji needs a terminal that draws emoji 2 cells wide. |
/clear, /resume or a new session.<id>.json files in the storage folder. This is a documented exception to the repository rule that mods write only to the temporary folder./feature-progress clean runs rm -f -- <id>.json … (macOS, Linux) or cmd /d /c del /f /q <id>.json … (Windows) with the storage folder as the working folder. The ids allow only lower case letters, digits and -, so no name carries shell syntax.cmd /c code. A link whose path holds & | < > ^ % ! " is not opened, because the link comes from the model; a toast says so.open, xdg-open, rm) are covered by unit tests only.claude plugin validate .
claude plugin test feature-progress
claude --plugin-dir ./feature-progress
| File | Role |
|---|---|
hooks/register.tsx | The tool, the command, the band and the pane; the session.start, session.end, tool.call, command.run and ui.render hooks |
hooks/report.ts | The tool input narrowing, the merge, remove and the timestamps |
hooks/store.ts | The storage folder, the file parser, the 4 MiB guard, loading, and the delete argv |
hooks/progress.ts | Stage text, count, order, groups, stale marker, relative times, the meter share and the percent |
hooks/spec.ts | The tool spec and its description |
hooks/band.tsx, hooks/pane.tsx | The band line, and the pane with its cards and task rows |
hooks/meter.tsx | The meter of done cells |
hooks/icons.ts | The glyph of each state per icon set, and its color |
hooks/viewer.ts, hooks/platform.ts | Opening a link, and the platform |
hooks/settings.ts | The settings and their defaults |
hooks/paths.ts, hooks/host.ts | Path helpers, and the engine calls the helper modules use |
types/index.d.ts | The stored feature types and the $.state contract |
hooks/register.tsx 236 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
3
4import type { Feature } from '../types'
5
6import { bandLine } from './band'
7import type { Host } from './host'
8import { flip, paneBody } from './pane'
9import { isStale, latestOpen, orderFeatures, replyText } from './progress'
10import { applyReport, parseReport } from './report'
11import { settingsOf } from './settings'
12import type { Settings } from './settings'
13import { IGNORED_RESULT, REPORT_SPEC, REPORT_TOOL } from './spec'
14import { platformOf } from './platform'
15import { cleanArgv, fileName, fileOf, folderOf, loadAll, readOne, serialize } from './store'
16import { openLink } from './viewer'
17
18const PLUGIN = 'feature-progress'
19
20/** The pane's id and title. */
21const PANE_ID = 'feature-progress'
22const PANE_TITLE = 'Feature progress'
23
24/** The slash command: it opens or closes the pane; `band` shows or hides the band; `clean` deletes the files of done features. */
25const COMMAND = {
26 name: 'feature-progress',
27 description: 'Open or close the feature-progress pane; band shows or hides the band; clean deletes the files of done features.',
28 argumentHint: '[band|clean]',
29} as const
30
31const USAGE_TEXT =
32 'Usage: /feature-progress opens or closes the pane; /feature-progress band shows or hides the band; /feature-progress clean deletes the files of done features.'
33
34/** The `/config` key of the `showBand` option (DEC-13). */
35const SHOW_BAND_KEY = `${PLUGIN}.showBand`
36
37/** How often a reload after /clear or /resume checks whether the session id switched, and for how long. */
38const SWITCH_POLL_MS = 200
39const SWITCH_LIMIT_MS = 10_000
40
41/** How often the relative times and the stale marker redraw; the tick reads no file (DEC-10). */
42const REDRAW_MS = 60_000
43
44const features = atom({ plugin: 'feature-progress', key: 'features' } as const, {} as Record<string, Feature>)
45const warnings = atom({ plugin: 'feature-progress', key: 'warnings' } as const, [] as string[])
46const toggled = atom({ plugin: 'feature-progress', key: 'toggled' } as const, [] as string[])
47
48function hostOf($: EngineInterface): Host {
49 return {
50 run: (argv, cwd) => $.process.run(argv, cwd === undefined ? undefined : { cwd }),
51 cwd: () => $.session.cwd(),
52 readFile: path => $.fs.read(path),
53 writeFile: (path, text) => $.fs.write(path, text),
54 list: path => $.fs.list(path),
55 exists: path => $.fs.exists(path),
56 env: async () => ({ os: await $.env.get('OS') }),
57 toast: text => $.ui.toast(text),
58 }
59}
60
61/** Reads the storage folder into the state and redraws (FR-16). */
62async function load($: EngineInterface, settings: Settings): Promise<void> {
63 const host = hostOf($)
64 const loaded = await loadAll(host, folderOf(await host.cwd(), settings.folder))
65 await update($, features, () => loaded.features)
66 await update($, warnings, () => loaded.warnings)
67 $.ui.invalidate('ui.render')
68}
69
70/** After /clear or /resume the state starts empty and the session id switches later: reload once it did (DEC-09). */
71function afterSwitch($: EngineInterface, endedId: string, reload: () => Promise<void>): void {
72 let waited = 0
73 const poll = async (): Promise<void> => {
74 if ((await $.session.id().catch(() => endedId)) !== endedId) return reload()
75 waited += SWITCH_POLL_MS
76 if (waited < SWITCH_LIMIT_MS) $.clock.after(SWITCH_POLL_MS, () => void poll().catch(() => undefined))
77 }
78 $.clock.after(SWITCH_POLL_MS, () => void poll().catch(() => undefined))
79}
80
81/** Validates a report, merges it with the file on disk, writes it and updates the state (DEC-02, DEC-03). */
82async function report($: EngineInterface, settings: Settings, input: unknown): Promise<ToolCallResult> {
83 const parsed = parseReport(input)
84 if (typeof parsed === 'string') return { deny: parsed }
85 const host = hostOf($)
86 const path = fileOf(folderOf(await host.cwd(), settings.folder), parsed.feature.id)
87 const stored = await readOne(host, path)
88 if (stored.kind === 'unreadable') return { deny: `feature-progress: ${path} is not a readable feature file; fix or delete it, then report again` }
89 const next = applyReport(stored.kind === 'feature' ? stored.feature : undefined, parsed, await $.clock.now())
90 if (typeof next === 'string') return { deny: next }
91 const file = serialize(next)
92 if ('error' in file) return { deny: file.error }
93 await host.writeFile(path, file.text)
94 await update($, features, all => ({ ...(all ?? {}), [next.id]: next }))
95 await update($, warnings, list => (list ?? []).filter(warning => warning !== path))
96 $.ui.invalidate('ui.render')
97
98 return { result: replyText(next) }
99}
100
101/** Opens the pane with focus, or focuses it when it is open; a pane the terminal is too narrow for is reported (FR-10). */
102async function openPane($: EngineInterface): Promise<{ text: string }> {
103 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE, focus: true })
104 if (!opened.isPlaced) {
105 $.ui.toast(`${PLUGIN}: widen the terminal to open the pane.`)
106 return { text: 'Feature progress pane waits for a wider terminal.' }
107 }
108
109 return { text: 'Feature progress pane opened.' }
110}
111
112/** Opens the pane, or closes it when it is open; the slash command's toggle (FR-10). */
113async function togglePane($: EngineInterface): Promise<{ text: string }> {
114 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE_ID)
115 if (!isOpen) return openPane($)
116 await $.ui.close({ id: PANE_ID })
117
118 return { text: 'Feature progress pane closed.' }
119}
120
121/** Flips `showBand` as a change in /config does; the engine then loads the module again with the new value (DEC-13). */
122async function flipBand($: EngineInterface, settings: Settings): Promise<{ text: string }> {
123 const done = await $.config.set({ key: SHOW_BAND_KEY, value: !settings.showBand })
124 if (done.deny !== undefined) return { text: `${PLUGIN}: ${done.deny}` }
125
126 return { text: settings.showBand ? 'Feature progress band hidden.' : 'Feature progress band shown.' }
127}
128
129/** Deletes the files of the features that are done on disk now, then reads the folder again (FR-17, DEC-05). */
130async function clean($: EngineInterface, settings: Settings): Promise<{ text: string }> {
131 await load($, settings)
132 const done = Object.values((await read($, features)) ?? {}).filter(feature => feature.state === 'done')
133 if (done.length === 0) return { text: 'No done feature to delete.' }
134 const host = hostOf($)
135 const folder = folderOf(await host.cwd(), settings.folder)
136 const argv = cleanArgv(await platformOf(host), done.map(feature => fileName(feature.id)))
137 await host.run(argv, folder).catch(() => ({ exitCode: 1, stdout: '' }))
138 await load($, settings)
139 const left = (await read($, features)) ?? {}
140 const deleted = done.filter(feature => left[feature.id] === undefined).length
141 const text = deleted === done.length ? `Deleted ${deleted} done features.` : `Deleted ${deleted} of ${done.length} done features; the others are still in ${folder}.`
142 $.ui.toast(`${PLUGIN}: ${text}`)
143
144 return { text }
145}
146
147export const register: Register = (on, options) => {
148 const settings = settingsOf(options)
149
150 on('session.start', async ($, e, next) => {
151 await $.tool.register(REPORT_SPEC).catch(error => $.ui.toast(`${PLUGIN}: could not register the report tool: ${String(error)}`))
152 await $.command.register(COMMAND).catch(error => $.ui.toast(`${PLUGIN}: could not register /${COMMAND.name}: ${String(error)}`))
153 await load($, settings).catch(() => undefined)
154 $.clock.every(REDRAW_MS, async () => {
155 const open = latestOpen((await read($, features)) ?? {})
156 if (open !== undefined) $.ui.invalidate('ui.render')
157 })
158
159 return next(e)
160 }).catch(($, e, next) => next(e))
161
162 /** Draws the band above what the hooks beneath draw, and yields to a survey (DEC-06) or to `showBand` off (DEC-13). */
163 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
164 if (!settings.showBand || e.props.hasSurvey) return next(e)
165 const feature = latestOpen((await read($, features)) ?? {})
166 if (feature === undefined) return next(e)
167 const below = await next(e)
168 const { Box, Text, Button } = $.ui.resolve(e)
169 const band = bandLine(
170 { Box, Text, Button },
171 {
172 feature,
173 isStale: isStale(feature, await $.clock.now(), settings.staleMinutes),
174 columns: e.props.bodyColumns,
175 icons: settings.icons,
176 onOpen: () => void openPane($).catch(() => $.ui.toast(`${PLUGIN}: could not open the pane.`)),
177 },
178 )
179
180 return (
181 <Box flexDirection="column">
182 {band}
183 {below}
184 </Box>
185 )
186 })
187
188 on('session.end', async ($, e, next) => {
189 if (e.reason === 'clear' || e.reason === 'resume') afterSwitch($, e.sessionId, () => load($, settings).catch(() => undefined))
190
191 return next(e)
192 }).catch(($, e, next) => next(e))
193
194 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
195 const { Box, Text, Button } = $.ui.resolve(e)
196 const flipped = (await read($, toggled)) ?? []
197 // The API has no color for the frame the terminal draws around a pane; this paints the body under the tree instead.
198 const isTerminal = e.surface === 'terminal'
199 const paint = isTerminal && settings.paneBackground !== '' ? { backgroundColor: settings.paneBackground, minHeight: e.props.scroll.bodyRows } : {}
200
201 return paneBody(
202 { Box, Text, Button },
203 {
204 ordered: orderFeatures((await read($, features)) ?? {}),
205 warnings: (await read($, warnings)) ?? [],
206 now: await $.clock.now(),
207 staleMinutes: settings.staleMinutes,
208 columns: e.props.bodyColumns,
209 icons: settings.icons,
210 paint,
211 toggled: flipped,
212 onToggle: id => void update($, toggled, list => flip(list ?? [], id)).catch(() => undefined),
213 onRefresh: () => void load($, settings).catch(() => $.ui.toast(`${PLUGIN}: could not read the feature files.`)),
214 onOpenLink: link => void openLink(hostOf($), settings.editor, link).catch(() => $.ui.toast(`${PLUGIN}: could not open the task link.`)),
215 },
216 )
217 })
218
219 /** Gating: answers the command itself; a failure prints the reason instead of the command's output. */
220 on('command.run', { command: COMMAND.name }, async ($, e) => {
221 const argument = e.args.trim()
222 if (argument === '') return togglePane($)
223 if (argument === 'band') return flipBand($, settings)
224 if (argument === 'clean') return clean($, settings)
225
226 return { text: USAGE_TEXT }
227 }).catch((_$, _e, next) => ({ text: `${PLUGIN}: ${next.error.message ?? next.error.kind}` }))
228
229 /** Gating: the hook answers the call itself; a failure refuses it, so the model learns the report was not saved. */
230 on('tool.call', { tool: REPORT_TOOL }, async ($, e) => {
231 if (e.agentId !== undefined) return { result: IGNORED_RESULT }
232
233 return report($, settings, e)
234 }).catch((_$, _e, next) => ({ deny: `${PLUGIN}: could not save the report: ${next.error.message ?? next.error.kind}` }))
235}
236hooks/band.tsx 47 lines1import type { Elements } from 'claude-code'
2
3import type { Feature } from '../types'
4
5import { glyphColumns, styleOf } from './icons'
6import type { IconSet } from './icons'
7import { countOf, filledCells, fitTitle, METER_CELLS, STALE_MARKER, stageText } from './progress'
8import { meter } from './meter'
9
10type Parts = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
11
12/** The space between the parts of the band. */
13const SEPARATOR = ' '
14
15/** The fewest title columns the band keeps before it drops the meter. */
16const MIN_TITLE_COLUMNS = 12
17
18/** What the band draws: the open feature updated last, whether it is stale, the band's width, the icon set, and what a press does. */
19export type BandProps = { feature: Feature; isStale: boolean; columns: number; icons: IconSet; onOpen: () => void }
20
21/** One line: glyph, title cut to fit, the stage as a chip, a meter, the count and the stale marker; the meter goes first, then the title shortens (NFR-05). */
22export function bandLine({ Box, Text, Button }: Parts, { feature, isStale, columns, icons, onOpen }: BandProps) {
23 const style = styleOf(feature.state, icons)
24 const { done, total } = countOf(feature)
25 const chip = ` ${stageText(feature)} `
26 const count = ` ${done}/${total}`
27 const stale = isStale ? `${SEPARATOR}${STALE_MARKER}` : ''
28 const fixed = glyphColumns(icons) + 1 + SEPARATOR.length + [...chip].length + SEPARATOR.length + count.length + [...stale].length
29 const hasMeter = columns - fixed - METER_CELLS >= MIN_TITLE_COLUMNS
30 const title = fitTitle(feature.title, columns - fixed - (hasMeter ? METER_CELLS : 0))
31
32 return (
33 <Box key="feature-progress-band" flexDirection="row">
34 <Button plain key="feature-progress-open" onPress={onOpen}>
35 <Text color={style.color}>{`${style.glyph} `}</Text>
36 <Text bold>{title}</Text>
37 <Text>{SEPARATOR}</Text>
38 <Text bold color="inverseText" backgroundColor={style.color}>{chip}</Text>
39 <Text>{SEPARATOR}</Text>
40 {hasMeter ? meter(Text, filledCells(done, total, METER_CELLS), METER_CELLS) : null}
41 <Text bold>{count}</Text>
42 {isStale ? <Text color="warning">{stale}</Text> : null}
43 </Button>
44 </Box>
45 )
46}
47hooks/host.ts 22 lines1/** One entry of a folder listing. */
2export type FolderEntry = { name: string; kind: string }
3
4/** What the helper modules need from the engine; register.tsx builds it from `$`, which never crosses an import. */
5export type Host = {
6 /** Runs a program with its arguments as separate values; `cwd` is an absolute folder. */
7 run: (argv: string[], cwd?: string) => Promise<{ exitCode: number; stdout: string }>
8 /** The session's working directory. */
9 cwd: () => Promise<string>
10 /** A file's text; rejects when the file is missing or over 4 MiB. */
11 readFile: (path: string) => Promise<string>
12 /** Writes a file, creating its folders. */
13 writeFile: (path: string, text: string) => Promise<void>
14 /** The entries of a folder; rejects when the folder is missing. */
15 list: (path: string) => Promise<FolderEntry[]>
16 /** Whether a path exists. */
17 exists: (path: string) => Promise<boolean>
18 /** `OS` as the environment names it (`Windows_NT` on Windows). */
19 env: () => Promise<{ os?: string }>
20 toast: (text: string) => void
21}
22hooks/pane.tsx 239 lines1import type { Color, Elements } from 'claude-code'
2
3import type { Feature, Task } from '../types'
4
5import { styleOf } from './icons'
6import type { IconSet } from './icons'
7import { meter } from './meter'
8import { agoText, countOf, durationText, filledCells, groupTasks, isStale, kindText, METER_CELLS, percentText, STALE_MARKER, stageText } from './progress'
9import type { Ordered } from './progress'
10
11type Parts = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
12
13/** The key of the Done section in the list of toggled sections. */
14export const DONE_SECTION = 'done'
15
16/** What the pane shows when no feature reported progress yet. */
17export const EMPTY_TEXT = 'No feature has reported progress yet.'
18
19/** The pointer under the empty state to the contract other flows follow. */
20export const EMPTY_HINT = 'A flow reports through the mcp__feature-progress__report tool; the feature-progress README has the contract.'
21
22/** Below this width the feature cards lose their frame, as session-info's cards do. */
23export const FRAME_MIN_COLUMNS = 44
24
25/** From this width a task row holds its state, steps, time and link on the same row as its title. */
26export const INLINE_MIN_COLUMNS = 64
27
28/** Columns a frame takes: a border and one column of padding on each side. */
29const FRAME_COLUMNS = 4
30
31/** The smallest and the largest feature meter, in cells. */
32const FEATURE_METER_MIN = 10
33const FEATURE_METER_MAX = 40
34
35/** The marks in front of an expanded and a collapsed section. */
36const EXPANDED = '▾'
37const COLLAPSED = '▸'
38
39/** The paint of the pane body: a background color and the rows it covers, empty when the person set none. */
40export type PanePaint = { backgroundColor?: string; minHeight?: number }
41
42/** What the pane draws, as plain values and callbacks. */
43export type PaneProps = {
44 ordered: Ordered
45 warnings: string[]
46 now: number
47 staleMinutes: number
48 /** The width of the pane body, which picks the frames and the row layout (NFR-05). */
49 columns: number
50 icons: IconSet
51 paint: PanePaint
52 /** Feature ids, and `DONE_SECTION`, whose open or closed state the person flipped from the default. */
53 toggled: string[]
54 onToggle: (id: string) => void
55 onRefresh: () => void
56 onOpenLink: (link: string) => void
57}
58
59/** The toggled list after the person flips `id`: added when absent, removed when present. */
60export const flip = (list: string[], id: string): string[] => (list.includes(id) ? list.filter(item => item !== id) : [...list, id])
61
62/** Whether a section is expanded: the default, flipped when the person toggled it. */
63const isExpanded = (toggled: string[], id: string, isOpenByDefault: boolean): boolean => toggled.includes(id) !== isOpenByDefault
64
65/** The cells of a feature meter for the room left after the count and the percent. */
66const featureMeterCells = (room: number): number => Math.min(FEATURE_METER_MAX, Math.max(FEATURE_METER_MIN, room))
67
68/** A stage as a chip: the word on the state's color. */
69function chip({ Text }: Parts, text: string, color: Color) {
70 return (
71 <Text bold color="inverseText" backgroundColor={color}>
72 {` ${text} `}
73 </Text>
74 )
75}
76
77/** One task row, with the time to measure its duration against and what pressing its link does. */
78type Row = { task: Task; now: number; icons: IconSet; isInline: boolean; onOpenLink: (link: string) => void }
79
80function taskRow(parts: Parts, { task, now, icons, isInline, onOpenLink }: Row) {
81 const { Box, Text, Button } = parts
82 const style = styleOf(task.state, icons)
83 const duration = durationText(task, now)
84 const details = (
85 <Box flexDirection="row" gap={1} flexShrink={0}>
86 <Text color={style.color}>{kindText(task)}</Text>
87 {task.steps === undefined ? null : (
88 <Box flexDirection="row">
89 {meter(Text, filledCells(task.steps.done, task.steps.total, METER_CELLS), METER_CELLS, style.color)}
90 <Text dimColor>{` ${task.steps.done}/${task.steps.total}`}</Text>
91 </Box>
92 )}
93 {duration === '' ? null : <Text dimColor>{duration}</Text>}
94 {task.link === undefined ? null : (
95 <Button plain key={`feature-progress-link:${task.id}`} onPress={() => onOpenLink(task.link ?? '')}>
96 <Text color="suggestion">↗ open</Text>
97 </Button>
98 )}
99 </Box>
100 )
101
102 return (
103 <Box key={`feature-progress-task:${task.id}`} flexDirection="column">
104 <Box flexDirection="row" gap={1}>
105 <Text color={style.color}>{style.glyph}</Text>
106 <Text bold dimColor={task.state === 'done'}>
107 {task.id}
108 </Text>
109 <Box flexGrow={1} flexShrink={1}>
110 <Text wrap="truncate-end" dimColor={task.state === 'done'}>
111 {task.title}
112 </Text>
113 </Box>
114 {isInline ? details : null}
115 </Box>
116 {isInline ? null : <Box paddingLeft={2}>{details}</Box>}
117 {task.note === undefined ? null : (
118 <Box paddingLeft={2}>
119 <Text dimColor italic wrap="wrap">{`└ ${task.note}`}</Text>
120 </Box>
121 )}
122 </Box>
123 )
124}
125
126/** A group's title on a rule, with its own count. */
127function groupTitle({ Box, Text }: Parts, name: string, tasks: Task[]) {
128 const done = tasks.filter(task => task.state === 'done').length
129
130 return (
131 <Box flexDirection="row" gap={1}>
132 <Text color="subtle">──</Text>
133 <Text bold>{name}</Text>
134 <Text dimColor>{`${done}/${tasks.length}`}</Text>
135 </Box>
136 )
137}
138
139/** One feature of the list, and whether it starts expanded. */
140type Block = { feature: Feature; props: PaneProps; isOpenByDefault: boolean }
141
142function featureCard(parts: Parts, { feature, props, isOpenByDefault }: Block) {
143 const { Box, Text, Button } = parts
144 const style = styleOf(feature.state, props.icons)
145 const { done, total } = countOf(feature)
146 const isOpen = isExpanded(props.toggled, feature.id, isOpenByDefault)
147 const isFeatureStale = isStale(feature, props.now, props.staleMinutes)
148 const isFramed = props.columns >= FRAME_MIN_COLUMNS
149 const inner = props.columns - (isFramed ? FRAME_COLUMNS : 0)
150 const count = ` ${done}/${total}`
151 const percent = ` ${percentText(done, total)}`
152 const cells = featureMeterCells(inner - count.length - percent.length)
153 const frame = isFramed
154 ? {
155 borderStyle: 'round',
156 borderColor: isFeatureStale ? 'warning' : 'subtle',
157 borderDimColor: !isOpen,
158 paddingX: 1,
159 hover: { borderColor: style.color, borderDimColor: false },
160 }
161 : {}
162
163 return (
164 <Box key={`feature-progress-feature:${feature.id}`} flexDirection="column" {...frame}>
165 <Box flexDirection="row" gap={1}>
166 <Box flexGrow={1} flexShrink={1}>
167 <Button plain key={`feature-progress-toggle:${feature.id}`} onPress={() => props.onToggle(feature.id)}>
168 <Text dimColor>{`${isOpen ? EXPANDED : COLLAPSED} `}</Text>
169 <Text color={style.color}>{`${style.glyph} `}</Text>
170 <Text bold>{feature.title}</Text>
171 </Button>
172 </Box>
173 <Box flexShrink={0}>{chip(parts, stageText(feature), style.color)}</Box>
174 </Box>
175 <Box flexDirection="row">
176 {meter(Text, filledCells(done, total, cells), cells)}
177 <Text bold>{count}</Text>
178 <Text dimColor>{percent}</Text>
179 </Box>
180 <Box flexDirection="row" gap={1}>
181 <Text dimColor>{`updated ${agoText(feature.updatedAt, props.now)}`}</Text>
182 {isFeatureStale ? <Text color="warning">{STALE_MARKER}</Text> : null}
183 </Box>
184 {isOpen && feature.note !== undefined ? <Text dimColor italic wrap="wrap">{feature.note}</Text> : null}
185 {isOpen ? (
186 <Box flexDirection="column" marginTop={1}>
187 {groupTasks(feature.tasks).map(group => (
188 <Box key={`feature-progress-group:${feature.id}:${group.name ?? ''}`} flexDirection="column">
189 {group.name === undefined ? null : groupTitle(parts, group.name, group.tasks)}
190 {group.tasks.map(task =>
191 taskRow(parts, { task, now: props.now, icons: props.icons, isInline: props.columns >= INLINE_MIN_COLUMNS, onOpenLink: props.onOpenLink }),
192 )}
193 </Box>
194 ))}
195 </Box>
196 ) : null}
197 </Box>
198 )
199}
200
201/** The pane: a header with Refresh and the counts, warnings, open features as cards (latest expanded), and a collapsed Done section (FR-11, FR-12, FR-18). */
202export function paneBody(parts: Parts, props: PaneProps) {
203 const { Box, Text, Button } = parts
204 const { open, done } = props.ordered
205 const isDoneOpen = isExpanded(props.toggled, DONE_SECTION, false)
206 const isEmpty = open.length === 0 && done.length === 0
207
208 return (
209 <Box flexDirection="column" gap={1} {...props.paint}>
210 <Box flexDirection="row" justifyContent="space-between" gap={1}>
211 <Button key="feature-progress-refresh" label="↻ Refresh" onPress={props.onRefresh} />
212 {isEmpty ? null : <Text dimColor>{`${open.length} open · ${done.length} done`}</Text>}
213 </Box>
214 {props.warnings.map(path => (
215 <Text key={`feature-progress-warning:${path}`} color="warning" wrap="wrap">{`⚠ Not a readable feature file: ${path}`}</Text>
216 ))}
217 {isEmpty ? (
218 <Box flexDirection="column">
219 <Text>{EMPTY_TEXT}</Text>
220 <Text dimColor wrap="wrap">
221 {EMPTY_HINT}
222 </Text>
223 </Box>
224 ) : null}
225 {open.map((feature, index) => featureCard(parts, { feature, props, isOpenByDefault: index === 0 }))}
226 {done.length === 0 ? null : (
227 <Box key="feature-progress-done" flexDirection="column" gap={1}>
228 <Button plain key="feature-progress-toggle:done" onPress={() => props.onToggle(DONE_SECTION)}>
229 <Text dimColor>{`${isDoneOpen ? EXPANDED : COLLAPSED} `}</Text>
230 <Text color="success">{`${styleOf('done', props.icons).glyph} `}</Text>
231 <Text bold>{`Done (${done.length})`}</Text>
232 </Button>
233 {isDoneOpen ? done.map(feature => featureCard(parts, { feature, props, isOpenByDefault: false })) : null}
234 </Box>
235 )}
236 </Box>
237 )
238}
239hooks/progress.ts 136 lines1import type { Feature, Kind, Task } from '../types'
2
3/** The fields that name a stage: the kind, the flow's own label, and the round. */
4export type Staged = { state: Kind; label?: string; round?: number }
5
6/** The tasks of one group in the pane; `name` is undefined for the tasks without a group. */
7export type TaskGroup = { name: string | undefined; tasks: Task[] }
8
9/** The open features, latest first, and the done ones, latest first. */
10export type Ordered = { open: Feature[]; done: Feature[] }
11
12const MINUTE_MS = 60_000
13const HOUR_MINUTES = 60
14const DAY_MINUTES = 24 * HOUR_MINUTES
15
16/** The marker the band and the pane add to a stale feature (FR-14). */
17export const STALE_MARKER = '⚠ stale'
18
19/** The character a shortened title ends with. */
20const ELLIPSIS = '…'
21
22/** The stage as the band and the pane show it: the label, else the kind, then `#round` (FR-09). */
23export function stageText(item: Staged): string {
24 const name = item.label ?? item.state
25
26 return item.round === undefined ? name : `${name} #${item.round}`
27}
28
29/** The stage of a task row: the kind and `#round`, then the flow's label, so the word for the kind always shows (FR-12, NFR-04). */
30export function kindText(item: Staged): string {
31 const kind = item.round === undefined ? item.state : `${item.state} #${item.round}`
32
33 return item.label === undefined ? kind : `${kind} · ${item.label}`
34}
35
36/** Tasks done out of all tasks, correction tasks included (FR-13). */
37export function countOf(feature: Feature): { done: number; total: number } {
38 return { done: feature.tasks.filter(task => task.state === 'done').length, total: feature.tasks.length }
39}
40
41/** The tool's answer to a stored report (FR-08). */
42export function replyText(feature: Feature): string {
43 const { done, total } = countOf(feature)
44
45 return `feature-progress: ${stageText(feature)} · ${done}/${total}`
46}
47
48const timeOf = (iso: string | undefined): number => {
49 const time = iso === undefined ? Number.NaN : Date.parse(iso)
50
51 return Number.isNaN(time) ? 0 : time
52}
53
54const byLatest = (a: Feature, b: Feature): number => timeOf(b.updatedAt) - timeOf(a.updatedAt)
55
56/** Open features latest first, and done features latest first (FR-11). */
57export function orderFeatures(features: Record<string, Feature>): Ordered {
58 const all = Object.values(features)
59
60 return { open: all.filter(feature => feature.state !== 'done').sort(byLatest), done: all.filter(feature => feature.state === 'done').sort(byLatest) }
61}
62
63/** The open feature updated last, which the band shows (FR-09). */
64export const latestOpen = (features: Record<string, Feature>): Feature | undefined => orderFeatures(features).open[0]
65
66/** Whether an open feature, not waiting on a person, went longer than `minutes` without a report; 0 turns it off (FR-14). */
67export function isStale(feature: Feature, now: number, minutes: number): boolean {
68 if (minutes <= 0 || feature.state === 'done' || feature.state === 'waiting') return false
69
70 return now - timeOf(feature.updatedAt) > minutes * MINUTE_MS
71}
72
73/** The tasks without a group first, then one block per group in the order each first appears (FR-12). */
74export function groupTasks(tasks: Task[]): TaskGroup[] {
75 const names: string[] = []
76 for (const task of tasks) if (task.group !== undefined && !names.includes(task.group)) names.push(task.group)
77 const loose = tasks.filter(task => task.group === undefined)
78 const groups = names.map(name => ({ name, tasks: tasks.filter(task => task.group === name) }))
79
80 return loose.length === 0 ? groups : [{ name: undefined, tasks: loose }, ...groups]
81}
82
83/** A span of whole minutes as `5 min`, `2 h 10 min` or `3 d`. */
84function spanText(minutes: number): string {
85 if (minutes < HOUR_MINUTES) return `${minutes} min`
86 if (minutes < DAY_MINUTES) {
87 const rest = minutes % HOUR_MINUTES
88 return rest === 0 ? `${Math.floor(minutes / HOUR_MINUTES)} h` : `${Math.floor(minutes / HOUR_MINUTES)} h ${rest} min`
89 }
90
91 return `${Math.floor(minutes / DAY_MINUTES)} d`
92}
93
94/** How long ago a time was: `just now` under a minute, else `5 min ago`. */
95export function agoText(iso: string, now: number): string {
96 const minutes = Math.floor((now - timeOf(iso)) / MINUTE_MS)
97
98 return minutes < 1 ? 'just now' : `${spanText(minutes)} ago`
99}
100
101/** How long a task ran: from `startedAt` to `finishedAt`, or to now while it runs; empty before it started. */
102export function durationText(task: Task, now: number): string {
103 if (task.startedAt === undefined) return ''
104 const end = task.finishedAt === undefined ? now : timeOf(task.finishedAt)
105 const minutes = Math.floor((end - timeOf(task.startedAt)) / MINUTE_MS)
106
107 return minutes < 1 ? '<1 min' : spanText(minutes)
108}
109
110/** A title cut to `width` characters with an ellipsis; at least the ellipsis stays (NFR-05). */
111export function fitTitle(title: string, width: number): string {
112 const characters = [...title]
113 if (characters.length <= width) return title
114
115 return `${characters.slice(0, Math.max(0, width - 1)).join('')}${ELLIPSIS}`
116}
117
118/** The characters of a meter: the done share, and the rest. */
119export const METER_FILLED = '━'
120export const METER_EMPTY = '─'
121
122/** Cells of the meter in the band and in a task row; the pane's feature meter takes the width it has. */
123export const METER_CELLS = 8
124
125/** How many of `cells` a meter fills for `done` out of `total`; an empty total fills none (FR-12, FR-13). */
126export function filledCells(done: number, total: number, cells: number): number {
127 if (total <= 0 || cells <= 0) return 0
128
129 return Math.round((Math.min(Math.max(done, 0), total) / total) * cells)
130}
131
132/** The done share as a whole percent, such as `71%`; an empty total is `0%`. */
133export function percentText(done: number, total: number): string {
134 return `${total <= 0 ? 0 : Math.floor((Math.min(done, total) / total) * 100)}%`
135}
136hooks/report.ts 236 lines1import type { Feature, Kind, Steps, Task } from '../types'
2
3/** The 9 kinds, in the order the pane sorts them. */
4export const KINDS = ['pending', 'planning', 'active', 'review', 'fixing', 'qa', 'waiting', 'blocked', 'done'] as const satisfies readonly Kind[]
5
6/** A feature id: lower case letters, digits and `-`, so it is a safe file name on every file system (DEC-04). */
7export const FEATURE_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
8
9/** The longest task id. */
10export const TASK_ID_MAX = 64
11
12/** The version of the stored file this mod writes. */
13export const SCHEMA_VERSION = 1
14
15const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/
16
17/** A change to the fields a feature and a task share; `null` clears an optional field. */
18export type ItemPatch = {
19 id: string
20 title?: string
21 state?: Kind
22 label?: string | null
23 round?: number | null
24 note?: string | null
25}
26
27/** A change to one task. */
28export type TaskPatch = ItemPatch & {
29 group?: string | null
30 steps?: Steps | null
31 link?: string | null
32}
33
34/** A validated tool input. */
35export type Report = { feature: ItemPatch; tasks: TaskPatch[]; remove: string[] }
36
37const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
38
39const isKind = (value: unknown): value is Kind => typeof value === 'string' && (KINDS as readonly string[]).includes(value)
40
41const isWhole = (value: unknown): value is number => typeof value === 'number' && Number.isInteger(value)
42
43/** Readers for the fields of one input object; each failure is pushed to `errors` with the field's path. */
44function readerOf(source: Record<string, unknown>, path: string, errors: string[]) {
45 const fail = (key: string, message: string): undefined => {
46 errors.push(`${path}.${key} ${message}`)
47 return undefined
48 }
49
50 /** A string field; an empty string clears it (`null`). */
51 const text = (key: string): string | null | undefined => {
52 const value = source[key]
53 if (value === undefined) return undefined
54 if (typeof value !== 'string') return fail(key, 'must be a string')
55
56 return value.trim() === '' ? null : value
57 }
58
59 const title = (): string | undefined => {
60 const value = source.title
61 if (value === undefined) return undefined
62 if (typeof value !== 'string' || value.trim() === '') return fail('title', 'must be a non-empty string')
63
64 return value
65 }
66
67 const state = (): Kind | undefined => {
68 const value = source.state
69 if (value === undefined) return undefined
70 if (!isKind(value)) return fail('state', `must be one of: ${KINDS.join(', ')}`)
71
72 return value
73 }
74
75 const round = (): number | null | undefined => {
76 const value = source.round
77 if (value === undefined || value === null) return value
78 if (!isWhole(value) || value < 1) return fail('round', 'must be a whole number of 1 or more, or null')
79
80 return value
81 }
82
83 const steps = (): Steps | null | undefined => {
84 const value = source.steps
85 if (value === undefined || value === null) return value
86 const isValid = isRecord(value) && isWhole(value.done) && isWhole(value.total) && value.total >= 1 && value.done >= 0 && value.done <= value.total
87 if (!isValid) return fail('steps', 'must be { done, total } with whole numbers, total of 1 or more and done between 0 and total, or null')
88
89 return { done: Number(value.done), total: Number(value.total) }
90 }
91
92 return { text, title, state, round, steps }
93}
94
95function itemOf(source: Record<string, unknown>, path: string, errors: string[]): ItemPatch {
96 const read = readerOf(source, path, errors)
97 const id = typeof source.id === 'string' ? source.id : ''
98
99 return { id, title: read.title(), state: read.state(), label: read.text('label'), round: read.round(), note: read.text('note') }
100}
101
102function featureOf(value: unknown, errors: string[]): ItemPatch | undefined {
103 if (!isRecord(value)) {
104 errors.push('feature must be an object with an id')
105 return undefined
106 }
107 const item = itemOf(value, 'feature', errors)
108 if (!FEATURE_ID.test(item.id)) errors.push('feature.id must match ^[a-z0-9][a-z0-9-]{0,63}$ (lower case letters, digits and -)')
109
110 return item
111}
112
113function taskOf(value: unknown, path: string, errors: string[]): TaskPatch | undefined {
114 if (!isRecord(value)) {
115 errors.push(`${path} must be an object with an id`)
116 return undefined
117 }
118 const read = readerOf(value, path, errors)
119 const item = itemOf(value, path, errors)
120 const isValidId = item.id !== '' && item.id.length <= TASK_ID_MAX && !CONTROL_CHARACTER.test(item.id)
121 if (!isValidId) errors.push(`${path}.id must be a string of 1 to ${TASK_ID_MAX} characters with no control character`)
122
123 return { ...item, group: read.text('group'), steps: read.steps(), link: read.text('link') }
124}
125
126function tasksOf(value: unknown, errors: string[]): TaskPatch[] {
127 if (value === undefined) return []
128 if (!Array.isArray(value)) {
129 errors.push('tasks must be an array')
130 return []
131 }
132 const tasks = value.map((task, index) => taskOf(task, `tasks[${index}]`, errors)).filter(task => task !== undefined)
133 const seen = new Set<string>()
134 for (const task of tasks) {
135 if (seen.has(task.id)) errors.push(`tasks has the id ${task.id} more than once`)
136 seen.add(task.id)
137 }
138
139 return tasks
140}
141
142function removeOf(value: unknown, errors: string[]): string[] {
143 if (value === undefined) return []
144 if (!Array.isArray(value) || !value.every(id => typeof id === 'string')) {
145 errors.push('remove must be an array of task ids')
146 return []
147 }
148
149 return value.map(String)
150}
151
152/** The tool input as a validated report, or a message that names the first wrong field. */
153export function parseReport(input: unknown): Report | string {
154 if (!isRecord(input)) return 'feature-progress: the input must be an object with a feature'
155 const errors: string[] = []
156 const feature = featureOf(input.feature, errors)
157 const tasks = tasksOf(input.tasks, errors)
158 const remove = removeOf(input.remove, errors)
159 if (feature === undefined || errors.length > 0) return `feature-progress: ${errors[0] ?? 'feature is required'}`
160
161 return { feature, tasks, remove }
162}
163
164/** `patch` when the call sent it, nothing when it sent `null`, else the stored value. */
165const pick = <T>(patch: T | null | undefined, stored: T | undefined): T | undefined => (patch === undefined ? stored : (patch ?? undefined))
166
167type Stamps = { startedAt?: string; updatedAt: string; finishedAt?: string }
168
169/** The stored item a change starts from, its new state, and the time of the change. */
170type Change = { stored: (Stamps & { state: Kind }) | undefined; state: Kind; iso: string }
171
172/** Finished when it enters `done`, kept while it stays there, cleared when it leaves. */
173function finishedOf({ stored, state, iso }: Change): string | undefined {
174 if (state !== 'done') return undefined
175
176 return stored?.state === 'done' ? stored.finishedAt : iso
177}
178
179/** Timestamps after a change (FR-06): started the first time it leaves `pending`, updated on every change. */
180function stampsOf(change: Change): Stamps {
181 const startedAt = change.stored?.startedAt ?? (change.state === 'pending' ? undefined : change.iso)
182
183 return { startedAt, updatedAt: change.iso, finishedAt: finishedOf(change) }
184}
185
186function mergeTask(stored: Task | undefined, patch: TaskPatch, iso: string): Task {
187 const state = patch.state ?? stored?.state ?? 'pending'
188
189 return {
190 id: patch.id,
191 title: patch.title ?? stored?.title ?? '',
192 state,
193 label: pick(patch.label, stored?.label),
194 round: pick(patch.round, stored?.round),
195 group: pick(patch.group, stored?.group),
196 steps: pick(patch.steps, stored?.steps),
197 note: pick(patch.note, stored?.note),
198 link: pick(patch.link, stored?.link),
199 ...stampsOf({ stored, state, iso }),
200 }
201}
202
203/**
204 * The stored feature after a report at `now` (epoch milliseconds), or a message when a new feature or task has no title.
205 * Every report moves the feature's `updatedAt`, so a task change also marks the feature as active (DEC-07).
206 */
207export function applyReport(stored: Feature | undefined, report: Report, now: number): Feature | string {
208 const iso = new Date(now).toISOString()
209 if (stored === undefined && report.feature.title === undefined) return 'feature-progress: feature.title is required for a new feature'
210 const known = new Map((stored?.tasks ?? []).map(task => [task.id, task]))
211 const missing = report.tasks.find(task => !known.has(task.id) && task.title === undefined)
212 if (missing !== undefined) return `feature-progress: tasks ${missing.id} needs a title, because it is a new task`
213
214 const patches = new Map(report.tasks.map(task => [task.id, task]))
215 const updated = (stored?.tasks ?? []).map(task => {
216 const patch = patches.get(task.id)
217 return patch === undefined ? task : mergeTask(task, patch, iso)
218 })
219 const added = report.tasks.filter(task => !known.has(task.id)).map(task => mergeTask(undefined, task, iso))
220 const removed = new Set(report.remove)
221 const patch = report.feature
222 const state = patch.state ?? stored?.state ?? 'pending'
223
224 return {
225 schemaVersion: SCHEMA_VERSION,
226 id: patch.id,
227 title: patch.title ?? stored?.title ?? '',
228 state,
229 label: pick(patch.label, stored?.label),
230 round: pick(patch.round, stored?.round),
231 note: pick(patch.note, stored?.note),
232 ...stampsOf({ stored, state, iso }),
233 tasks: [...updated, ...added].filter(task => !removed.has(task.id)),
234 }
235}
236hooks/settings.ts 36 lines1import type { IconSet } from './icons'
2
3/** The program that opens a task link. */
4export type Editor = 'auto' | 'vscode' | 'system'
5
6/** The settings the mod reads once per load; a change in the config menu reloads the module. */
7export type Settings = { folder: string; staleMinutes: number; editor: Editor; showBand: boolean; paneBackground: string; icons: IconSet }
8
9/** The folder of the feature files when the setting is empty; the same default as `plugin.json`. */
10export const DEFAULT_FOLDER = '.claude/feature-progress/'
11
12/** Minutes without a report before an open feature is stale; the same default as `plugin.json`. */
13export const DEFAULT_STALE_MINUTES = 30
14
15/** The program that opens a task link; the same default as `plugin.json`. */
16export const DEFAULT_EDITOR: Editor = 'auto'
17
18/** Whether the band shows above the prompt; the same default as `plugin.json`. */
19export const DEFAULT_SHOW_BAND = true
20
21/** The icon set of the states; the same default as `plugin.json`. */
22export const DEFAULT_ICONS: IconSet = 'unicode'
23
24/** The settings from `options`, with the manifest defaults for a missing or broken value. */
25export function settingsOf(options: Record<string, unknown>): Settings {
26 const folder = typeof options.folder === 'string' && options.folder.trim() !== '' ? options.folder : DEFAULT_FOLDER
27 const minutes = options.staleMinutes
28 const staleMinutes = typeof minutes === 'number' && Number.isFinite(minutes) && minutes >= 0 ? minutes : DEFAULT_STALE_MINUTES
29 const editor = options.editor === 'vscode' || options.editor === 'system' ? options.editor : DEFAULT_EDITOR
30 const showBand = typeof options.showBand === 'boolean' ? options.showBand : DEFAULT_SHOW_BAND
31 const paneBackground = typeof options.paneBackground === 'string' ? options.paneBackground.trim() : ''
32 const icons = options.icons === 'nerd' || options.icons === 'emoji' ? options.icons : DEFAULT_ICONS
33
34 return { folder, staleMinutes, editor, showBand, paneBackground, icons }
35}
36hooks/spec.ts 77 lines1import { KINDS, TASK_ID_MAX } from './report'
2
3/** The tool's own name; the engine exposes it as `REPORT_TOOL`. */
4export const REPORT_NAME = 'report'
5
6/** The tool as the model calls it. */
7export const REPORT_TOOL = `mcp__feature-progress__${REPORT_NAME}` as const
8
9/** The answer to a subagent's call, which the mod does not store (DEC-02). */
10export const IGNORED_RESULT = 'Ignored: only the main session reports progress.'
11
12/** Everything the model needs to call the tool, since no system-prompt text carries it (TechSpec, Contracts and data). */
13export const REPORT_DESCRIPTION =
14 'Report the progress of a feature and its tasks to the feature-progress band and pane. ' +
15 'Only the main session calls this tool; subagents do not. ' +
16 'Each call merges into the stored feature: send `feature.id` always, `feature.title` on the first call, and only the fields and tasks that changed. ' +
17 'A task is matched by `id`; a new id adds it, a known id updates only the fields sent. ' +
18 '`remove` deletes tasks by id. ' +
19 '`state` is one of: pending, planning, active, review, fixing, qa, waiting, blocked, done. ' +
20 'Use `round` for the review, correction or QA round, `group` to group tasks such as "Review #1", `steps` for done/total sub-steps, ' +
21 '`note` for a short reason, and `link` for a file path. ' +
22 'The tool sets the timestamps.'
23
24const STATE = { type: 'string', enum: [...KINDS] }
25const ROUND = { type: ['integer', 'null'], minimum: 1, description: 'The review, correction or QA round; null clears it.' }
26const LABEL = { type: 'string', description: 'Shown instead of the state word; an empty string clears it.' }
27const NOTE = { type: 'string', description: 'A short reason or remark; an empty string clears it.' }
28
29/** The tool spec `$.tool.register` takes. */
30export const REPORT_SPEC = {
31 name: REPORT_NAME,
32 description: REPORT_DESCRIPTION,
33 inputSchema: {
34 type: 'object',
35 properties: {
36 feature: {
37 type: 'object',
38 properties: {
39 id: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]{0,63}$', description: 'Lower case letters, digits and -, such as the slug of the feature folder.' },
40 title: { type: 'string', description: 'Required on the first call for this id.' },
41 state: STATE,
42 label: LABEL,
43 round: ROUND,
44 note: NOTE,
45 },
46 required: ['id'],
47 },
48 tasks: {
49 type: 'array',
50 items: {
51 type: 'object',
52 properties: {
53 id: { type: 'string', maxLength: TASK_ID_MAX },
54 title: { type: 'string', description: 'Required for a new task.' },
55 state: STATE,
56 label: LABEL,
57 round: ROUND,
58 group: { type: 'string', description: 'Groups tasks in the pane, such as "Review #1"; an empty string clears it.' },
59 steps: {
60 type: ['object', 'null'],
61 properties: { done: { type: 'integer', minimum: 0 }, total: { type: 'integer', minimum: 1 } },
62 required: ['done', 'total'],
63 description: 'Sub-steps done out of total; null clears it.',
64 },
65 note: NOTE,
66 link: { type: 'string', description: 'A file path, relative to the working directory or absolute; an empty string clears it.' },
67 },
68 required: ['id'],
69 },
70 },
71 remove: { type: 'array', items: { type: 'string' }, description: 'Ids of tasks to delete.' },
72 },
73 required: ['feature'],
74 },
75 isDeferred: false,
76} as const
77hooks/platform.ts 13 lines1import type { Host } from './host'
2
3/** The operating systems the mod tells apart for the commands it starts. */
4export type Platform = 'windows' | 'mac' | 'linux'
5
6/** `Windows_NT` in `OS` is Windows; otherwise `uname -s` tells macOS (`Darwin`) from Linux. */
7export async function platformOf(host: Host): Promise<Platform> {
8 if ((await host.env()).os === 'Windows_NT') return 'windows'
9 const uname = await host.run(['uname', '-s']).catch(() => ({ exitCode: 1, stdout: '' }))
10
11 return uname.stdout.trim() === 'Darwin' ? 'mac' : 'linux'
12}
13hooks/store.ts 148 lines1import type { Feature, Kind, Steps, Task } from '../types'
2
3import type { Host } from './host'
4import { absolute, join } from './paths'
5import type { Platform } from './platform'
6import { FEATURE_ID, KINDS, SCHEMA_VERSION } from './report'
7
8/** The largest feature file the mod writes: the limit of `$.fs.read` and `$.fs.write` (NFR-03). */
9export const FILE_LIMIT_BYTES = 4 * 1024 * 1024
10
11const FILE_EXTENSION = '.json'
12
13/** The features and the unreadable files of one folder. */
14export type Loaded = { features: Record<string, Feature>; warnings: string[] }
15
16/** What a read of one feature file found. */
17export type ReadResult = { kind: 'missing' } | { kind: 'unreadable' } | { kind: 'feature'; feature: Feature }
18
19/** The file name of a feature: its id, which `FEATURE_ID` keeps safe, and `.json`. */
20export const fileName = (id: string): string => `${id}${FILE_EXTENSION}`
21
22/** The absolute storage folder from the session's working directory and the `folder` setting. */
23export const folderOf = (cwd: string, folder: string): string => absolute(cwd, folder)
24
25/** The path of a feature's file in the storage folder. */
26export const fileOf = (folder: string, id: string): string => join(folder, fileName(id))
27
28const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
29
30const isKind = (value: unknown): value is Kind => typeof value === 'string' && (KINDS as readonly string[]).includes(value)
31
32const textOf = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined)
33
34const roundOf = (value: unknown): number | undefined => (typeof value === 'number' && Number.isInteger(value) && value >= 1 ? value : undefined)
35
36function stepsOf(value: unknown): Steps | undefined {
37 if (!isRecord(value)) return undefined
38 const { done, total } = value
39 if (typeof done !== 'number' || typeof total !== 'number' || done < 0 || done > total || total < 1) return undefined
40
41 return { done, total }
42}
43
44function taskOf(value: unknown): Task | undefined {
45 if (!isRecord(value)) return undefined
46 const { id, title, state, updatedAt } = value
47 if (typeof id !== 'string' || typeof title !== 'string' || !isKind(state) || typeof updatedAt !== 'string') return undefined
48
49 return {
50 id,
51 title,
52 state,
53 label: textOf(value.label),
54 round: roundOf(value.round),
55 group: textOf(value.group),
56 steps: stepsOf(value.steps),
57 note: textOf(value.note),
58 link: textOf(value.link),
59 startedAt: textOf(value.startedAt),
60 updatedAt,
61 finishedAt: textOf(value.finishedAt),
62 }
63}
64
65/** A stored file's text as a feature, or undefined for broken JSON, another schema version or a missing field (FR-18). */
66export function parseFeature(text: string): Feature | undefined {
67 let value: unknown
68 try {
69 value = JSON.parse(text)
70 } catch {
71 return undefined
72 }
73 if (!isRecord(value) || value.schemaVersion !== SCHEMA_VERSION || !Array.isArray(value.tasks)) return undefined
74 const { id, title, state, updatedAt } = value
75 if (typeof id !== 'string' || !FEATURE_ID.test(id) || typeof title !== 'string' || !isKind(state) || typeof updatedAt !== 'string') return undefined
76 const tasks = value.tasks.map(taskOf)
77 if (tasks.some(task => task === undefined)) return undefined
78
79 return {
80 schemaVersion: SCHEMA_VERSION,
81 id,
82 title,
83 state,
84 label: textOf(value.label),
85 round: roundOf(value.round),
86 note: textOf(value.note),
87 startedAt: textOf(value.startedAt),
88 updatedAt,
89 finishedAt: textOf(value.finishedAt),
90 tasks: tasks.filter(task => task !== undefined),
91 }
92}
93
94/** The UTF-8 size of a text in bytes, without an encoder the runtime may lack. */
95export function utf8Bytes(text: string): number {
96 let bytes = 0
97 for (const character of text) {
98 const code = character.codePointAt(0) ?? 0
99 if (code < 0x80) bytes += 1
100 else if (code < 0x800) bytes += 2
101 else if (code < 0x10000) bytes += 3
102 else bytes += 4
103 }
104
105 return bytes
106}
107
108/** The file text of a feature, or a message when it would pass `FILE_LIMIT_BYTES` (NFR-03). */
109export function serialize(feature: Feature): { text: string } | { error: string } {
110 const text = `${JSON.stringify(feature, null, 2)}\n`
111 if (utf8Bytes(text) > FILE_LIMIT_BYTES) return { error: `feature-progress: the file of ${feature.id} would pass 4 MiB; remove done tasks or shorten the notes` }
112
113 return { text }
114}
115
116/** Reads one feature file: missing, unreadable, or the feature. */
117export async function readOne(host: Host, path: string): Promise<ReadResult> {
118 if (!(await host.exists(path))) return { kind: 'missing' }
119 const text = await host.readFile(path).catch(() => undefined)
120 const feature = text === undefined ? undefined : parseFeature(text)
121
122 return feature === undefined ? { kind: 'unreadable' } : { kind: 'feature', feature }
123}
124
125/** Every feature file of the folder; a missing folder has none, and a file that is not a feature named by its id is a warning. */
126export async function loadAll(host: Host, folder: string): Promise<Loaded> {
127 if (!(await host.exists(folder))) return { features: {}, warnings: [] }
128 const entries = await host.list(folder)
129 const names = entries.filter(entry => entry.kind === 'file' && entry.name.endsWith(FILE_EXTENSION)).map(entry => entry.name)
130 const results = await Promise.all(names.map(async name => ({ name, result: await readOne(host, join(folder, name)) })))
131 const features: Record<string, Feature> = {}
132 const warnings: string[] = []
133 for (const { name, result } of results) {
134 if (result.kind === 'feature' && fileName(result.feature.id) === name) features[result.feature.id] = result.feature
135 else warnings.push(join(folder, name))
136 }
137
138 return { features, warnings }
139}
140
141/**
142 * The argv that deletes feature files, run with the storage folder as its working directory, so the folder never
143 * reaches the command line; the names are `<id>.json`, which `FEATURE_ID` keeps free of any shell syntax (DEC-05).
144 */
145export function cleanArgv(platform: Platform, names: string[]): string[] {
146 return platform === 'windows' ? ['cmd', '/d', '/c', 'del', '/f', '/q', ...names] : ['rm', '-f', '--', ...names]
147}
148hooks/viewer.ts 49 lines1import type { Host } from './host'
2import { absolute } from './paths'
3import type { Platform } from './platform'
4import { platformOf } from './platform'
5import type { Editor } from './settings'
6
7/** Characters `cmd` reads as syntax; a link that holds one never reaches `cmd /c code` (the link comes from the model). */
8const CMD_SYNTAX = /[&|<>^%!"]/
9
10/** The argv that succeeds when the program is on the PATH: `where` on Windows; `command -v` elsewhere (as in session-info). */
11export const lookupArgv = (platform: Platform, program: string): string[] =>
12 platform === 'windows' ? ['where', program] : ['sh', '-c', 'command -v "$1"', 'sh', program]
13
14/**
15 * The argv that opens a file: VS Code (`code` is a .cmd script on Windows, which only `cmd` runs), or the system's
16 * default program (DEC-08, the editor part of session-info's viewer).
17 */
18export function openArgv(platform: Platform, program: Exclude<Editor, 'auto'>, path: string): string[] {
19 if (program === 'vscode') return platform === 'windows' ? ['cmd', '/c', 'code', path] : ['code', path]
20 if (platform === 'windows') return ['explorer.exe', path.replace(/\//g, '\\')]
21
22 return [platform === 'mac' ? 'open' : 'xdg-open', path]
23}
24
25/** The program the setting names; `auto` is VS Code when `code` is on the PATH, else the system's default program. */
26async function programOf(host: Host, editor: Editor, platform: Platform): Promise<Exclude<Editor, 'auto'>> {
27 if (editor !== 'auto') return editor
28 const lookup = await host.run(lookupArgv(platform, 'code')).catch(() => ({ exitCode: 1 }))
29
30 return lookup.exitCode === 0 ? 'vscode' : 'system'
31}
32
33/** Opens a task's link with the `editor` setting; a missing file or a link `cmd` would misread gets a toast (FR-15). */
34export async function openLink(host: Host, editor: Editor, link: string): Promise<void> {
35 const path = absolute(await host.cwd(), link)
36 if (!(await host.exists(path))) {
37 host.toast(`feature-progress: ${path} does not exist.`)
38 return
39 }
40 const platform = await platformOf(host)
41 const program = await programOf(host, editor, platform)
42 if (program === 'vscode' && platform === 'windows' && CMD_SYNTAX.test(path)) {
43 host.toast('feature-progress: the task link has a character cmd would read as a command; open the file by hand.')
44 return
45 }
46 // explorer.exe ends with exit code 1 even when it opened the file, so only a program that did not start is reported.
47 await host.run(openArgv(platform, program, path)).catch(() => host.toast(`feature-progress: could not start ${program === 'vscode' ? 'VS Code' : 'the default program'}.`))
48}
49hooks/icons.ts 26 lines1import type { Color } from 'claude-code'
2
3import type { Kind } from '../types'
4
5/** The icon sets of the `icons` option: `unicode` draws in every terminal, `nerd` needs a Nerd Font, `emoji` draws two cells wide. */
6export type IconSet = 'unicode' | 'nerd' | 'emoji'
7
8/** The glyph of each kind per icon set, and its theme color; the word is the kind itself, so color only reinforces it (NFR-04). */
9export const KIND_STYLE = {
10 pending: { glyph: { unicode: '○', nerd: '\u{f10c}', emoji: '⚪' }, color: 'inactive' },
11 planning: { glyph: { unicode: '◇', nerd: '\u{f14e}', emoji: '📝' }, color: 'suggestion' },
12 active: { glyph: { unicode: '●', nerd: '\u{f144}', emoji: '🚀' }, color: 'claude' },
13 review: { glyph: { unicode: '◎', nerd: '\u{f06e}', emoji: '🔍' }, color: 'permission' },
14 fixing: { glyph: { unicode: '✎', nerd: '\u{f0ad}', emoji: '🔧' }, color: 'warning' },
15 qa: { glyph: { unicode: '◆', nerd: '\u{f0c3}', emoji: '🧪' }, color: 'merged' },
16 waiting: { glyph: { unicode: '◷', nerd: '\u{f252}', emoji: '⏳' }, color: 'planMode' },
17 blocked: { glyph: { unicode: '✕', nerd: '\u{f05e}', emoji: '⛔' }, color: 'error' },
18 done: { glyph: { unicode: '✓', nerd: '\u{f058}', emoji: '✅' }, color: 'success' },
19} as const satisfies Record<Kind, { glyph: Record<IconSet, string>; color: Color }>
20
21/** The glyph and color of a kind in the chosen icon set. */
22export const styleOf = (kind: Kind, set: IconSet): { glyph: string; color: Color } => ({ glyph: KIND_STYLE[kind].glyph[set], color: KIND_STYLE[kind].color })
23
24/** Terminal cells one glyph of the set takes: an emoji draws two. */
25export const glyphColumns = (set: IconSet): number => (set === 'emoji' ? 2 : 1)
26