SLOPSHOPPER

feature-progress

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…

newpanebandguardcommandtoast
★ 4v0.3.0MITupdated 2026-10-25dougcunha/claude-mods/feature-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · feature-progress
│ ┃ Feature progress ✕ › fix the failing auth test and add an audit log call │ ┃ [ ↻ Refresh ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ No feature has reported progress yet. ⎿ Read 6 lines │ ┃ A flow reports through the ⏺ Update(src/auth.ts) │ ┃ mcp__feature-progress__report tool; the ⎿ Added 2 lines, removed 1 line │ ┃ feature-progress README has the contract. ⏺ 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 │ │ › /feature-progress │ ⎿ feature-progress: Feature progress pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Feature progress
[ ↻ Refresh ] No feature has reported progress yet. A flow reports through the mcp__feature-progress__report tool; the feature-progress README has the contract.
README

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

✨ What it does

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.

  • 📍 Band above the prompt: the open feature with the latest report, its glyph, its stage as a colored chip, a meter of the done tasks and the count, for example ✎ 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.
  • 🗂️ Pane (/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.
  • 💾 Files: 1 JSON file per feature in the storage folder (.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">

🚀 Install

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/

📣 Report progress from your flow

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.

Input

FieldTypeRequiredRule
feature.idstringalwaysLower case letters, digits and -, 1 to 64 characters, such as the slug of the feature folder. It names the file.
feature.titlestringfirst callNot empty.
feature.statestatenoOne of the 9 states below; a new feature without one is pending.
feature.labelstringnoShown instead of the state word in the band and the feature chip, for example Revisão. "" clears it.
feature.roundinteger or nullnoThe review, correction or QA round, 1 or more; shown as #2. null clears it.
feature.notestringnoA short reason or remark. "" clears it.
tasks[]arraynoThe tasks that changed, each as below.
tasks[].idstringalways1 to 64 characters, no control character.
tasks[].titlestringnew taskNot empty.
tasks[].state, .label, .round, .notenoAs 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[].groupstringnoThe pane groups tasks by it, for example Review #1. "" clears it.
tasks[].steps{ done, total } or nullnoWhole numbers, total 1 or more, done from 0 to total. null clears it.
tasks[].linkstringnoA file path, relative to the session's working folder or absolute. "" clears it.
removestring[]noIds of tasks to delete; an unknown id is ignored.

Merge rules

  • Each call merges into the stored feature. Send feature.id always, feature.title on the first call, and only what changed.
  • A task is matched by 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.
  • The mod sets the timestamps: 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.
  • The count is done tasks out of all tasks, correction tasks included.
  • A wrong call is refused with the field to fix, for example 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.

States

StateGlyph (unicode)EmojiColorUse it for
pending○⚪inactiveNot started
planning◇📝suggestionWriting the requirements, the design or the plan
active●🚀claudeImplementing
review◎🔍permissionIn code review, with the review number as round
fixing✎🔧warningCorrecting what a review or QA found, with the round
qa◆🧪mergedIn QA, with the QA run number as round
waiting◷⏳planModeWaiting on a person, such as an approval
blocked✕⛔errorBlocked, with the reason as note
done✓✅successFinished

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.

Examples

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" }]
}

The stored file

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

🎮 Use

CommandWhat it does
/feature-progressOpens the pane, or closes it when it is open.
/feature-progress bandHides 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 cleanDeletes 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.

🔧 Settings

Set them in /config, under the mod's name.

OptionDefaultWhat 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.
staleMinutes30Minutes without a report before an open feature shows ⚠ stale. A waiting or done feature is never stale. 0 turns the marker off.
editorautoThe 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.
showBandtrueShows the band above the prompt. false hides it; /feature-progress band flips it.
paneBackgroundemptyA 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.
iconsunicodeThe glyphs of the states: unicode works in every terminal, nerd needs a Nerd Font, emoji needs a terminal that draws emoji 2 cells wide.

🚧 Limitations

  • No live updates from other sessions: the API has no file watch, and the mod does not poll. A session sees its own reports at once; another session's reports show after Refresh, /clear, /resume or a new session.
  • The band can be hidden by another mod: the band draws above what the mods beneath it draw. A mod that draws the band above the prompt without passing it on (savvy-progress does) hides this band when it runs first. The pane still works.
  • Files in the repository: the mod writes and deletes only <id>.json files in the storage folder. This is a documented exception to the repository rule that mods write only to the temporary folder.
  • Delete command: the mod API has no file delete, so /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.
  • Links on Windows: VS Code opens through cmd /c code. A link whose path holds & | < > ^ % ! " is not opened, because the link comes from the model; a toast says so.
  • Two sessions on one feature: each report reads, merges and writes the file. Two reports at the same moment can lose one of them; the next report puts the state right.
  • Size: a feature file stays under 4 MiB; a report that would pass it is refused.
  • Platforms: tested on Windows 11. The commands for macOS and Linux (open, xdg-open, rm) are covered by unit tests only.

🔨 Development

claude plugin validate .
claude plugin test feature-progress
claude --plugin-dir ./feature-progress
FileRole
hooks/register.tsxThe tool, the command, the band and the pane; the session.start, session.end, tool.call, command.run and ui.render hooks
hooks/report.tsThe tool input narrowing, the merge, remove and the timestamps
hooks/store.tsThe storage folder, the file parser, the 4 MiB guard, loading, and the delete argv
hooks/progress.tsStage text, count, order, groups, stale marker, relative times, the meter share and the percent
hooks/spec.tsThe tool spec and its description
hooks/band.tsx, hooks/pane.tsxThe band line, and the pane with its cards and task rows
hooks/meter.tsxThe meter of done cells
hooks/icons.tsThe glyph of each state per icon set, and its color
hooks/viewer.ts, hooks/platform.tsOpening a link, and the platform
hooks/settings.tsThe settings and their defaults
hooks/paths.ts, hooks/host.tsPath helpers, and the engine calls the helper modules use
types/index.d.tsThe stored feature types and the $.state contract

📄 License

MIT

Source 15 files
hooks/register.tsx 236 lines
1import { 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}
236
hooks/band.tsx 47 lines
1import 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}
47
hooks/host.ts 22 lines
1/** 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}
22
hooks/pane.tsx 239 lines
1import 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}
239
hooks/progress.ts 136 lines
1import 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}
136
hooks/report.ts 236 lines
1import 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}
236
hooks/settings.ts 36 lines
1import 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}
36
hooks/spec.ts 77 lines
1import { 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
77
hooks/platform.ts 13 lines
1import 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}
13
hooks/store.ts 148 lines
1import 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}
148
hooks/viewer.ts 49 lines
1import 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}
49
hooks/icons.ts 26 lines
1import 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