Notices when Claude marks a task done while code it changed has not been tested since, and tells Claude and you; a band shows the last test run. It warns, it…

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 136 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Gate } from '../types'
5import { describe, isIgnored, list, ranFiles, sameFile, shortPath, testOutcome, testRun, warning } from './gate'
6
7const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
8const SHELL_TOOLS = ['Bash', 'PowerShell']
9const DEFAULT_IGNORE = '.md,.mdx,.markdown,.txt,.rst,.adoc,.org'
10const gate = atom({ plugin: 'done-gate', key: 'gate' } as const, { lastTest: null, unchecked: [], warnings: 0 } as Gate)
11// The band shows only when asked: `/done-gate on` shows it and `/done-gate off` hides it, for this session; until
12// then the `band` option decides. Hiding it changes the drawing only: edits and test runs are still tracked, and
13// Claude still gets its note when a task is marked done too early.
14const shown = atom({ plugin: 'done-gate', key: 'shown' } as const, null)
15const SWITCH: Record<string, boolean> = { on: true, off: false }
16
17// Task names by id, from TaskCreate: a TaskUpdate call carries only the id. Each edit gets the next number, so a
18// test run clears only the edits made before it started, not one made while it ran.
19type Setup = { root: string; extra: string[]; ignore: string[]; subjects: Map<string, string>; edits: number; editedAt: Map<string, number> }
20
21// The tasks a TodoWrite call newly marked completed, or the one a TaskUpdate completed.
22function completedTasks(tool: string, input: Record<string, any>, result: any, subjects: Map<string, string>): string[] {
23 if (tool === 'TaskUpdate') {
24 // A refused update changed nothing; when the tool says which status it moved to, that wins.
25 if (result?.success === false || (result?.statusChange?.to ?? input.status) !== 'completed') return []
26 return [String(input.subject ?? subjects.get(String(input.taskId)) ?? `task ${input.taskId}`)]
27 }
28 if (tool !== 'TodoWrite') return []
29 const before = new Set<string>((result?.oldTodos ?? []).filter((t: any) => t?.status === 'completed').map((t: any) => String(t.content)))
30 const after: any[] = result?.newTodos ?? input.todos ?? []
31 return after.filter(t => t?.status === 'completed' && !before.has(String(t.content))).map(t => String(t.content))
32}
33
34// Files changed and test runs seen; answers with a note for Claude when a task is marked done too early.
35async function observe($: any, setup: Setup, e: Record<string, any>, ran: any, startedAt: number): Promise<string | undefined> {
36 const tool = String(e.tool)
37 const path = e.file_path ?? e.notebook_path
38 if (EDIT_TOOLS.includes(tool) && typeof path === 'string') {
39 if (ran.isError === true || isIgnored(path, setup.ignore)) return undefined
40 const file = shortPath(setup.root, path)
41 setup.editedAt.set(file, ++setup.edits)
42 await update($, gate, g => (g.unchecked.includes(file) ? g : { ...g, unchecked: [...g.unchecked, file] }))
43 return undefined
44 }
45 if (SHELL_TOOLS.includes(tool) && typeof e.command === 'string') {
46 // A command sent to the background has not finished: its outcome is unknown.
47 if (ran.result?.backgroundTaskId !== undefined) return undefined
48 const passed = ran.isError !== true && ran.result?.interrupted !== true
49 const command = e.command
50 const at = await $.clock.now()
51 const masked = testRun(command, setup.extra)
52 // A masked run (`pytest | tail -20`) is judged by the runner's summary in what came back, when there is one.
53 const said = masked === 'masked' ? testOutcome(`${ran.text ?? ''}\n${ran.result?.stdout ?? ''}`) : undefined
54 const kind = masked === 'masked' ? (said === 'failed' ? 'failed' : said === 'passed' && passed ? 'test' : 'masked') : masked
55 const before = (f: string) => (setup.editedAt.get(f) ?? 0) <= startedAt
56 if (kind === 'failed' || (kind !== undefined && kind !== 'masked' && !passed)) {
57 await update($, gate, g => ({ ...g, lastTest: { passed: false, at, command: command.slice(0, 120) } }))
58 } else if (kind === 'test') {
59 await update($, gate, g => ({ ...g, lastTest: { passed: true, at, command: command.slice(0, 120) }, unchecked: g.unchecked.filter(f => !before(f)) }))
60 } else if (kind === undefined && passed) {
61 // Running a changed script checks that it runs, if not that it is right. Reading it (cat, git diff) checks nothing.
62 const scripts = ranFiles(command)
63 await update($, gate, g => ({ ...g, unchecked: g.unchecked.filter(f => !(before(f) && scripts.some(s => sameFile(setup.root, s, f)))) }))
64 }
65 // A masked run with no runner summary to read (`pytest || true` printing nothing useful) says nothing either way.
66 return undefined
67 }
68 if (tool === 'TaskCreate' && ran.result?.task?.id !== undefined) setup.subjects.set(String(ran.result.task.id), String(e.subject ?? ran.result.task.subject ?? ''))
69 const done = completedTasks(tool, e, ran.result, setup.subjects)
70 const now = await read($, gate)
71 if (done.length === 0 || now.unchecked.length === 0) return undefined
72 await update($, gate, g => ({ ...g, warnings: g.warnings + 1 }))
73 return warning(now, done[0] ?? 'a task')
74}
75
76export const register: Register = (on, options) => {
77 const setup: Setup = { root: '', extra: list(options.testCommands), ignore: list(options.ignore ?? DEFAULT_IGNORE), subjects: new Map(), edits: 0, editedAt: new Map() }
78
79 on('session.start', async ($, e, next) => {
80 await $.command.register({
81 name: 'done-gate',
82 description: 'Show the last test run and the code files changed since (/done-gate on or off shows or hides the band)',
83 argumentHint: '[on|off]',
84 })
85 setup.root = await $.session.root()
86 return next(e)
87 })
88
89 on('command.run', { command: 'done-gate' }, async ($, e) => {
90 const show = SWITCH[e.args.trim().toLowerCase()]
91 if (show !== undefined) {
92 await update($, shown, () => show)
93 return { text: show ? 'band on (/done-gate off hides it)' : 'band off (/done-gate on shows it)' }
94 }
95 const now = await read($, gate)
96 const head = describe(now, await $.clock.now()) ?? 'done-gate ▸ no code changed and no test run yet'
97 const tail = now.unchecked.length > 0 ? `\nchanged since: ${now.unchecked.join(', ')}` : ''
98 const last = now.lastTest === null ? '' : `\nlast test command: ${now.lastTest.command}`
99 return { text: head + last + tail }
100 })
101
102 // An observer, not a guard: it only ever adds a note and never refuses a call; whatever goes wrong here,
103 // the tool's own answer is returned as it came.
104 on('tool.call', async ($, e, next) => {
105 const startedAt = setup.edits
106 const ran = await next(e)
107 if (ran.deny !== undefined) return ran
108 try {
109 const note = await observe($, setup, e as unknown as Record<string, any>, ran, startedAt)
110 if (note === undefined) return ran
111 $.ui.toast(note.slice(0, 160))
112 return { ...ran, context: [...(ran.context ?? []), note] }
113 } catch {
114 return ran
115 }
116 })
117
118 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
119 const below = await next(e)
120 if (!((await read($, shown)) ?? options.band === 'on')) return below
121 const text = describe(await read($, gate), await $.clock.now())
122 if (e.props.hasSurvey || text === undefined) return below
123 const { Box, Text } = $.ui.resolve(e)
124 const now = await read($, gate)
125 const color = now.lastTest?.passed === false ? 'error' : now.unchecked.length > 0 ? 'warning' : 'success'
126 return (
127 <Box flexDirection="column">
128 <Text wrap="truncate" color={color}>
129 {text.slice(0, Math.max(10, e.props.bodyColumns))}
130 </Text>
131 {below}
132 </Box>
133 )
134 })
135}
136hooks/gate.ts 137 lines1// Pure helpers for done-gate: which commands run tests or scripts, which files count as code, and the words it shows.
2
3import type { Gate } from '../types'
4
5// The usual test runners, as the first word(s) of a simple command.
6export const TEST_COMMAND =
7 /^(pytest|py\.test|python[\d.]* -m (pytest|unittest)|(npm|pnpm|yarn|bun)( run)? test|vitest|jest|mocha|go test|cargo (test|nextest)|mvn( -\S+)* (test|verify)|(\.\/)?gradlew? test|dotnet test|(bundle exec )?rspec|(vendor\/bin\/)?phpunit|mix test|swift test|ctest|make (test|check)|tox|nox|deno test|claude plugin test)(\s|$)/
8
9// Words that run the rest of the command: `FOO=1 pytest`, `npx vitest`, `uv run pytest`.
10const WRAPPERS = /^(?:(?:[A-Za-z_][A-Za-z0-9_]*=\S*|time|env|npx|bunx|pnpm (?:exec|dlx)|yarn dlx|uv run|poetry run|pipenv run|hatch run|pdm run|rye run)\s+)+/
11// Programs that run the file named after them.
12const INTERPRETERS = /^(python[\d.]*|py|node|deno run|bun(?: run)?|tsx|ts-node|ruby|perl|php|bash|sh|zsh|pwsh|powershell|Rscript|julia|lua|go run)(?=\s|$)/
13
14export function list(option: unknown): string[] {
15 return String(option ?? '').split(',').map(p => p.trim()).filter(Boolean)
16}
17
18// Quoted text is an argument, never a command: `echo "pytest"` runs echo.
19function unquoted(command: string): string {
20 return command.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""')
21}
22
23// The simple commands in a command line, split at `&&`, `||`, `;`, `|`, newlines and brackets, wrappers dropped.
24export function segments(command: string): string[] {
25 return unquoted(command)
26 .split(/&&|\|\||[;|\n()]/)
27 .map(s => s.trim().replace(WRAPPERS, ''))
28 .filter(Boolean)
29}
30
31// What a command line says about tests: `test` when one of its simple commands is a test runner (or one of yours,
32// `testCommands`) and the line's exit status is the runner's; `masked` when it could be another command's
33// (`pytest | tail -20`, `pytest; echo done`, `pytest || true`): then only the runner's own summary line can tell.
34export function testRun(command: string, extra: readonly string[]): 'test' | 'masked' | undefined {
35 const isTest = (s: string) => TEST_COMMAND.test(s) || extra.some(e => s === e || s.startsWith(`${e} `))
36 const line = unquoted(command)
37 const pieces = line.split(/(&&|\|\||[;|\n])/)
38 const first = pieces.findIndex((p, at) => at % 2 === 0 && isTest(p.trim().replace(/^\(+\s*|\s*\)+$/g, '').replace(WRAPPERS, '')))
39 if (first < 0) return segments(command).some(isTest) ? 'masked' : undefined
40 // After the runner, only `&&` keeps its exit status; `||` anywhere can turn a failure into success.
41 const after = pieces.slice(first + 1).filter((_, at) => at % 2 === 0)
42 return line.includes('||') || after.some(sep => sep !== '&&') ? 'masked' : 'test'
43}
44
45// A runner's own summary in its output, for a run whose exit status was masked. Failure words win.
46export function testOutcome(output: string): 'passed' | 'failed' | undefined {
47 if (/\b[1-9]\d* (failed|failing|fail|errors?)\b|^FAIL(ED)?\b|test result: FAILED/m.test(output)) return 'failed'
48 if (/\b\d+ passed\b|\b\d+ passing\b|\b\d+ pass\b[\s\S]*\b0 fail\b|^ok\s+\S|test result: ok|^OK\b|Tests:\s+\d+ passed/m.test(output)) return 'passed'
49 return undefined
50}
51
52// The files a command line runs: the first argument after an interpreter (`python scripts/report.py`), or a
53// command that is itself a path (`./build.sh`). `python -m pkg` and `python -c "…"` name no file.
54export function ranFiles(command: string): string[] {
55 const out: string[] = []
56 for (const s of segments(command)) {
57 const program = INTERPRETERS.exec(s)
58 if (program === null) {
59 const first = s.split(/\s+/)[0] ?? ''
60 if (/[\\/]/.test(first)) out.push(first)
61 continue
62 }
63 for (const word of s.slice(program[0].length).trim().split(/\s+/)) {
64 if (word === '-m' || word === '-c' || word === '-e') break
65 if (word.startsWith('-')) continue
66 if (word !== '') out.push(word)
67 break
68 }
69 }
70 return out
71}
72
73// Docs and notes are not code: a change to them does not need a test run.
74export function isIgnored(path: string, suffixes: readonly string[]): boolean {
75 const lower = path.toLowerCase()
76 return suffixes.some(s => lower.endsWith(s.toLowerCase()))
77}
78
79// Windows paths (a drive letter or a network share) compare without case; others with it.
80function caseless(path: string): boolean {
81 return /^([A-Za-z]:|[\\/]{2})/.test(path)
82}
83
84function clean(path: string): string {
85 const unc = /^[\\/]{2}/.test(path)
86 const parts: string[] = []
87 for (const part of path.replace(/\\/g, '/').split('/')) {
88 if (part === '.' || part === '') continue
89 if (part === '..' && parts.length > 0) parts.pop()
90 else parts.push(part)
91 }
92 return (unc ? '//' : path.replace(/\\/g, '/').startsWith('/') ? '/' : '') + parts.join('/')
93}
94
95// The path relative to the project when it lies inside it, with forward slashes.
96export function shortPath(root: string, path: string): string {
97 const full = clean(/^([A-Za-z]:)?[\\/]/.test(path) ? path : `${root}/${path}`)
98 const base = clean(root)
99 const fold = (p: string) => (caseless(root) ? p.toLowerCase() : p)
100 return fold(full).startsWith(`${fold(base)}/`) ? full.slice(base.length + 1) : full
101}
102
103export function sameFile(root: string, a: string, b: string): boolean {
104 const x = shortPath(root, a)
105 const y = shortPath(root, b)
106 return caseless(root) ? x.toLowerCase() === y.toLowerCase() : x === y
107}
108
109export function ago(ms: number): string {
110 const s = Math.max(0, Math.round(ms / 1000))
111 return s < 60 ? `${s} s ago` : s < 3600 ? `${Math.round(s / 60)} min ago` : `${Math.round(s / 3600)} h ago`
112}
113
114function files(names: readonly string[], max = 3): string {
115 return names.slice(0, max).join(', ') + (names.length > max ? ` and ${names.length - max} more` : '')
116}
117
118// The band's line; undefined when nothing has happened yet.
119export function describe(gate: Gate, now: number): string | undefined {
120 if (gate.lastTest === null && gate.unchecked.length === 0) return undefined
121 const test =
122 gate.lastTest === null ? 'no test run yet' : `tests ${gate.lastTest.passed ? '✔ passed' : '✘ failed'} ${ago(now - gate.lastTest.at)}`
123 const changed = gate.unchecked.length === 0 ? '' : ` · ${gate.unchecked.length} file${gate.unchecked.length === 1 ? '' : 's'} changed since`
124 const warned = gate.warnings === 0 ? '' : ` · warned ${gate.warnings}×`
125 return `done-gate ▸ ${test}${changed}${warned}`
126}
127
128// What Claude reads after it marks a task done while code is unchecked. A note, never a refusal.
129export function warning(gate: Gate, task: string): string {
130 const since = gate.lastTest === null ? 'and no test has run in this session' : gate.lastTest.passed ? 'since the last passing test run' : 'and the last test run failed'
131 return (
132 `done-gate: "${task}" was marked done, but ${gate.unchecked.length} code file${gate.unchecked.length === 1 ? ' was' : 's were'} changed ${since}: ` +
133 `${files(gate.unchecked)}. Before you report this task as finished, run the tests that cover these files, ` +
134 `or tell the user plainly that they were not tested and why.`
135 )
136}
137types/index.d.ts 16 lines1export type Gate = {
2 // The last test command that finished in this session.
3 lastTest: { passed: boolean; at: number; command: string } | null
4 // Code files Claude changed since the last passing test run, relative to the project.
5 unchecked: string[]
6 // How many times a task was marked done while code was unchecked.
7 warnings: number
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 // shown: the band switched on or off with /done-gate on|off this session; null until then (the band option decides).
13 'done-gate': { gate: Gate; shown: boolean | null }
14 }
15}
16