A band above the prompt with how far your plan file is (phases and steps done, what is in progress, what is next) and Claude's own task list, kept current as…

Four small Claude Code mods, free to use under the MIT licence. A mod is a plugin made of function hooks: Claude Code calls it at every step (a tool call, a slash command, a redraw), and it can answer, change or watch that step.
| Mod | What it does | Command |
|---|---|---|
| secret-guard | Reads every .env and .env.* from your session's folder up to the drive root when a session starts and hides their secret values (keys named like PASSWORD, SECRET, TOKEN, API_KEY) in every tool result before Claude reads it, along with anything that looks like a secret on its own: KEY=VALUE under such a name, the password in postgres://user:REDACTED@host, private keys, JWTs, AWS keys, Kubernetes Secret data. A secret reaches Claude as ‹hidden: DB_PASSWORD›. It tells Claude, in every conversation, to use $DB_PASSWORD instead of printing the value, and it refuses the few commands that would print a whole env file or a decrypted secret. | /secret-guard lists the protected files and key names |
| plan-meter | A one-line band above the prompt that says how far your plan is: plan ▸ Ship the export · phases 1/3 · steps 3/6 (50%) · now: API · Claude's tasks 2/5. It reads your plan file (and the phase files it links to) in many formats, and Claude's own task list. It updates when a file changes. | /plan-meter on / /plan-meter off shows or hides the band; /plan-meter opens a pane with the details; /plan-meter docs/roadmap.md picks a file |
| done-gate | When Claude marks a task done while code it changed has not been tested since, it tells Claude, in the tool result Claude reads, and you, in a toast. A band shows the last test run: done-gate ▸ tests ✔ passed 4 min ago · 2 files changed since. It warns; it never blocks. | /done-gate on / /done-gate off shows or hides the band; /done-gate lists the changed files and the last test command |
| context-meter | A band above the prompt with what fills the context window, by category and in /context's colours (context ▸ 90k of 1M · 9% · compacts at 987k), and a countdown to when the prompt cache expires, which turns from green through amber to red: cache ▸ 41:07 left (1h TTL, assumed: subscription). A Compact button (or c while the band has focus) runs the same compaction as /compact. | /context-meter shows or hides the band (on / off to set it); the button |
Tested on Claude Code 2.1.291 on Windows 11. Mods are an early-access feature, so the API can change between versions; if a mod stops loading after an update, check claude plugin validate on its folder.
git clone https://github.com/vumichien/claude-code-mods-kit.git
claude --plugin-dir claude-code-mods-kit/plugins/plan-meter
--plugin-dir loads the mod for that session only. Repeat the flag to load more than one.
claude plugin marketplace add vumichien/claude-code-mods-kit
claude plugin install secret-guard@chien-mods
claude plugin install plan-meter@chien-mods
claude plugin install done-gate@chien-mods
claude plugin install context-meter@chien-mods
Start a new session afterwards. To remove one: claude plugin uninstall plan-meter@chien-mods.
The bands start hidden. plan-meter, done-gate and context-meter each draw a band above the prompt, but only when you ask: type /plan-meter on, /done-gate on or /context-meter on when you want to see it, and off to hide it again (a bare /context-meter flips it). The choice lasts for the session. Hiding a band changes only what is drawn: the plan is still read, done-gate still tells Claude when a task is marked done too early, and context-meter still measures, so a band is current the moment it comes back. To have a band from the start, set that mod's band option to on.
Every option has a default, so all four mods work without any. plan-meter, done-gate and context-meter share one: band, off (default) or on, whether the band shows before you switch it with its command. To change one, use /plugin configure <name>@chien-mods inside Claude Code, pass --config key=value to claude plugin install, or pipe a JSON object to claude plugin configure <name>@chien-mods --values-stdin. With --plugin-dir, put them in a settings file: --settings '{"pluginConfigs":{"done-gate":{"options":{"testCommands":"make ci"}}}}'.
secret-guard
mode: value (default) hides secrets in results and refuses the commands listed below. command instead refuses any call whose command or path names a protected env file, except to load it (source .env, --env-file .env), without reading values or changing results; it is simpler, but it blocks harmless commands and misses reads that don't name the file.secretFiles (default .env, .env.*, !*.example): comma-separated globs of the env files to read. A file name is looked for in the session's folder and every folder above it, up to the drive root. A path is read where it points: ~/vault//.env (from your home folder; goes up to 8 folders deep and skips node_modules, .git, .venv, venv and __pycache__), an absolute path, or one relative to the session's folder. !glob leaves files out. Each file must be in .env format.secretKeys: comma-separated key names to treat as secrets on top of the built-in rule. The rule: a key is a secret when a part of its name (split at _, -, . and camelCase) is PASS, PASSWD, PASSWORD, PW, PWD, SECRET, TOKEN, KEY, DSN, CREDENTIAL or PRIVATE, or ends with one of the first seven (APIKEY, DBPASS). So DB_PASSWORD, apiKey and AWS_SECRET_ACCESS_KEY are secrets; DB_NAME, DB_HOST, ACCOUNT_ID, MAX_TOKENS, TOKENIZER_PATH and the shell's own PWD are not.identifierKeys: comma-separated keys that match the rule but hold names, not secrets (KMS_KEY_ID, SSH_KEY_NAME). They are never hidden.Values shorter than 8 characters are never hidden (except the password in a URL such as postgres://user:REDACTED@host, which is always hidden), so PW=1 or TOKEN_TTL=60 does not mask every 1 or 60. The values of non-secret keys are never hidden at all, so database names, hosts, users and account ids stay readable.
In value mode it refuses these commands, each time naming a way to do the same without printing the secret: cat, type, Get-Content, less, more, head, tail or bat of a protected file (cat .env | cut -d= -f1 is allowed, it prints names); a bare env, printenv, set or Get-ChildItem env:; and, unless the output goes to a file or a variable (> out.json, VALUE=$(...)), aws ssm ... --with-decryption, aws secretsmanager get-secret-value and kubectl get secret ... -o yaml|json. It never refuses loading a file: source .env, . .env, set -a, --env-file .env.
Every conversation starts with a short # secret-guard note to Claude: the protected files and key names (names only), what ‹hidden: NAME› means, and the convention (reference $NAME, let scripts load the env file, never print a secret, report only whether a command worked). So you no longer need to remind each session. The note is rebuilt after a compaction or /clear. Messages Claude sends to another agent or session (SendMessage) are scrubbed like tool results. So is what Claude Code attaches to a message on its own, which never passes through a tool call: a file Claude read, attached again after a compaction; a file changed on disk; a file you @-mention; a settings hook's output.
plan-meter
plan: comma-separated paths, relative to the project, tried in order; the first one that matches a file wins. A * in any part matches anything, and among several matches the most recently changed file wins. Default: plans/*/plan.md, PLAN.md, plan.md, TODO.md, TASKS.md, ROADMAP.md, todo.txt, TODO.org.refreshSeconds (default 15, at least 5): how often the plan is read again, so an edit you make in your own editor shows up too. Edits Claude makes show up at once.The band shows only when there is a plan or a task list. The pane draws in the terminal and the desktop app; under claude -p, /plan-meter answers with the band's line. (The command was /plan up to 0.1.0; Claude Code 2.1.294 has a built-in /plan, which refused the name.)
context-meter
cacheTtl: auto (default), 5m or 1h. A mod cannot read the cache lifetime Claude Code asks for, so auto follows Claude Code's defaults: one hour on a Claude subscription (the session reports rate-limit windows), five minutes with an API key or a cloud provider. Set it when you know better: you set promptCacheTtl or ENABLE_PROMPT_CACHING_1H, or you are drawing on usage credits, where Claude Code drops to five minutes. The band always says which lifetime it assumed and why.breakdown: summary (default) estimates the categories locally and sends nothing. full counts them with the token-count API after every turn, as /context does: more exact, one request per tool and memory file.Every request of the main conversation that hits the cache resets its timer, so the clock restarts at each model request, from the moment it was sent, not only when a turn ends. While Claude works the band says the cache is being kept warm; the countdown runs between turns, from the last request. A subagent's requests have caches of their own and are left out. After a compaction it starts again with the next message. The button is hidden while a turn runs and before the conversation's first reply, when Claude Code refuses a compaction ("Not enough messages to compact"); if a compaction is refused or a hook vetoes it, the band says why, and so does a toast. If you set CLAUDE_AUTOCOMPACT_PCT_OVERRIDE, which can only bring auto-compaction earlier, the header shows that point and marks it as your setting (compacts at 500k (your 50% setting)), since the breakdown Claude Code returns may still give its default; that one environment variable is all context-meter reads. In the desktop app and the IDE extensions, which run Claude Code through its SDK, Claude Code cannot compact between turns yet, so there the button runs /compact as if you had typed it. Below the bar, every category and the free part get a coloured entry with their tokens and share of the window, wrapped onto as many rows as they need. Compacting is a model call: the band costs no tokens, the button does. With little room above the prompt the band keeps two rows, the fill and the cache clock.
done-gate
testCommands: comma-separated commands that run your tests, added to the usual runners. Example: ./scripts/check.sh, make ci. Each one counts when it is the command itself, with or without arguments, so check does not match git checkout.ignore (default .md,.mdx,.markdown,.txt,.rst,.adoc,.org): file endings whose changes need no test run.The usual runners it knows: pytest, python -m pytest or unittest, npm/pnpm/yarn/bun test or run test, vitest, jest, mocha, go test, cargo test, cargo nextest, mvn test or verify, gradle test, dotnet test, rspec, phpunit, mix test, swift test, ctest, make test or check, tox, nox, deno test, claude plugin test. A runner counts when it is a command in the line, after &&, ; or cd api && and behind FOO=1, npx, uv run or poetry run; a runner named inside an argument (echo pytest, git commit -m "fix pytest") does not.
plan-meter does not ask you to write your plan its way. It recognises these, alone or mixed in one file:
| Format | Example | What it counts | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| Checklist | - [x] write the schema | One step per item. Bullets -, *, +, 1., 1). Marks: x or X done; / or ~ in progress (Obsidian); - cancelled, left out of the count; a space, >, <, ! or ? still to do. | ||||||||
| Status table | `\ | Phase \ | Name \ | Status \ | with a row \ | 2 \ | API \ | 🚧 In progress \ | ` | One phase per row. The status column is the one headed Status, State, Progress, Done or Done?, Trạng thái, Tình trạng, ステータス or 状態. The name comes from a Name, Title, Task, Item, What, Deliverable, Tên or Công việc column, else Phase, Step, Milestone, Stage or Giai đoạn, else the first cell with words in it (so a Phase column holding only 2 is skipped). |
| Headings as phases | ## Phase 2: API (in progress), ## Step 3 ✅ | Used when the file has no status table. A heading named Phase, Step, Stage, Milestone, Sprint, Part or Task with a number (also Giai đoạn 1, Bước 2, フェーズ1) is a phase. Its status comes from a mark in it, a status at its end ((done), [WIP], — done, : in progress), the checklist beneath it (all ticked is done, some ticked is in progress), or the phase file it links to. Any other heading counts only when it ends with a status alone: ## Setup (done). | ||||||||
| Linked phase files | Phase 1 | Links in the plan (in text, tables or headings) to Markdown files whose name starts with phase, step, stage, milestone, sprint, part or task and goes on with a number or a dash: phase-01-schema.md, phase1.md, steps.md, part_2.md, but not department.md. Relative to the plan's folder, up to 30; a #section part is ignored, and two links to one file count it once. Their checklists add to the steps. A phase whose status the plan leaves blank or unknown takes the file's status from its frontmatter or status line, else from its checklist; a status the plan states, such as Pending, wins. A plan with no phases of its own takes one phase per linked file. | ||||||||
| YAML frontmatter | title: Ship the export / status: in_progress | The plan's title and its own status. | ||||||||
| Status line | Status: Draft, phase 3 next, Status: …, Status (2026-10-07): … | The plan's own status, shown as written when nothing in the file can be counted. | ||||||||
| org-mode | * TODO write the schema, ** DONE tests | One step per headline. DONE done; DOING, IN-PROGRESS, STARTED, WAITING, HOLD in progress; CANCELLED left out; TODO, NEXT to do. | ||||||||
| todo.txt | x 2026-10-01 call the bank | A file named todo.txt (or *.todo.txt): one step per line, x at the start is done. |
Status words it understands in tables, headings, status lines and frontmatter, in English, Vietnamese and Japanese. When a cell holds several, the first one wins, so Done (review pending) is done and Not started is to do.
[x]in-progress, in_progress), WIP, doing, ongoing, active, started, running, in review, reviewing, blocked, đang, đang làm, 進行中, 🚧 🔄 ⏳ ▶[ ]The title is the frontmatter title:, else the first # heading (a leading Plan: is dropped). Anything inside a fenced code block is skipped.
Claude's own tasks. When Claude keeps a task list (its TodoWrite, TaskCreate and TaskUpdate tools), the band adds Claude's tasks done/total, and the pane lists them. That list is the session's: it starts empty in a new session.
What it cannot read. A plan that says how far it is only in prose ("we finished the API last week") has nothing to count; the band then shows its status line if it has one. Headings named Phase with no status and no checklist beneath give no phase count, rather than a made-up 0 of N. On the 21 plan files in the author's own writing workspace, 10 gave a phase count, 8 a step count, 10 had only a status line to show, and 1 had nothing plan-meter could read.
pytest -q | tail -20, pytest; echo done, pytest || true), done-gate reads the runner's own summary line in the output instead: 5 passed, 1 failed, 18 pass … 0 fail, test result: ok, ok pkg, OK. Failure words win. With no summary to read, the run counts for nothing.python scripts/report.py, ./build.sh) clears that file, since running a script checks at least that it runs. Reading it (cat, git diff) does not.completed, or a TodoWrite item newly completed) while changed files are unchecked, Claude reads this beside the tool's result, and you see the same as a toast:done-gate: "Add the export" was marked done, but 1 code file was changed and no test has run in this session: src/export.py. Before you report this task as finished, run the tests that cover these files, or tell the user plainly that they were not tested and why.
sed -i, a code generator) or outside Claude Code, nor a test run sent to the background, whose result it cannot know.aws ssm get-parameter --query Parameter.Value --output text, a password file, a column of a CSV), is not recognised; that is why the commands above are refused. To stop Claude from reading a file at all, use permission deny rules such as Read(./.env), the sandbox, OS file permissions or a secrets manager.secretFiles names its path.# secret-guard note (so Claude tells you) and in /secret-guard, and it still refuses cat of that file. Fix the file, or leave it out with ! in secretFiles. A file it cannot read at all (no permission) makes it refuse every call in that session; claude plugin disable secret-guard@chien-mods turns it off.password: "hunter2-test" in a test), the values of a ConfigMap listed together with a Secret. When Claude then edits that file, the marker is turned back into the value, so the edit works. It leaves alone a value already masked (sk_****…), a key inside a quoted pattern (rg -c "API_SECRET=" deploy/*.ini) and an attribute assigned in code (self.api_key = config.service_key).‹hidden: NAME›. When an Edit, Write, MultiEdit or NotebookEdit carries that marker, secret-guard puts the real value back only when the target file already holds it, so a value is restored where it was and never copied into a new file. Otherwise the call is refused and Claude is told why. A marker in a Bash or PowerShell command that stands for a value is refused, never filled in. A marker that stands for no value secret-guard knows (a note or a README quoting the format, ‹hidden: NAME›) is plain text, written and run as it is; so is a marker the file already holds as text.aws_secret_access_key, SecretAccessKey) or when it sits on the same line as an access key id; a bare 40-character string elsewhere is not, since every git commit hash would match./secret-guard says why; if a check failed, or a secret sat in a result as a number it can't replace, /secret-guard counts the results it withheld.Mods are not sandboxed. A mod's hooks run with your permissions and can read files and start processes. These four are short; read them first. claude plugin validate plugins/<name> lists every hook a mod registers and every call it makes. None of the four starts a process or uses the network of its own. (context-meter's breakdown: full asks Claude Code to count tokens, which Claude Code does with the API; its Compact button asks Claude Code for a compaction, which is a model call.)
claude plugin validate and claude plugin test (plugins/<name>/tests/) and type-checks with tsc..env of fake canary values, a demo plan with two phase files, and a small Python file, real Claude Code sessions (2.1.291, haiku) ran with the three mods, once loaded by --plugin-dir and once installed from this repository with claude plugin marketplace add vumichien/claude-code-mods-kit:cat .env, Claude received two ‹hidden: …› markers and no canary value. Since 0.3.0 that command is refused before it runs, with the command that prints the key names only and the way to load the file without printing it; rerun on 2.1.295 with --plugin-dir, cat .env was refused and a following grep -r DEMO_ . reached Claude with two markers and no canary value;/plan (now /plan-meter) answered phases 0/2 · steps 1/4 (25%) before that session and steps 2/4 (50%) after it, with no model turn.echo, cat counted as running a file, and parsing and path cases) are fixed and each has a test.validate, tsc and its 26 tests, which drive its band, countdown and button against the engine's test host, and in one live terminal session (2.1.294, a 1M window): before /compact the band read 178k of 1M · 18%, against the 179,681 tokens Claude Code recorded for the compaction; once the compaction finished, before any new message, it read 98k of 1M · 10%, and the cache line had reset to starts with the next message.validate, tsc and its 47 tests, and in live sessions loaded with --plugin-dir (the debug log confirms it replaced the installed 0.2.0) in a throwawayhooks/register.tsx 212 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { ClaudeTask, PlanMeter } from '../types'
5import { bar, line, parsePlan, percent, summarize } from './parse'
6import type { Parsed } from './parse'
7import { candidates, caseless, dirname, relativeTo, resolvePath, samePath, segmentTest } from './paths'
8
9const PANE = 'plan-meter'
10const DEFAULT_PLANS = 'plans/*/plan.md,PLAN.md,plan.md,TODO.md,TASKS.md,ROADMAP.md,todo.txt,TODO.org'
11const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
12const ZERO = { done: 0, active: 0, todo: 0, total: 0 }
13const meter = atom({ plugin: 'plan-meter', key: 'meter' } as const, null)
14const tasks = atom({ plugin: 'plan-meter', key: 'tasks' } as const, [] as ClaudeTask[])
15// The band shows only when asked: `/plan-meter on` shows it and `/plan-meter off` hides it, for this session; until
16// then the `band` option decides. Hiding it changes the drawing only: the plan is still read and /plan-meter still answers.
17const shown = atom({ plugin: 'plan-meter', key: 'shown' } as const, null)
18const SWITCH: Record<string, boolean> = { on: true, off: false }
19
20type Setup = { root: string; patterns: string[]; chosen: string | undefined; watched: string[] }
21
22// The timer, edits and /plan-meter can overlap: only the newest reading may be written.
23let newestRead = 0
24
25// Expands a pattern one segment at a time (`*` in any segment); the most recently changed match wins.
26async function newest($: any, root: string, pattern: string): Promise<string | undefined> {
27 const parts = resolvePath(root, pattern).split('/')
28 const first = parts.findIndex(p => p.includes('*'))
29 if (first < 0) {
30 const path = parts.join('/')
31 return (await $.fs.exists(path)) ? path : undefined
32 }
33 let found: { path: string; mtimeMs: number }[] = [{ path: parts.slice(0, first).join('/'), mtimeMs: 0 }]
34 for (let i = first; i < parts.length; i++) {
35 const test = segmentTest(parts[i] ?? '', caseless(root))
36 const kind = i === parts.length - 1 ? 'file' : 'dir'
37 const next: typeof found = []
38 for (const dir of found) {
39 const entries: { name: string; kind: string; mtimeMs: number }[] = await $.fs.list(dir.path || '/').catch(() => [])
40 for (const entry of entries) if (entry.kind === kind && test.test(entry.name)) next.push({ path: `${dir.path}/${entry.name}`, mtimeMs: entry.mtimeMs })
41 }
42 found = next
43 }
44 return found.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]?.path
45}
46
47// Finds the plan, reads it and the phase files it links to, and sums them up, with the files to watch.
48async function measure($: any, setup: Setup): Promise<{ meter: PlanMeter; watched: string[] }> {
49 const empty = { file: '', title: null, planStatus: null, phases: ZERO, steps: ZERO, current: null, next: [], formats: [], files: 0 }
50 let path = setup.chosen
51 for (const pattern of path === undefined ? setup.patterns : []) {
52 path = await newest($, setup.root, pattern)
53 if (path !== undefined) break
54 }
55 if (path === undefined) return { meter: { ...empty, error: `no plan found (${setup.patterns.join(', ')}); name one with /plan-meter <path>` }, watched: [] }
56 const file = relativeTo(setup.root, path)
57 try {
58 const plan = parsePlan(await $.fs.read(path), path)
59 const linked: { link: string; path: string; parsed: Parsed; again?: boolean }[] = []
60 for (const link of plan.links.slice(0, 30)) {
61 const target = resolvePath(dirname(path), link)
62 // Two spellings of one file (`a.md`, `./a.md`) are read and counted once.
63 const seen = linked.find(l => samePath(l.path, target))
64 if (seen !== undefined) {
65 linked.push({ ...seen, link, again: true })
66 continue
67 }
68 // A link to a file that isn't there is skipped, not an error.
69 const text: string | undefined = await $.fs.read(target).catch(() => undefined)
70 if (text !== undefined) linked.push({ link, path: target, parsed: parsePlan(text, target) })
71 }
72 return { meter: summarize(file, plan, linked), watched: [path, ...linked.filter(l => !l.again).map(l => l.path)] }
73 } catch (err) {
74 return { meter: { ...empty, file, error: `could not read ${file}: ${err instanceof Error ? err.message : String(err)}` }, watched: [path] }
75 }
76}
77
78// The timer, edits and /plan-meter can overlap: only the newest reading is kept, checked again as it is written.
79async function refresh($: any, setup: Setup): Promise<PlanMeter> {
80 const reading = ++newestRead
81 const found = await measure($, setup)
82 if (reading === newestRead) setup.watched = found.watched
83 await update($, meter, now => (reading === newestRead ? found.meter : now))
84 return found.meter
85}
86
87// Claude's task list after one of its task tools ran: TodoWrite replaces it, TaskCreate adds, TaskUpdate changes.
88async function trackTasks($: any, tool: string, input: Record<string, any>, result: any): Promise<void> {
89 if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
90 const list: ClaudeTask[] = input.todos.map((t: any, i: number) => ({ id: `todo-${i}`, subject: String(t?.content ?? ''), status: t?.status }))
91 await update($, tasks, () => list)
92 } else if (tool === 'TaskCreate' && typeof result?.task?.id === 'string') {
93 const task: ClaudeTask = { id: result.task.id, subject: String(input.subject ?? result.task.subject ?? ''), status: 'pending' }
94 await update($, tasks, list => [...list.filter(t => t.id !== task.id), task])
95 } else if (tool === 'TaskUpdate' && typeof input.taskId === 'string' && result?.success !== false) {
96 // A refused update changed nothing; when the tool says which status it moved to, that wins.
97 const id = input.taskId
98 const status = result?.statusChange?.to ?? input.status
99 await update($, tasks, list =>
100 status === 'deleted'
101 ? list.filter(t => t.id !== id)
102 : list.map(t => (t.id !== id ? t : { ...t, ...(status ? { status } : {}), ...(input.subject ? { subject: input.subject } : {}) })),
103 )
104 }
105}
106
107export const register: Register = (on, options) => {
108 const setup: Setup = { root: '', patterns: candidates(options.plan ?? DEFAULT_PLANS), chosen: undefined, watched: [] }
109 if (setup.patterns.length === 0) setup.patterns = candidates(DEFAULT_PLANS)
110 const everyMs = Math.max(5, Number(options.refreshSeconds ?? 15)) * 1000
111
112 on('session.start', async ($, e, next) => {
113 // Not /plan: Claude Code has a built-in of that name, and a refused name must not stop the plan being read.
114 await $.command
115 .register({
116 name: 'plan-meter',
117 description: "Show the plan's progress (/plan-meter <path> picks a file; /plan-meter on or off shows or hides the band)",
118 argumentHint: '[on|off|<path>]',
119 })
120 .catch(() => undefined)
121 setup.root = await $.session.root()
122 await refresh($, setup)
123 // Catches edits made outside Claude Code too, such as the plan open in your editor.
124 $.clock.every(everyMs, () => void refresh($, setup))
125 return next(e)
126 })
127
128 on('command.run', { command: 'plan-meter' }, async ($, e) => {
129 const show = SWITCH[e.args.trim().toLowerCase()]
130 if (show !== undefined) {
131 await update($, shown, () => show)
132 return { text: show ? 'band on (/plan-meter off hides it)' : 'band off (/plan-meter on shows it)' }
133 }
134 if (e.args.trim() !== '') setup.chosen = resolvePath(setup.root, e.args)
135 const found = await refresh($, setup)
136 // Panes draw only in the terminal and the desktop app; under claude -p the line below is the answer.
137 await $.ui.open({ id: PANE, title: 'Plan', focus: true, closeOnEscape: true }).catch(() => undefined)
138 const list = await read($, tasks)
139 const done = list.filter(t => t.status === 'completed').length
140 return { text: list.length > 0 ? `${line(found)} · Claude's tasks ${done}/${list.length}` : line(found) }
141 })
142
143 // An observer, not a guard: whatever goes wrong here, the tool's own answer is returned as it came.
144 on('tool.call', async ($, e, next) => {
145 const ran = await next(e)
146 if (ran.deny !== undefined || ran.isError) return ran
147 try {
148 const tool = String(e.tool)
149 const input = e as unknown as Record<string, any>
150 await trackTasks($, tool, input, ran.result)
151 const path = input.file_path ?? input.notebook_path
152 if (EDIT_TOOLS.includes(tool) && typeof path === 'string') {
153 const full = resolvePath(setup.root, path)
154 const isPlanLike = /(^|\/)(plan|todo|tasks|roadmap)\.(md|txt|org)$|\/phase-[^/]*\.md$/i.test(full)
155 if (setup.watched.some(w => samePath(w, full)) || (setup.chosen === undefined && isPlanLike)) await refresh($, setup)
156 }
157 } catch {
158 // The band stays as it was; the next edit or re-read tries again.
159 }
160 return ran
161 })
162
163 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
164 const below = await next(e)
165 if (!((await read($, shown)) ?? options.band === 'on')) return below
166 const now = await read($, meter)
167 const list = await read($, tasks)
168 // A project with no plan and no task list gets no band at all.
169 const hasPlan = now !== null && now.error === null
170 if (e.props.hasSurvey || (!hasPlan && list.length === 0)) return below
171 const { Box, Text } = $.ui.resolve(e)
172 const done = list.filter(t => t.status === 'completed').length
173 const parts = [hasPlan && now !== null ? line(now) : 'plan ▸ none', ...(list.length > 0 ? [`Claude's tasks ${done}/${list.length}`] : [])]
174 return (
175 <Box flexDirection="column">
176 <Text wrap="truncate" dimColor={!hasPlan}>
177 {parts.join(' · ').slice(0, Math.max(10, e.props.bodyColumns))}
178 </Text>
179 {below}
180 </Box>
181 )
182 })
183
184 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
185 const { Box, Text } = $.ui.resolve(e)
186 const now = await read($, meter)
187 const list = await read($, tasks)
188 const width = Math.max(10, Math.min(30, e.props.bodyColumns - 30))
189 return (
190 <Box flexDirection="column">
191 {now === null && <Text dimColor>No plan read yet.</Text>}
192 {now !== null && now.error !== null && <Text color="warning">{now.error}</Text>}
193 {now !== null && now.error === null && <Text bold>{now.title ?? now.file}</Text>}
194 {now !== null && now.error === null && now.planStatus !== null && <Text>{`Status: ${now.planStatus}`}</Text>}
195 {now !== null && now.phases.total > 0 && (
196 <Text color={now.phases.done === now.phases.total ? 'success' : undefined}>{`Phases ${bar(now.phases, width)} ${now.phases.done}/${now.phases.total} (${percent(now.phases)}%)`}</Text>
197 )}
198 {now !== null && now.steps.total > 0 && (
199 <Text color={now.steps.done === now.steps.total ? 'success' : undefined}>{`Steps ${bar(now.steps, width)} ${now.steps.done}/${now.steps.total} (${percent(now.steps)}%)`}</Text>
200 )}
201 {now !== null && now.current !== null && <Text color="warning">{`Now: ${now.current}`}</Text>}
202 {now !== null && now.next.length > 0 && <Text>{`Next: ${now.next.join(' · ')}`}</Text>}
203 {list.length > 0 && <Text bold>{`Claude's tasks ${list.filter(t => t.status === 'completed').length}/${list.length}`}</Text>}
204 {list.slice(0, 8).map(t => (
205 <Text key={t.id} dimColor={t.status === 'completed'}>{`${t.status === 'completed' ? '✔' : t.status === 'in_progress' ? '▶' : '·'} ${t.subject}`}</Text>
206 ))}
207 {now !== null && now.error === null && <Text dimColor>{`${now.file} · ${now.files} file(s) · ${now.formats.join(', ') || 'no format recognised'}`}</Text>}
208 </Box>
209 )
210 })
211}
212hooks/parse.ts 288 lines1// Pure helpers for plan-meter: read a plan file in any of the formats it knows, and say how far along it is.
2
3import type { Count, PlanMeter, Status } from '../types'
4
5export type Item = { text: string; status: Status }
6export type Parsed = {
7 title: string | null
8 // The plan's own status line (frontmatter `status:`, `**Status:** …`, `Status (date): …`), as written.
9 planStatus: string | null
10 // Phases: rows of a table with a status column, or headings that carry a status or are named Phase/Step/….
11 // `said` is false when the plan gives no status the parser knows, so a linked phase file may supply one.
12 phases: (Item & { link: string | null; said: boolean })[]
13 // Steps: checklist items, org-mode TODO/DONE headlines, todo.txt lines.
14 steps: Item[]
15 // Relative links to phase files (`phase-*.md`, `step-*.md`, …), to read their steps too.
16 links: string[]
17 // Which formats were found, for the pane and the README's list.
18 formats: string[]
19}
20
21// Status words in English, Vietnamese and Japanese, and the usual marks. The earliest match in a text wins,
22// so "Done (pending review)" is done and "Not started" is to do.
23const STATUS_WORDS: [Status, RegExp][] = [
24 ['todo', /\b(not (started|done|yet)|incomplete|unfinished|to-?do|to do|pending|planned|backlog|queued|open)\b|chưa( làm| xong)?|未着手|⬜|☐|\[ \]/i],
25 ['dropped', /\b(cancel+ed|dropped|won'?t (do|fix)|wontfix|skipped|obsolete|abandoned|n\/a)\b|hủy|bỏ qua|中止|🚫/i],
26 ['done', /\b(done|complete[d]?|finished|shipped|merged|closed|resolved|delivered|passed)\b|xong|hoàn thành|完了|✅|✔|☑|✓|\[x\]/i],
27 ['active', /\b(in[-_ ]?progress|wip|doing|ongoing|active|started|running|in review|reviewing|blocked)\b|đang( làm)?|進行中|🚧|🔄|⏳|▶/i],
28]
29
30export function classify(text: string): Status | undefined {
31 let best: { status: Status; at: number } | undefined
32 for (const [status, pattern] of STATUS_WORDS) {
33 const found = pattern.exec(text)
34 if (found !== null && (best === undefined || found.index < best.at)) best = { status, at: found.index }
35 }
36 return best?.status
37}
38
39// `[x]` and its cousins: GitHub's x, Obsidian's / (doing), - (cancelled), > (deferred).
40const CHECKBOX = /^\s*(?:[-*+]|\d+[.)])\s+\[([ xX/~\-><!?])\]\s+(.*)$/
41const CHECK_MARKS: Record<string, Status> = { x: 'done', X: 'done', '/': 'active', '~': 'active', '-': 'dropped' }
42const ORG_HEADLINE = /^\*+\s+(TODO|NEXT|DOING|IN-PROGRESS|STARTED|WAITING|HOLD|DONE|CANCELL?ED)\s+(.*)$/
43const ORG_STATUS: Record<string, Status> = { DONE: 'done', CANCELLED: 'dropped', CANCELED: 'dropped', DOING: 'active', 'IN-PROGRESS': 'active', STARTED: 'active', WAITING: 'active', HOLD: 'active' }
44const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/
45// A heading named like a phase: "Phase 2", "Step 3:", "Milestone IV", "Giai đoạn 1", "フェーズ1".
46const PHASE_NAME = /^(?:\d+[.)]\s*)?(phase|step|stage|milestone|sprint|part|task|giai đoạn|bước|フェーズ)\s*[\dIVX]+\b/i
47// A heading's own status: a mark anywhere, or a status set off at its end: "(done)", "[WIP]", "— done", ": in progress".
48const HEADING_MARK = /[✅✔☑✓🚧🔄⏳⬜☐🚫]/u
49const STATUS_ALONE = /^(done|complete[d]?|finished|in[-_ ]?progress|wip|doing|todo|to-?do|pending|blocked|skipped|cancel+ed|dropped|xong|hoàn thành|đang làm|chưa làm|完了|進行中|未着手)$/i
50// Where a status sits at a heading's end, tried in this order: "(done)" or "[done]", "— done", ": done".
51const HEADING_TAILS = [/\s*[([]\s*([^)\]]+?)\s*[)\]]\s*$/, /\s+[—–|-]\s+([^—–|]+?)\s*$/, /:\s+([^:]+?)\s*$/]
52const MARKS = /\s*[✅✔☑✓🚧🔄⏳⬜☐🚫]+\s*/gu
53
54// A heading's status and its name without a status word at the end.
55// A heading named like a phase takes any status word in its tail; another heading only a tail that is a status alone,
56// so "## Phase 2: Draft (in progress)" carries a status and "## Decisions (closed 2026-09-27)" does not.
57function headingStatus(words: string, named: boolean): { status: Status | undefined; name: string } {
58 const mark = HEADING_MARK.exec(words)
59 for (const tail of HEADING_TAILS) {
60 const found = tail.exec(words)
61 const said = (found?.[1] ?? '').replace(MARKS, ' ').trim()
62 if (found === null || said === '') continue
63 const alone = STATUS_ALONE.test(said)
64 const status = alone || named ? classify(said) : undefined
65 if (status === undefined) continue
66 return { status: mark !== null ? classify(mark[0]) : status, name: (alone ? words.slice(0, found.index) : words).replace(MARKS, ' ').trim() }
67 }
68 return { status: mark !== null ? classify(mark[0]) : undefined, name: words.replace(MARKS, ' ').trim() }
69}
70const STATUS_HEADER = /^(status|state|progress|done\??|trạng thái|tình trạng|ステータス|状態)$/i
71// Name columns, most telling first: a "Phase" column often holds only the number.
72const NAME_HEADERS = [/^(name|title|task|item|what|tên|công việc|deliverable)$/i, /^(phase|step|milestone|stage|giai đoạn)$/i]
73const DELIMITER = /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?$/
74const MD_LINK = /\[([^\]]*)\]\(([^)\s]+)\)/g
75// A phase file's name: `phase-01-schema.md`, `phase1.md`, `steps.md`, `part_2.md`; not `department.md`.
76const PHASE_FILE = /(^|\/)(phase|step|stage|milestone|sprint|part|task)s?([-_ ]?\d[^/]*|[-_ ][^/]*)?\.md$/i
77
78// Plain text of a cell or line: links to their text, emphasis and code marks dropped.
79export function plain(text: string): string {
80 return text.replace(MD_LINK, '$1').replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim()
81}
82
83// A table row's cells; `\|` is a pipe inside a cell, not a border.
84function cells(row: string): string[] {
85 return row.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '').split(/(?<!\\)\|/).map(c => c.replace(/\\\|/g, '|').trim())
86}
87
88// Links to phase files, without any `#section` part.
89function phaseLinks(text: string): string[] {
90 return [...text.matchAll(MD_LINK)].map(m => (m[2] ?? '').split('#')[0] ?? '').filter(href => href !== '' && !/^[a-z]+:/i.test(href) && PHASE_FILE.test(href))
91}
92
93export function parsePlan(text: string, fileName: string): Parsed {
94 const out: Parsed = { title: null, planStatus: null, phases: [], steps: [], links: [], formats: [] }
95 const found = new Set<string>()
96 let lines = text.split(/\r?\n/)
97
98 // YAML frontmatter: title and status.
99 if (lines[0]?.trim() === '---') {
100 const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---')
101 if (end > 0) {
102 for (const line of lines.slice(1, end)) {
103 const kv = /^(title|status):\s*["']?(.*?)["']?\s*$/i.exec(line)
104 if (kv?.[1]?.toLowerCase() === 'title') out.title = kv[2] || null
105 if (kv?.[1]?.toLowerCase() === 'status') out.planStatus = kv[2] || null
106 }
107 found.add('frontmatter')
108 lines = lines.slice(end + 1)
109 }
110 }
111
112 // todo.txt: one task per line, `x ` marks it done.
113 if (/(^|[\\/])([^\\/]*\.)?todo\.txt$/i.test(fileName)) {
114 for (const line of lines.map(l => l.trim()).filter(Boolean)) out.steps.push({ text: line.replace(/^x\s+(\d{4}-\d\d-\d\d\s+)*/, ''), status: line.startsWith('x ') ? 'done' : 'todo' })
115 out.formats.push('todo.txt')
116 return out
117 }
118
119 let fence: string | undefined
120 let tableStatus = -1
121 let tableName = -1
122 // Heading phases with no status of their own take it from the checklist beneath them.
123 const headingPhases: (Item & { link: string | null; level: number; from: number; to: number; own: boolean })[] = []
124 const close = (level: number) => {
125 for (const h of headingPhases) if (h.to < 0 && h.level >= level) h.to = out.steps.length
126 }
127 for (const [i, raw] of lines.entries()) {
128 const line = raw.trimEnd()
129 const trimmed = line.trim()
130 const marker = /^(`{3,}|~{3,})/.exec(trimmed)?.[1]
131 if (marker !== undefined) {
132 if (fence === undefined) fence = marker
133 else if (marker[0] === fence[0] && marker.length >= fence.length && trimmed === marker) fence = undefined
134 continue
135 }
136 if (fence !== undefined) continue
137
138 const box = CHECKBOX.exec(line)
139 if (box !== null) {
140 out.steps.push({ text: plain(box[2] ?? ''), status: CHECK_MARKS[box[1] ?? ' '] ?? 'todo' })
141 found.add('checklist')
142 out.links.push(...phaseLinks(line))
143 continue
144 }
145
146 const org = ORG_HEADLINE.exec(line)
147 if (org !== null) {
148 out.steps.push({ text: plain(org[2] ?? ''), status: ORG_STATUS[org[1] ?? ''] ?? 'todo' })
149 found.add('org-mode')
150 continue
151 }
152
153 // A table: its header row is the line above a delimiter row.
154 if (trimmed.includes('|') && DELIMITER.test(trimmed) && (lines[i - 1] ?? '').includes('|')) {
155 const header = cells(lines[i - 1] ?? '').map(plain)
156 tableStatus = header.findIndex(h => STATUS_HEADER.test(h))
157 tableName = NAME_HEADERS.map(re => header.findIndex(h => re.test(h))).find(at => at >= 0) ?? -1
158 continue
159 }
160 if (tableStatus >= 0 && trimmed.includes('|')) {
161 const row = cells(trimmed)
162 const named = plain(row[tableName] ?? '')
163 // A number alone names nothing: take the first other cell with words in it.
164 const name = named !== '' && !/^\d+$/.test(named) ? named : plain(row.find((c, at) => at !== tableStatus && !/^\d*$/.test(plain(c))) ?? '')
165 const links = phaseLinks(trimmed)
166 const said = classify(plain(row[tableStatus] ?? ''))
167 out.phases.push({ text: name, status: said ?? 'todo', link: links[0] ?? null, said: said !== undefined })
168 out.links.push(...links)
169 found.add('status table')
170 continue
171 }
172 if (!trimmed.includes('|')) tableStatus = -1
173
174 const heading = HEADING.exec(trimmed)
175 if (heading !== null) {
176 const words = plain(heading[2] ?? '')
177 if (out.title === null && heading[1] === '#') out.title = words.replace(/^(plan|kế hoạch)\s*[:—-]\s*/i, '') || null
178 const level = heading[1]?.length ?? 1
179 close(level)
180 const named = PHASE_NAME.test(words)
181 const { status, name } = headingStatus(words, named)
182 if (level > 1 && !/^status\b/i.test(words) && (status !== undefined || named)) {
183 headingPhases.push({ text: name, status: status ?? 'todo', link: phaseLinks(trimmed)[0] ?? null, level, from: out.steps.length, to: -1, own: status !== undefined })
184 }
185 out.links.push(...phaseLinks(trimmed))
186 continue
187 }
188
189 // A status line in the body: `**Status:** done`, `Status (2026-10-07): Draft`.
190 const statusLine = /^status\b(?:\s*\([^)]*\))?\s*:\s*(.+)$/i.exec(plain(trimmed))
191 if (statusLine !== null && out.planStatus === null) {
192 out.planStatus = plain(statusLine[1] ?? '').slice(0, 80)
193 found.add('status line')
194 }
195 out.links.push(...phaseLinks(trimmed))
196 }
197
198 close(1)
199 // Headings count as phases only when no status table gave them, and only when at least one says how far it is
200 // (itself, the checklist beneath it, or the phase file it links to).
201 const known = headingPhases.map(h => (h.own ? h.status : derived(out.steps.slice(h.from, h.to))))
202 if (out.phases.length === 0 && known.some((s, at) => s !== undefined || headingPhases[at]?.link !== null)) {
203 out.phases = headingPhases.map((h, at) => ({ text: h.text, link: h.link, status: known[at] ?? 'todo', said: known[at] !== undefined }))
204 found.add('status headings')
205 }
206 out.links = [...new Set(out.links)]
207 out.formats = [...found]
208 return out
209}
210
211// What a run of steps adds up to: all done is done, any started is active, none started is to do.
212function derived(steps: readonly Item[]): Status | undefined {
213 const c = count(steps)
214 if (c.total === 0) return undefined
215 return c.done === c.total ? 'done' : c.done + c.active > 0 ? 'active' : 'todo'
216}
217
218// A linked phase file's own status: its frontmatter or status line, else what its steps say.
219function fileStatus(p: Parsed): Status | undefined {
220 return (p.planStatus === null ? undefined : classify(p.planStatus)) ?? derived(p.steps)
221}
222
223// The plan and its linked phase files, read together into what the band and the pane show.
224// `again` marks a second link to a file already listed, so its steps are not counted twice.
225export function summarize(file: string, plan: Parsed, all: readonly { link: string; parsed: Parsed; again?: boolean }[]): PlanMeter {
226 const linked = all.filter(l => l.again !== true)
227 const steps = [...plan.steps, ...linked.flatMap(l => l.parsed.steps)]
228 // A phase that says nothing the parser knows defers to its phase file; "Pending" stays pending.
229 const resolved = plan.phases.map(p => {
230 if (p.said) return { text: p.text, status: p.status, known: true }
231 const own = all.find(l => l.link === p.link)
232 const status = own === undefined ? undefined : fileStatus(own.parsed)
233 return { text: p.text, status: status ?? ('todo' as Status), known: status !== undefined }
234 })
235 // When no phase says how far it is, there is no phase count to show rather than a made-up 0 of N.
236 let phases: Item[] = resolved.some(r => r.known) ? resolved.map(({ text, status }) => ({ text, status })) : []
237 if (phases.length === 0 && linked.some(l => fileStatus(l.parsed) !== undefined)) {
238 phases = linked.map(l => ({ text: l.parsed.title ?? l.link, status: fileStatus(l.parsed) ?? 'todo' }))
239 }
240 const active = phases.find(p => p.status === 'active') ?? steps.find(s => s.status === 'active')
241 const todo = steps.filter(s => s.status === 'todo').concat(steps.length === 0 ? phases.filter(p => p.status === 'todo') : [])
242 const formats = new Set([...plan.formats, ...linked.flatMap(l => l.parsed.formats)])
243 if (linked.length > 0) formats.add('linked phase files')
244 return {
245 file,
246 title: plan.title,
247 planStatus: plan.planStatus,
248 phases: count(phases),
249 steps: count(steps),
250 current: active?.text ?? null,
251 next: todo.slice(0, 3).map(t => t.text),
252 formats: [...formats],
253 files: 1 + linked.length,
254 error: null,
255 }
256}
257
258// One line for the band and for /plan-meter under `claude -p`.
259export function line(m: PlanMeter): string {
260 if (m.error !== null) return `plan ▸ ${m.error}`
261 const pieces = [`plan ▸ ${m.title ?? m.file}`]
262 if (m.phases.total > 0) pieces.push(`phases ${m.phases.done}/${m.phases.total}`)
263 if (m.steps.total > 0) pieces.push(`steps ${m.steps.done}/${m.steps.total} (${percent(m.steps)}%)`)
264 if (m.phases.total === 0 && m.steps.total === 0) pieces.push(m.planStatus ?? 'no checklist, status table or status headings found')
265 if (m.current !== null) pieces.push(`now: ${m.current}`)
266 else if (m.next[0] !== undefined) pieces.push(`next: ${m.next[0]}`)
267 return pieces.join(' · ')
268}
269
270export function count(items: readonly Item[]): Count {
271 const c = { done: 0, active: 0, todo: 0, total: 0 }
272 for (const item of items) {
273 if (item.status === 'dropped') continue
274 c[item.status] += 1
275 c.total += 1
276 }
277 return c
278}
279
280export function percent(c: Count): number {
281 return c.total === 0 ? 0 : Math.round((c.done / c.total) * 100)
282}
283
284export function bar(c: Count, width: number): string {
285 const filled = c.total === 0 ? 0 : Math.round((c.done / c.total) * width)
286 return '█'.repeat(filled) + '░'.repeat(width - filled)
287}
288hooks/paths.ts 58 lines1// Pure path helpers for plan-meter: Windows and Unix paths, compared the way each file system would.
2
3export function isAbsolute(path: string): boolean {
4 return /^([A-Za-z]:)?[\\/]/.test(path)
5}
6
7// Windows paths (a drive letter or a network share) compare without case; Unix paths with it.
8export function caseless(path: string): boolean {
9 return /^([A-Za-z]:|[\\/]{2})/.test(path)
10}
11
12// Forward slashes, `.` and `..` segments applied, case kept, a network share's leading `//` kept.
13export function normalize(path: string): string {
14 const share = /^[\\/]{2}[^\\/]/.test(path)
15 const out: string[] = []
16 for (const part of path.replace(/\\/g, '/').split('/')) {
17 if (part === '.' || (part === '' && out.length > 0)) continue
18 if (part === '..' && out.length > 1) out.pop()
19 else if (part !== '..') out.push(part)
20 }
21 return (share ? '/' : '') + out.join('/')
22}
23
24// A path relative to `base` unless absolute, normalized.
25export function resolvePath(base: string, path: string): string {
26 const clean = path.trim()
27 return normalize(isAbsolute(clean) ? clean : `${base}/${clean}`)
28}
29
30export function dirname(path: string): string {
31 const full = normalize(path)
32 return full.slice(0, Math.max(0, full.lastIndexOf('/')))
33}
34
35function fold(path: string, like: string): string {
36 return caseless(like) ? path.toLowerCase() : path
37}
38
39// The path relative to the project when it lies inside it.
40export function relativeTo(root: string, path: string): string {
41 const base = normalize(root)
42 const full = normalize(path)
43 return fold(full, base).startsWith(`${fold(base, base)}/`) ? full.slice(base.length + 1) : full
44}
45
46export function samePath(a: string, b: string): boolean {
47 return fold(normalize(a), a) === fold(normalize(b), a)
48}
49
50// One segment of a pattern as a test for a name: `*` matches any run of characters; case is ignored on Windows.
51export function segmentTest(segment: string, ignoreCase = true): RegExp {
52 return new RegExp(`^${segment.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, ignoreCase ? 'i' : '')
53}
54
55export function candidates(option: unknown): string[] {
56 return String(option ?? '').split(',').map(p => p.trim()).filter(Boolean)
57}
58types/index.d.ts 34 lines1export type Status = 'done' | 'active' | 'todo' | 'dropped'
2
3export type Count = { done: number; active: number; todo: number; total: number }
4
5export type PlanMeter = {
6 // The plan file, relative to the project when it lies inside it.
7 file: string
8 title: string | null
9 // The plan's own status line or frontmatter status, as written.
10 planStatus: string | null
11 // Phases: status-table rows, status headings, or linked phase files (dropped ones left out).
12 phases: Count
13 // Steps: checklist items, org-mode headlines, todo.txt lines, in the plan and its linked phase files.
14 steps: Count
15 // The first phase or step in progress, and the first ones still to do.
16 current: string | null
17 next: string[]
18 // Which formats were recognised, and how many files were read (the plan plus linked phase files).
19 formats: string[]
20 files: number
21 // Set when no plan was found or it could not be read.
22 error: string | null
23}
24
25// Claude's own task list, as its TodoWrite / TaskCreate / TaskUpdate calls left it.
26export type ClaudeTask = { id: string; subject: string; status: 'pending' | 'in_progress' | 'completed' }
27
28declare module 'claude-code' {
29 interface PluginState {
30 // shown: the band switched on or off with /plan-meter on|off this session; null until then (the band option decides).
31 'plan-meter': { meter: PlanMeter | null; tasks: ClaudeTask[]; shown: boolean | null }
32 }
33}
34