After a turn that edited files or ran commands, Haiku lists the choices Claude made that you did not state. Mark each right or wrong, add a note, and send the…

Eight 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.
Each mod has its own on/off switch, /<mod> on, /<mod> off and /<mod> status, kept across sessions. secret-guard starts on; the other seven start off, so installing one changes nothing until you turn it on. See Switching a mod on and off.
| 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; /secret-guard off / on |
| 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 / off / status; /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 / off; /done-gate status 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 on / off / status; the button |
| idea-shelf | Park an idea while Claude works, without interrupting it: /idea try the bulk endpoint later keeps it on this project's shelf, across sessions. The band counts them (ideas ▸ 3 ideas parked [Shelf]); the shelf lists them with Send, Edit and Delete. Send puts the idea in the prompt box, or sends it when the box is empty and Claude is idle. A parked idea never reaches the model until you send it. | /idea <text>; /idea opens the shelf; /idea-shelf on / off / status |
| session-monitor | See your other Claude Code sessions from this one: other sessions ▸ 1 waiting · 2 working · 1 done [List], waiting first, in the colour for "needs you". Sessions in this same folder get their own line: also here ▸ "Fix the export" · working. Waiting means a permission dialog or a question is open. | /session-monitor on / off / status; /session-monitor list opens the list |
| prompt-enhancer | An Enhance button above the prompt: Haiku rewrites your draft so it names the skills that fit, lists what Claude should ask you first, and says what done looks like. The rewrite replaces the draft; nothing is sent until you press Enter or Send. | /prompt-enhancer on / off / status; the buttons |
| assumption-check | After a turn that edited files or ran commands, Haiku lists the choices Claude made that you did not state (assumptions ▸ 6 to check [Review]): a date format, a default, a library. Mark each Right or Wrong, add a note, and Send corrections puts one prompt in the box that says what to change and what to keep. | /assumption-check on / off / status; /assumption-check review opens the list |
Tested on Claude Code 2.1.296 on Windows 11 (secret-guard, plan-meter, done-gate and context-meter first on 2.1.291 to 2.1.295). 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
claude plugin install idea-shelf@chien-mods
claude plugin install session-monitor@chien-mods
claude plugin install prompt-enhancer@chien-mods
claude plugin install assumption-check@chien-mods
Start a new session afterwards. To remove one: claude plugin uninstall plan-meter@chien-mods.
Every mod answers /<mod> on, /<mod> off and /<mod> status (a bare /<mod> is status). The switch is kept in the mod's own store, so it holds in every later session, in every folder, until you change it.
secret-guard OFF: secrets not hidden, and its note to Claude and the hiding both stop./plan-meter on, /idea-shelf on, and so on.claude plugin disable <mod>@chien-mods still unloads a mod entirely, and each one's tests include one that feeds every hooked event to the mod while it is off and finds each one passed on unchanged.Up to version 0.2 of plan-meter, done-gate and context-meter, on and off only showed or hid the band for the session; now they switch the whole mod, and the choice stays.
Every option has a default, so all eight mods work without any. plan-meter, done-gate and context-meter share one: band, on (default) or off. With off, a mod that is switched on keeps working (the plan is read, done-gate still tells Claude, context-meter still measures) but draws no band. 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.
idea-shelf, session-monitor, prompt-enhancer and assumption-check have no options.
Each paragraph ends with the mod's calls: line from claude plugin validate: every call it makes into Claude Code. Costs are what the author's checks measured on Claude Code 2.1.296 with Haiku 5.5, at Anthropic's list prices; on a Claude subscription the same calls count against your usage instead.
idea-shelf. /idea <text> runs at once, even while Claude works, and does not interrupt the turn. The shelf is one list per project (the session's root folder), at most 200 ideas of 2,000 characters, in the mod's store on your machine. It sends nothing to the model. The line /idea <text> leaves in the conversation would carry the idea to the model, so idea-shelf rewrites that line before it is stored: the model and the transcript read /idea (an idea, kept on the shelf); the shelf keeps the text. In the author's check, a word typed after /idea was absent from the messages the model reads before and after. An idea reaches the model only when you send it, and then it arrives as a prompt "from the idea-shelf plugin". The shelf's text box is in its pane, so it draws in the terminal and the desktop app; elsewhere /idea <text> still parks. Cost: none. calls: $.clock.now, $.command.register, $.prompt.fill, $.prompt.read, $.prompt.submit, $.session.root, $.state.get, $.state.set, $.store.get, $.store.set, $.ui.open, $.ui.resolve
session-monitor. Each session that has it on writes one small file, ~/.cache/claude-mods/session-monitor/<session id>.json: the session id, its folder, its state (working, waiting or done), when that state began, when the file was written, and a title, which is the title Claude Code gave the session, else the folder's name. No prompt or command text. The file is rewritten at every change of state and every 15 seconds, and each session reads the others' files on the same beat; that is the mod's only reach beyond its own session. Working runs from a prompt to the end of the turn; waiting, while a permission dialog is open or Claude's question waits for you (in auto mode the classifier answers most permission checks, so waiting is brief there); done, otherwise. A closing session marks its file ended; a file not rewritten for 90 seconds (a killed session) is skipped. Mods cannot delete files, so the files stay, one per session, about 240 bytes each: delete the folder whenever you like. It sends nothing to the model. Cost: none. calls: $.clock.every, $.clock.now, $.command.register, $.env.get (HOME, USERPROFILE), $.fs.list, $.fs.read, $.fs.write, $.session.id, $.session.root, $.state.get, $.state.set, $.store.get, $.store.set, $.ui.open, $.ui.resolve
prompt-enhancer. Nothing happens until you press Enhance (e after Ctrl+X, Tab gives the band the keyboard). Then it sends Haiku one request holding your draft, the name and the first 100 characters of the description of each skill and command your session has (built-in commands left out), and the first 40 lines of the project's CLAUDE.md. It never reads .env files. It sends nothing when the draft is empty, longer than 4,000 characters, or holds secret-guard's ‹hidden: …› placeholder or a value shaped like a key (a private key, sk-…, AKIA…, ghp_…, xox?-…, a JWT); the band says why. A rewrite arrives in a few seconds and replaces the draft. If you edited the draft meanwhile, your edit wins and the rewrite is dropped. After 20 seconds it gives up and the draft stays. It never submits: Enter or Send (n) does. A command cannot read the prompt box, so Enhance is a button only. Cost: one press with about 150 skills and commands in the session took 6,615 input and 661 output tokens, about $0.001; the band and /prompt-enhancer status show each press's tokens. calls: $.command.list, $.command.register, $.fs.read (CLAUDE.md), $.model.complete, $.prompt.fill, $.prompt.read, $.prompt.submit, $.session.root, $.state.get, $.state.set, $.store.get, $.store.set, $.ui.resolve
assumption-check. It counts the edits and commands of each turn. When a turn that edited a file or ran a command ends (not one that only read, nor one you interrupted), it sends Haiku one request: that turn's messages, from your last prompt on, each tool call's input cut to 1,500 characters, the whole at most 30,000. These are messages the model already read, after secret-guard (if on) hid what it hides. It asks for at most 8 assumptions as JSON, and asks once more if the answer does not parse. The check starts after the turn has ended, so the turn never waits for it. The list lives for the session only. Send corrections puts a prompt in the box ("You assumed: … That is wrong: <your note>", and the ones you marked right, to keep); pressed again, it sends that prompt, but never while Claude is working. In the author's check, Claude wrote a date parser that read 3/4/2026 day first; the list named that choice first; marked wrong with the note "our users are in the US", the correction changed the two date formats and nothing else. It lists what it finds; it does not prove the list complete. Cost: a median of 1,176 input and 1,339 output tokens per checked turn over 13 checks, about $0.0008; /assumption-check status shows the last one. calls: $.clock.after, $.command.register, $.model.complete, $.prompt.fill, $.prompt.read, $.prompt.submit, $.session.messages, $.state.get, $.state.set, $.store.get, $.store.set, $.ui.open, $.ui.resolve
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
hooks/register.tsx 237 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Assumption, Item } from '../types'
5import { ASK, CHANGING, RETRY, TIMEOUT_MS, correctionPrompt, costLine, lastTurn, parseAssumptions } from './assume'
6import { STORE_KEY, storedSwitch, switchText, switchWord } from './toggle'
7
8const PANE = 'assumption-check'
9// The switch as this session read it (toggle.ts). Off, every hook passes its event on unchanged and no turn is
10// checked. The mod starts off; `/assumption-check on` turns it on for every session.
11const enabled = atom({ plugin: 'assumption-check', key: 'enabled' } as const, false)
12const items = atom({ plugin: 'assumption-check', key: 'items' } as const, [] as Item[])
13const checking = atom({ plugin: 'assumption-check', key: 'checking' } as const, false)
14const notice = atom({ plugin: 'assumption-check', key: 'notice' } as const, null as string | null)
15const lastCost = atom({ plugin: 'assumption-check', key: 'lastCost' } as const, null as string | null)
16const isFilled = atom({ plugin: 'assumption-check', key: 'isFilled' } as const, false)
17const round = atom({ plugin: 'assumption-check', key: 'round' } as const, 0)
18
19// This turn as the hooks saw it. `changed`: calls of the main loop that edited a file or ran a command.
20// `isBusy`: a turn of the main loop runs, so nothing is submitted. `notes`: the review's notes by item, kept
21// here rather than in the state so that typing does not redraw the box it types into.
22type Session = { changed: number; isBusy: boolean; notes: string[] }
23
24// The check: one Haiku call over the last turn, and one retry if the answer is not the JSON asked for. The list
25// is the newest checked turn's alone, so the previous one is cleared first.
26async function collect($: any, s: Session): Promise<void> {
27 await update($, checking, () => true)
28 await update($, notice, () => null)
29 await update($, items, () => [])
30 await update($, isFilled, () => false)
31 s.notes = []
32 try {
33 const turn = lastTurn(await $.session.messages())
34 const used = { input_tokens: 0, output_tokens: 0 }
35 let found: Assumption[] | undefined
36 let calls = 0
37 let failed: string | null = null
38 for (const ask of [ASK, `${ASK}\n\n${RETRY}`]) {
39 calls++
40 const answer = await $.model.complete({ model: 'haiku', prompt: `${turn}\n\n---\n${ask}`, maxTokens: 1500, timeoutMs: TIMEOUT_MS })
41 if (!answer.isAnswered) {
42 failed = answer.reason === 'aborted' ? `no answer within ${TIMEOUT_MS / 1000} s` : `the model call failed (${answer.reason})`
43 break
44 }
45 used.input_tokens += answer.usage.input_tokens
46 used.output_tokens += answer.usage.output_tokens
47 found = parseAssumptions(answer.text)
48 if (found !== undefined) break
49 }
50 await update($, lastCost, () => costLine(used, calls))
51 if (found === undefined) {
52 await update($, notice, () => failed ?? "could not read Haiku's answer as a list")
53 return
54 }
55 const list = found
56 await update($, items, () => list.map(a => ({ ...a, mark: null, note: '' })))
57 // A new list gets new keys, so that no note box keeps the text typed into the previous list's.
58 await update($, round, r => r + 1)
59 await update($, isFilled, () => false)
60 } finally {
61 await update($, checking, () => false)
62 }
63}
64
65async function openReview($: any): Promise<void> {
66 // Panes draw only in the terminal and the desktop app; elsewhere the command's line is the answer.
67 await $.ui.open({ id: PANE, title: 'Assumptions', focus: true, closeOnEscape: true }).catch(() => undefined)
68}
69
70async function mark($: any, n: number, value: 'right' | 'wrong'): Promise<void> {
71 await update($, items, list => list.map((i, k) => (k === n ? { ...i, mark: i.mark === value ? null : value } : i)))
72}
73
74function noteOn(s: Session, n: number, text: string): void {
75 s.notes[n] = text
76}
77
78// First press: the corrections go into the prompt box, after any draft there. Second press: they are submitted,
79// but never while Claude works (then Enter in the box is the author's to press).
80async function sendCorrections($: any, s: Session): Promise<void> {
81 if (await read($, isFilled)) {
82 if (s.isBusy) {
83 await update($, notice, () => 'Claude is working: press Enter in the prompt box when it is done')
84 return
85 }
86 const box = (await $.prompt.read()).text
87 if (box.trim() === '') {
88 await update($, notice, () => 'the prompt box is empty: nothing to send')
89 return
90 }
91 await $.prompt.fill({ text: '', mode: 'replace' })
92 await $.prompt.submit({ text: box, asUser: true })
93 await update($, items, () => [])
94 await update($, isFilled, () => false)
95 await update($, notice, () => 'corrections sent')
96 return
97 }
98 const prompt = correctionPrompt((await read($, items)).map((i, k) => ({ ...i, note: s.notes[k] ?? '' })))
99 if (prompt === undefined) {
100 await update($, notice, () => 'mark at least one assumption Wrong first')
101 return
102 }
103 const box = (await $.prompt.read()).text
104 const filled = await $.prompt.fill({ text: box.trim() === '' ? prompt : `${box.replace(/\s+$/, '')}\n\n${prompt}`, mode: 'replace' })
105 await update($, isFilled, () => filled.isFilled)
106 await update($, notice, () => (filled.isFilled ? 'the corrections are in the prompt box: press Enter there, or Send here' : 'the prompt box could not take them now'))
107}
108
109export const register: Register = on => {
110 const s: Session = { changed: 0, isBusy: false, notes: [] }
111
112 on('session.start', async ($, e, next) => {
113 // Registered even when the mod is off, so that it can be turned on.
114 await $.command
115 .register({ name: 'assumption-check', description: "Switch the assumption check on or off, show the last check's cost, or open the review", argumentHint: '[on|off|status|review]', immediate: true })
116 .catch(() => undefined)
117 const isOn = storedSwitch(await $.store.get(STORE_KEY).catch(() => undefined), false)
118 await update($, enabled, () => isOn)
119 return next(e)
120 })
121
122 on('command.run', { command: 'assumption-check' }, async ($, e) => {
123 const isReview = (e.args ?? '').trim().toLowerCase() === 'review'
124 const word = isReview ? 'status' : switchWord(e.args)
125 if (word === undefined) return { text: 'use /assumption-check on, off, status or review' }
126 if (word === 'status') {
127 if (!(await read($, enabled))) return { text: switchText('assumption-check', false) }
128 if (isReview) await openReview($)
129 const n = (await read($, items)).length
130 const cost = await read($, lastCost)
131 return { text: `${switchText('assumption-check', true)} · ${n} to check${cost === null ? ' · no turn checked yet' : ` · last check: ${cost}`}` }
132 }
133 const isOn = word === 'on'
134 await $.store.set(STORE_KEY, isOn)
135 await update($, enabled, () => isOn)
136 return { text: switchText('assumption-check', isOn) }
137 })
138
139 on('turn.start', async ($, e, next) => {
140 if (!(await read($, enabled))) return next(e)
141 s.changed = 0
142 s.isBusy = true
143 await update($, notice, () => null)
144 return next(e)
145 })
146
147 // An observer: counts the main loop's calls that changed something; the result goes back as it came.
148 on('tool.call', async ($, e, next) => {
149 if (!(await read($, enabled))) return next(e)
150 const ran = await next(e)
151 if (e.agentId === undefined && CHANGING.includes(String(e.tool)) && ran.deny === undefined) s.changed++
152 return ran
153 }).catch(($, e, next) => next(e))
154
155 // At most one check per turn, and none for a turn that only read or was interrupted. It runs after the turn
156 // has ended, so the turn never waits for it.
157 on('turn.complete', async ($, e, next) => {
158 if (!(await read($, enabled)) || e.agentId !== undefined) return next(e)
159 const done = await next(e)
160 s.isBusy = false
161 const isChecked = s.changed > 0 && !e.isAborted
162 s.changed = 0
163 if (isChecked) $.clock.after(0, () => void collect($, s).catch(() => undefined))
164 return done
165 }).catch(($, e, next) => next(e))
166
167 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
168 const below = await next(e)
169 if (!(await read($, enabled)) || e.props.hasSurvey) return below
170 const list = await read($, items)
171 const busy = await read($, checking)
172 const said = await read($, notice)
173 if (!busy && list.length === 0 && said === null) return below
174 const { Box, Button, Text } = $.ui.resolve(e)
175 const open = list.filter(i => i.mark === null).length
176 return (
177 <Box flexDirection="column">
178 <Box>
179 <Text dimColor wrap="truncate">
180 {busy ? 'assumptions ▸ checking the last turn… ' : list.length > 0 ? `assumptions ▸ ${open} to check ` : `assumptions ▸ ${said} `}
181 </Text>
182 {list.length > 0 && <Button key="review" label="Review" hotkey="a" onPress={() => openReview($)} />}
183 </Box>
184 {below}
185 </Box>
186 )
187 })
188
189 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
190 const ui = $.ui.resolve(e)
191 const { Box, Button, Text } = ui
192 // Mobile draws no text field: there the review marks and sends, without notes.
193 const Input = 'Input' in ui ? ui.Input : undefined
194 if (!(await read($, enabled))) return <Text dimColor>{switchText('assumption-check', false)}</Text>
195 const list = await read($, items)
196 const said = await read($, notice)
197 const filled = await read($, isFilled)
198 const r = await read($, round)
199 return (
200 <Box flexDirection="column">
201 <Text bold>{`Assumptions in the last turn · ${list.length}`}</Text>
202 {/* At the top, so that a list taller than the pane never hides it. */}
203 {list.length > 0 && (
204 <Box>
205 <Button key="send" label={filled ? 'Send' : 'Send corrections'} onPress={() => sendCorrections($, s)} />
206 {said !== null && <Text dimColor wrap="truncate">{` ${said}`}</Text>}
207 </Box>
208 )}
209 {list.length === 0 && said !== null && <Text dimColor>{said}</Text>}
210 {list.length === 0 && <Text dimColor>Nothing to check. After a turn that edits files or runs commands, the list appears here.</Text>}
211 {list.map((item, n) => (
212 <Box key={`item-${r}-${n}`} flexDirection="column">
213 <Text {...(item.mark === 'wrong' ? { color: 'error' } : item.mark === 'right' ? { color: 'success' } : {})}>
214 {`${n + 1}. ${item.claim}${item.mark !== null ? ` [${item.mark}]` : ''}`}
215 </Text>
216 {(item.where !== '' || item.why !== '') && <Text dimColor wrap="truncate">{` ${[item.where, item.why].filter(Boolean).join(' · ')}`}</Text>}
217 <Box>
218 <Button key={`right-${r}-${n}`} label="Right" onPress={() => mark($, n, 'right')} />
219 <Button key={`wrong-${r}-${n}`} label="Wrong" onPress={() => mark($, n, 'wrong')} />
220 {Input !== undefined && (
221 <Input
222 key={`note-${r}-${n}`}
223 label=" Note: "
224 placeholder="what it should be"
225 submitLabel="keep"
226 onInput={(value: string) => noteOn(s, n, value)}
227 onSubmit={(value: string) => noteOn(s, n, value)}
228 />
229 )}
230 </Box>
231 </Box>
232 ))}
233 </Box>
234 )
235 })
236}
237hooks/assume.ts 75 lines1// assumption-check's pure parts: which turns are checked, what Haiku reads and is asked, how its answer is read,
2// and the prompt the author's corrections become.
3import type { Assumption, Item } from '../types'
4
5// A turn that changed something: it edited a file or ran a command. A turn that only read is not checked.
6export const CHANGING: readonly string[] = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'PowerShell']
7export const MAX_ITEMS = 8
8export const TIMEOUT_MS = 60_000
9const INPUT_CHARS = 1500
10const TURN_CHARS = 30_000
11
12export const ASK =
13 'List the assumptions made in the last turn that the user did not state: choices of format, library, behaviour, naming, defaults, error handling, time zone or scope. Answer with JSON only, no prose: [{"claim": "...", "where": "file or command", "why": "why it was chosen"}], at most 8, most consequential first; [] if there are none.'
14export const RETRY = 'Your last answer was not valid JSON. Answer again with the JSON array only.'
15
16type Message = { role: string; text: string; toolUses?: readonly { tool: string; input: unknown }[] }
17
18// The last turn as text: from the author's last prompt to the end, each tool call with its input cut short.
19// A long turn keeps its end, where the answer and the last edits are.
20export function lastTurn(messages: readonly Message[]): string {
21 let start = messages.length - 1
22 while (start > 0 && !(messages[start]!.role === 'user' && messages[start]!.text !== '')) start--
23 const text = messages
24 .slice(Math.max(0, start))
25 .map(m => {
26 const uses = (m.toolUses ?? []).map(u => `[${u.tool}] ${JSON.stringify(u.input).slice(0, INPUT_CHARS)}`).join('\n')
27 return `${m.role.toUpperCase()}: ${m.text}${uses ? `\n${uses}` : ''}`
28 })
29 .join('\n\n')
30 return text.length > TURN_CHARS ? `…${text.slice(-TURN_CHARS)}` : text
31}
32
33// Haiku's answer as a list, or undefined when it is not the JSON asked for. Prose or a code fence around the
34// array is tolerated; items without a claim are dropped; at most 8 are kept.
35export function parseAssumptions(reply: string): Assumption[] | undefined {
36 const from = reply.indexOf('[')
37 const to = reply.lastIndexOf(']')
38 if (from < 0 || to < from) return undefined
39 let value: unknown
40 try {
41 value = JSON.parse(reply.slice(from, to + 1))
42 } catch {
43 return undefined
44 }
45 if (!Array.isArray(value)) return undefined
46 const text = (v: unknown) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim() : '')
47 return value
48 .filter((v): v is Record<string, unknown> => v !== null && typeof v === 'object' && text((v as any).claim) !== '')
49 .slice(0, MAX_ITEMS)
50 .map(v => ({ claim: text(v.claim), where: text(v.where), why: text(v.why) }))
51}
52
53// The prompt the review becomes: the wrong ones with the author's notes, to be changed, and the right ones, to be
54// kept. Undefined when nothing was marked wrong.
55export function correctionPrompt(items: readonly Item[]): string | undefined {
56 const wrong = items.filter(i => i.mark === 'wrong')
57 if (wrong.length === 0) return undefined
58 const right = items.filter(i => i.mark === 'right')
59 const what = (i: Item) => `${i.claim}${i.where !== '' ? ` (${i.where})` : ''}`
60 // "You assumed" keeps the claim from reading as the request itself.
61 const fix = (i: Item, n: number) => `${n + 1}. You assumed: ${what(i)}. That is wrong${i.note.trim() !== '' ? `: ${i.note.trim()}` : '.'}`
62 const parts = [`Some choices in your last turn were wrong. Change only these:\n${wrong.map(fix).join('\n')}`]
63 if (right.length > 0) parts.push(`These were right; keep them as they are:\n${right.map((i, n) => `${n + 1}. ${what(i)}`).join('\n')}`)
64 parts.push('Leave everything else unchanged.')
65 return parts.join('\n\n')
66}
67
68export type Usage = { input_tokens: number; output_tokens: number }
69
70const thousands = (n: number) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
71
72export function costLine(usage: Usage, calls: number): string {
73 return `${thousands(usage.input_tokens)} in · ${thousands(usage.output_tokens)} out tokens (haiku${calls > 1 ? `, ${calls} calls` : ''})`
74}
75hooks/toggle.ts 24 lines1// The mod's on/off switch, `/<mod> on|off|status`. The value is kept in $.store under STORE_KEY: one store per
2// plugin, shared by every session and folder, so the switch holds across restarts. A session reads it as it
3// starts and when its own command changes it; a session already open elsewhere picks a change up when it next
4// starts. Pure helpers only: `claude plugin validate` follows `$` only within the file that uses it.
5export const STORE_KEY = 'enabled'
6
7export type SwitchWord = 'on' | 'off' | 'status'
8
9// The command's argument as a switch word: '' and 'status' ask, 'on' and 'off' set; anything else is the mod's own.
10export function switchWord(args: string | undefined): SwitchWord | undefined {
11 const word = (args ?? '').trim().toLowerCase()
12 if (word === '' || word === 'status') return 'status'
13 return word === 'on' || word === 'off' ? word : undefined
14}
15
16// The stored value, or the mod's default when it was never set (or holds something else).
17export function storedSwitch(value: unknown, byDefault: boolean): boolean {
18 return typeof value === 'boolean' ? value : byDefault
19}
20
21export function switchText(mod: string, isOn: boolean): string {
22 return isOn ? `on (/${mod} off turns it off)` : `off (/${mod} on turns it on)`
23}
24types/index.d.ts 17 lines1// One guess Claude made that the author did not state: what, where, and why Claude chose it.
2export type Assumption = { claim: string; where: string; why: string }
3
4// An assumption as the review holds it: the author's mark, and the note the corrections carry.
5export type Item = Assumption & { mark: 'right' | 'wrong' | null; note: string }
6
7declare module 'claude-code' {
8 interface PluginState {
9 // enabled: the mod's on/off switch as this session read it from the store, or as /assumption-check on|off set it.
10 // items: the last checked turn's assumptions, for this session only. checking: Haiku is reading the last turn.
11 // notice: what the last action did, or why the check failed. lastCost: the tokens the last check took.
12 // isFilled: the corrections are in the prompt box, so the review's button now sends them. round: how many lists
13 // this session has had, which keys the review's elements.
14 'assumption-check': { enabled: boolean; items: Item[]; checking: boolean; notice: string | null; lastCost: string | null; isFilled: boolean; round: number }
15 }
16}
17