SLOPSHOPPER

plan-meter

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…

newpanebandguardcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plan-meter
│ ┃ Plan ✕ › fix the failing auth test and add an audit log call │ ┃ no plan found (plans/*/plan.md, PLAN.md, │ ┃ plan.md, TODO.md, TASKS.md, ROADMAP.md, ⏺ Read(src/auth.ts) │ ┃ todo.txt, TODO.org); name one with ⎿ Read 6 lines │ ┃ /plan-meter <path> ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /plan-meter │ ⎿ plan-meter: plan ▸ no plan found (plans/*/plan.md, PLAN.md, plan │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Plan
no plan found (plans/*/plan.md, PLAN.md, plan.md, TODO.md, TASKS.md, ROADMAP.md, todo.txt, TODO.org); name one with /plan-meter <path>
README

claude-code-mods-kit

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.

ModWhat it doesCommand
secret-guardReads 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-meterA 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-gateWhen 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-meterA 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.

Try one without installing anything

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.

Install

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.

Options

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 formats plan-meter reads

plan-meter does not ask you to write your plan its way. It recognises these, alone or mixed in one file:

FormatExampleWhat it counts
Checklist- [x] write the schemaOne 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 filesPhase 1Links 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 frontmattertitle: Ship the export / status: in_progressThe plan's title and its own status.
Status lineStatus: 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 testsOne step per headline. DONE done; DOING, IN-PROGRESS, STARTED, WAITING, HOLD in progress; CANCELLED left out; TODO, NEXT to do.
todo.txtx 2026-10-01 call the bankA 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.

  • Done: done, complete, completed, finished, shipped, merged, closed, resolved, delivered, passed, xong, hoàn thành, 完了, ✅ ✔ ☑ ✓ [x]
  • In progress: in progress (also in-progress, in_progress), WIP, doing, ongoing, active, started, running, in review, reviewing, blocked, đang, đang làm, 進行中, 🚧 🔄 ⏳ ▶
  • To do: not started, not done, not yet, incomplete, unfinished, todo, to do, pending, planned, backlog, queued, open, chưa, chưa làm, chưa xong, 未着手, ⬜ ☐ [ ]
  • Dropped, left out of the count: cancelled, canceled, dropped, won't do, won't fix, wontfix, skipped, obsolete, abandoned, n/a, hủy, bỏ qua, 中止, 🚫

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.

What done-gate does and does not do

  • It watches the files Claude changes with its Edit, Write, MultiEdit and NotebookEdit tools, and the commands Claude runs with Bash or PowerShell. A test command that passes clears every file changed before it started; one that fails keeps them and turns the band red.
  • When the exit status could belong to another command (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.
  • A passing command that runs a changed file (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.
  • When Claude marks a task done (TaskUpdate to 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.

  • It does not refuse anything, and it cannot tell whether your tests cover the changed files: any passing test run counts.
  • It does not see files changed by a shell command (sed -i, a code generator) or outside Claude Code, nor a test run sent to the background, whose result it cannot know.
  • It keeps its record for the session only.

What secret-guard does not do

  • It is not a security boundary. It hides exact copies of the values in your env files, and values that look like secrets by their key name or their shape. In the author's tests it missed a base64-encoded copy of a value and the value's first 8 characters printed alone. A secret printed bare, with no key name beside it and in no env file (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.
  • It reads the env files at session start. A value added later is protected from the next session. A file outside the folders above the session (another project, a secrets folder in your home directory) is read only when secretFiles names its path.
  • A file it cannot parse (a quoted value that never closes) is skipped: its values are not protected, while every other file still is. secret-guard names the file and the reason, never its content, in the status line, in the # 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.
  • The detectors also hide what only looks like a secret: a literal under a secret-looking name in code you read (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).
  • Edits. A file Claude read with a hidden value shows ‹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.
  • An AWS secret access key is caught by its key name (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.
  • When it cannot check a result, it withholds the result rather than letting it through. If an env file could not be read, it refuses every call and every outgoing message and /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.
  • It keeps the values in memory only: never in a file, a log, the status line, the context block or Claude Code's state. The context block holds the key names only.

Before you install any mod

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

How it was tested

  • Each mod passes claude plugin validate and claude plugin test (plugins/<name>/tests/) and type-checks with tsc.
  • In a throwaway folder holding a .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:
  • asked to run 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;
  • asked to add a task, change the Python file, tick the plan's step and mark the task done without running anything, Claude received the done-gate note and quoted it back;
  • /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.
  • A second reviewer, OpenAI's Codex, read both new mods; its 13 findings (a test runner named only inside echo, cat counted as running a file, and parsing and path cases) are fixed and each has a test.
  • In a control session without the mods, both canary values reached Claude and no note appeared.
  • The live-session checks above are for the first three mods. context-meter (added 2026-10-08, on Claude Code 2.1.294) is checked by 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.
  • secret-guard 0.3.0 (2026-10-09, Claude Code 2.1.295, haiku) is checked by 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 throwaway
Source 4 files
hooks/register.tsx 212 lines
1import { 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}
212
hooks/parse.ts 288 lines
1// 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}
288
hooks/paths.ts 58 lines
1// 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}
58
types/index.d.ts 34 lines
1export 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