Guardrails, task board and spec-driven agent roles for Claude Code, enforced by hooks instead of prompts.

A Claude Code plugin that turns your working rules into things the agent cannot skip: a guardrail runs before every tool call, work lives on a task board with checklists and mandatory handoffs, and agent roles are kept separate (spec-test author, implementer, reviewer). Everything is enforced by hooks and one small binary, not by prompt text.
.env files, writes to the main tree) plus your own, per machine or per repo. A blocked call gets a one-line reason and the alternative.ratchet pdf) through the liteparse CLI, with automatic OCR retry and output kept out of the terminal./ctx opens a pane with the breakdown by category and every subagent's window, the main window's trend with a forecast of the turns left before auto-compaction, the heaviest tool results, and the held task's tokens by role. It is a function-hook module: it needs a Claude Code with function-hook modules and is tested against 2.1.288.ratchet-tasks and ratchet-pdf skills, the /opsx:* OpenSpec commands, /ratchet:init and /ratchet:map.Cost of the hot path: the guardrail hook does 11-13 ms of its own work per tool call (see Latency at the end).
claude plugin marketplace add EduardoIllanes/ratchet claude plugin install ratchet@ratchet
That is all. The first time a hook runs it downloads the prebuilt ratchet binary for your platform (macOS arm64/x64, Linux x64, Windows x64) from the release pinned in .claude-plugin/binary-version, verifies its SHA-256 against the release's SHA256SUMS.txt, and puts it in the plugin's bin/. No Rust, no PATH changes, nothing installed anywhere else.
The plugin manifest carries no version on purpose: Claude Code tracks the marketplace commit, so claude plugin update ratchet@ratchet picks up every change to agents, skills and hooks without waiting for a binary release. Each update lands in a fresh plugin dir, and the first hook there downloads the pinned binary again (a few MB). A new binary ships as a tagged release that bumps Cargo.toml and binary-version together.
Then opt a repo in. Open a Claude Code session at its root and run:
/ratchet:init
ratchet is not on your shell's PATH: the binary lives only in the plugin's bin/, and Claude Code adds that directory to the PATH of its own sessions. So every ratchet ... command in this README runs inside a session, either through the ! prefix (! ratchet task list) or by letting the agent run it. To use it from a terminal, put that bin/ on your PATH or link the binary, for example ln -s "<plugin dir>/bin/ratchet" ~/.local/bin/ratchet; the link breaks on every plugin update, because the plugin dir is named after the marketplace commit.
If the download cannot happen (offline, unsupported platform, checksum mismatch), the hook prints one line and exits 0; the session is not affected, and the hooks stay quiet for an hour before retrying. To retry now: bash <plugin dir>/hooks/bootstrap.sh. To install by hand: download the asset for your platform from https://github.com/EduardoIllanes/ratchet/releases, verify it against SHA256SUMS.txt, and unpack the single file it contains into <plugin dir>/bin/; or export RATCHET_BIN=<path to a binary you built>, which the hooks check first. The plugin dir is the installPath for ratchet@ratchet in ~/.claude/plugins/installed_plugins.json.
Real output from a throwaway repo, run from inside a Claude Code session (! prefix). Opt it in:
$ ratchet config init wrote /tmp/demo/ratchet.toml
Ask the guardrails what they would do with a tool call (the repo has a .venv; exit code 2 means blocked, which is what the hook returns to Claude Code):
$ ratchet guardrails test Bash '{"command":"python x.py"}' [ratchet guardrail:python-venv] Python must run through the repo's virtualenv, not the global interpreter. Prefix the command with uv run (e.g. uv run python scripts/x.py) or call the venv interpreter (.venv/Scripts/python or .venv/bin/python).
Create a task with its acceptance criteria and work it:
$ ratchet task new "Port the parser" -c "tests green" -c "docs updated" T-0001 Port the parser (backlog)
$ ratchet task list T-0001 backlog p3 Port the parser (0/2)
$ ratchet task check T-0001 1 T-0001 [x] 1. tests green (1/2)
$ ratchet task handoff T-0001 "parser ported; docs still pending" T-0001 handoff recorded
$ ratchet task show T-0001 T-0001 Port the parser status backlog · repo demo · priority p3 progress 1/2
last handoff (2026-09-17T13:15:38Z): parser ported; docs still pending
events: 2026-09-17T13:15:38Z task.created checklist=2 priority=3 repo=demo tags=[] title=Port the parser 2026-09-17T13:15:38Z checklist.done tests green 2026-09-17T13:15:38Z handoff parser ported; docs still pending
Inside a Claude Code session the hooks do the rest: ratchet task claim T-0001 ties the task to that session, the next session start prints the briefing with the last handoff, and closing the session without recording anything is refused once.
Run /ratchet:init from a Claude Code session at the repo root (or ratchet config init where the binary is on your PATH, see Quick install), which writes this file with comments:
[repo] default_branch = "main" worktrees_dir = ".worktrees"
[guardrails] off = []
Without that file, every hook is a no-op.
Init also appends a short block to the repo's CLAUDE.md saying when to dispatch the reader and researcher agents. It is written once (skipped when a <!-- ratchet agents: line is already there) and is yours to edit; a CLAUDE.md that is not a regular file is left alone.
Built-in: python-venv, git-destructive, env-files, main-tree, big-read. Disable per repo with [guardrails] off = ["id"]. Add your own with the same schema, machine-wide in ~/.ratchet/config.toml → [guardrails] extra = "guardrails.toml", or per repo in ratchet.toml → [guardrails] extra = "ratchet/guardrails.toml". A rule with an existing id replaces it. Example of a custom content rule that keeps a database read-only (use the write-method names of your own driver in the pattern):
[[rules]] id = "db-readonly" tools = ["Bash", "PowerShell", "Edit", "Write", "NotebookEdit", "MultiEdit"] kind = "content" pattern = '\.(write_rows|purge_all)\s*\(' message = "The database is read-only for agents." alternative = "Read through the data layer; if a write is really needed, the owner does it by hand."
A one-off command rule can also live right in ratchet.toml, without a separate file — match is a regex over each command segment, and the message must itself state the alternative (say "use" or "instead") or the file is refused on load:
[[guardrails.rules]] name = "no-curl" match = '^\s*curl\b' message = "Use the repo's fetch script instead."
Inline rules are always command-only, so name must not equal a built-in id (python-venv, git-destructive, env-files, main-tree, big-read) — that would silently replace a non-command built-in with one that can never match. Reusing an id, or giving an explicit empty tools = [], is refused on load naming the rule and the alternative (rename it, or disable the built-in with off = [...]).
Known behaviour, by design: command rules split on ; only outside quotes, so a quoted "done; mypy clean" does not trip python-venv; but a content rule scans what will be written, so quoting a blocked pattern in documentation blocks that write too.
big-read keeps a whole big file out of the orchestrator's own context: a Read with no offset/limit, or a bare cat/head/tail/less/more, over a regular file of more than [guardrails] big_read_lines lines (default 350) is blocked, naming three ways out — Read with a window, grep for the lines wanted, or the reader agent (haiku, low effort) with the file and a question. It exempts a file under the repo's worktrees directory (that is where implementers read whole files on purpose), a file that does not exist, and a piped command (cat big.rs | grep fn already filters before anything reaches the transcript). Override the threshold per repo:
[guardrails] big_read_lines = 800
Work lives in tasks, and a task moves only in ways you can check afterwards.
ratchet task list # the board of this repo, one line per task ratchet task list --mine --status in_progress ratchet task show T-0042 # body, checklist, last handoff, last ten events ratchet task new "Port the parser" -c "tests green" -c "docs updated" ratchet task claim T-0042 # one call: takes it, already in_progress ratchet task check T-0042 1 2 # one or more criteria met, in order, in one call ratchet task note T-0042 "found X" "decided Y" # one or more notes, in one call ratchet task handoff T-0042 "what is left and how to resume" --status review ratchet task archive T-0042 # a reviewed done task leaves the board
claim puts a task in in_progress in the same call (through ready first if it was in backlog). check and note each take one or more values and apply them in order, one event per value; a bad checklist number in a check call refuses the whole call before marking anything. handoff's --status <state> applies the same transition ratchet task status would — including --why and the owner's --unreviewed — after recording the handoff; a refused transition still leaves the handoff recorded.
Identifiers are T-0001, T-0002, … from a sequence that never reuses a number. A task carries a title, a body, a priority from 1 to 4, tags, an optional parent, and the checklist that is its acceptance criteria. Progress is derived: it is items done / items total, computed on every read. Nothing anywhere accepts "about 60 % done", and a task with no checklist reports no progress at all — which is also why closing one as done then needs an explicit --why.
Statuses are backlog → ready → in_progress → blocked → review → done, plus "back to the queue" (in_progress, blocked or review → ready) and "back to the start" (anything → backlog). A refused move lists the ones that were allowed. Every change appends one event per fact — created, claimed, status, checklist, note, handoff, archived — and events are never edited or deleted.
Claiming ties a task to your session. A task held by a session that is still alive is not taken from it; one held by a session that died is, with a note recording the transfer. Sessions that die give their work back on their own (see State and sessions).
Three things the harness does with the board, without being asked:
AGENTS.md at its root, a second line rules: AGENTS.md points at it — the briefing never restates what is in it.[ratchet] T-0042 in_progress (2/5) · last handoff: "…".ratchet task handoff …. It never blocks twice, and never blocks a headless run, where nobody could answer.Any command that would print more than 60 lines writes them to ~/.ratchet/out/<timestamp>-<name>.txt instead and prints the first 20 plus that path. --json gives the machine-readable form of any board, session or db command; --session <id> attributes a write explicitly and is accepted anywhere on the line, but it can never name a subagent (a value with / is refused) — a board write made from inside a subagent's own Bash call is attributed to that subagent automatically, by matching the call against the task id and subcommand, never by a flag.
Everything ratchet remembers lives in one SQLite file: ~/.ratchet/ratchet.db (override the directory with RATCHET_HOME). SQLite is compiled into the binary — nothing to install — and the schema is applied by embedded migrations. The session-start hook migrates automatically; everywhere else, an out-of-date database says so and asks for ratchet db migrate.
ratchet db path # where the database is ratchet db migrate # apply pending migrations ratchet session list # one line per session: id, state, repo, branch, tasks, last signal ratchet session list --live ratchet session show # the session covering this directory (or name one)
A session is registered by the hooks themselves, with the identifier Claude Code gives them — ratchet never invents one. It records the repo, the directory, the worktree and branch when there are any, the mode (interactive or headless), who launched it (user or platform), and its signals. State is derived, never stored: live while the last signal is under live_minutes, idle until idle_minutes, orphaned after that, ended once the session closed. Both thresholds come from [thresholds] in the repo's ratchet.toml (10 and 60 by default).
When a session dies, its work goes back: a task in_progress held by an ended or orphaned session returns to ready without a session, at the end of that session and, for sessions that died without a hook, at the next session start in that repo. The task keeps its whole history.
Two environment variables let a launcher place a headless session in the registry: RATCHET_SESSION_ID (the identity, also written to the session's shell through CLAUDE_ENV_FILE), RATCHET_SESSION_MODE and RATCHET_LAUNCHED_BY. RATCHET_NOW (RFC 3339) replaces the clock for one process and exists so the time-dependent behaviour above can be tested without sleeping.
The PreToolUse guardrail hook still opens no database at all: it is the hot path. Its own work is the ~11-13 ms measured above; the wall-clock ceiling it is tested against fails on this machine for the process-launch reasons given under Latency.
ratchet pdf <file> [--pages "1-8,12"] [--ocr] extracts text from a local PDF via the external liteparse CLI (npm i -g @llamaindex/liteparse) — no network code, no approval list, nothing web-related. The fast pass runs first; text under the configured minimum retries once with OCR automatically; --ocr forces OCR from the start. The extracted text is written to ~/.ratchet/out/pdf/<stem>[-p<pages>][-ocr].txt; the terminal prints only a short header (pages, whether OCR was used, characters extracted, the sink path) and never the body. Configure the extractor and its limits in ~/.ratchet/config.toml:
[pdf] extractor = "liteparse" timeout_s = 60 ocr_timeout_s = 600 ocr_min_chars = 200 ocr_language = "eng" max_file_bytes = 209715200
The full audited web-fetch flow (approval lists, robots.txt, cache, forms) is not here and is not planned — it stays in ops, where it already runs daily (owner decision, 2026-09-16; see docs/superpowers/specs/2026-09-16-ratchet-plugin-design.md §2 D-pdf, §9).
Eight agent profiles in agents/: analyst, spec-test-author, implementer, reviewer, refactorer, researcher, mapper, reader — see AGENTS.md for how they are meant to be combined. Two skills: ratchet-tasks (working the board, writing handoffs) and ratchet-pdf (extracting text from a local PDF). OpenSpec work is the six /opsx:* commands (propose, apply, update, sync, archive, explore) in commands/opsx/; they need the openspec CLI installed separately. Ratchet used to also ship the same six workflows a second time as openspec-* skills — that duplicate set is gone (T-0011); the /opsx:* commands carry every instruction the skills had. The standalone openspec plugin ships this same command set under its own name — do not install it alongside ratchet, it only doubles the listing every agent pays for on every turn.
ratchet map derives .ratchet/map.md — the repo's layout, gate commands, and one sentence per source file — from git ls-files, file headers and manifests, deterministically, with no model involved. /ratchet:map runs it and offers to wire it into CLAUDE.md (--wire, run once, never automatic); /ratchet:map --deep describes header-less files by dispatching the mapper agent (haiku), which only ever records a sentence through ratchet map note — it edits no file directly. ratchet map status prints the map's freshness (map: current, map: N commits behind, or map: from another branch); the session-start briefing shows the same line when the map is not current, dropped first if the briefing's own 40-line cap is tight. A map never grows past 150 lines — over the cap, the deepest directories collapse into one summary line each. .ratchet/map.md and .ratchet/map.notes are local and untracked; --wire is what adds .ratchet/ to .gitignore, not ratchet map on its own. Configure [map] exclude and [map] gate in ratchet.toml when the defaults don't fit a repo.
ratchet usage reports token cost per task, role and model, read straight from the transcripts Claude Code already writes under ~/.claude/projects (or RATCHET_CLAUDE_PROJECTS) and joined with the sessions/task events ratchet already records — no extra tracking, nothing to opt into. Plain ratchet usage lists every task touched in the current repo's window, one line each (status, review rounds, abbreviated tokens, title, cost when weights are configured); a trailing skipped N partial N version X line appears only when the scan actually met something it couldn't fully read.
ratchet usage <id> shows one task in detail: tokens per role/model, orientation (the orchestrator's cost before the first claim), tokens per review round, orchestrator share, cache efficiency, and cost.ratchet usage --by task|role|model|session aggregates across the whole window instead of listing tasks; --by role also adds review rounds per task and orientation per session.--since 7d|30d|<RFC 3339 date> widens or narrows the window (default 7d); --all-repos drops the repo filter and reports across every repo ratchet knows about; --json prints the same data as JSON (raw, unabbreviated numbers) instead of the terminal text.Cost is only ever shown when every bucket contributing to a number matched a configured weight. Configure weights in the machine config (~/.ratchet/config.toml), keyed by model name prefix and matched longest-prefix-first, so a more specific prefix overrides a shorter one:
[usage.weights] "claude-sonnet" = { input = 3.0, cache_write = 3.75, cache_read = 0.3, output = 15.0 } "claude-sonnet-5" = { input = 2.5, cache_write = 3.0, cache_read = 0.25, output = 12.0 }
ratchet usage <id> --note is the command's only write: it appends a task note starting with usage: that holds the same one-task summary paragraph the <id> detail view is built from (total tokens, rounds, orientation, cost), through the same services::tasks::note write ratchet task note uses — attributed to the same session, refused with the identical message on a task that doesn't exist. Without --note, ratchet usage never writes anything.
With a Rust toolchain (1.79 or newer; on macOS also the Xcode Command Line Tools, on Windows the Visual Studio Build Tools, both for the bundled SQLite):
cargo build --release cp target/release/ratchet <plugin dir>/bin/ # ratchet.exe on Windows
Measured pre-tool cost on Windows 11 (release build): about 11-13 ms of ratchet's own work above process-launch cost (full pre-tool runs ~53-56 ms in a direct harness against a ~40 ms do-nothing-binary floor on that machine). The check this replaces, in a Python harness, cost ~830 ms. cargo test -p ratchet --release --test latency -- --nocapture prints the figures for your machine; the 60 ms ceiling in that test is a local sanity check and fails on slow launchers, which is why CI reports it without gating on it.
Binaries are not code-signed or notarized; macOS Gatekeeper may ask once (xattr -d com.apple.quarantine bin/ratchet clears it). No Linux arm64 build. The audited web-fetch flow (approval lists, robots.txt, cache) that the original group 3 plan would have ported is deliberately not planned for ratchet — it stays in ops.
v0.2.3 — a context meter, in repos that opted in: the status line shows how full the main context window is, its move since the last turn and a small bar per running subagent; a toast fires at 70% and 85%; and /ctx opens a pane with the breakdown by category, the trend with a forecast of the turns left before auto-compaction, the heaviest tool results still in the window, the held task's tokens by role and every subagent's window. It is a function-hook module tested against Claude Code 2.1.288. Also ratchet usage no longer double counts: Claude Code writes one transcript record per content block of a response, each repeating its usage, and every record was summed (totals were roughly twice the real ones); a response now counts once, by message.id.
v0.2.2 — ratchet config init also appends to the repo's CLAUDE.md a block saying when dispatching the reader and researcher agents pays off and when it does not (idempotent, never rewrites existing content, skips a CLAUDE.md that is not a regular file). Also since v0.2.1: on Windows the hook wrapper now propagates a blocking exit code, so guardrails block there too, and reviewer.md records verdicts with a bare ratchet task review.
v0.2.1 — Windows fixes: the big-read guardrail now sees whole-file Read calls (the PreToolUse matcher was missing Read), Git Bash drive paths (/c/..., /cygdrive/c/...) resolve for big-read and main-tree, ratchet config init prints a native repo root, and CI is green on Windows.
v0.2.0 — groups 0-7 of the design spec are shipped. New since v0.1.1: ratchet map, the big-read guardrail, subagent lifecycle on the board, ratchet usage (token cost per task, role and model), task review verdicts with a done gate that requires an independent approve (attributed per subagent, proven by its transcript), batched board commands, repo guardrail rules in ratchet.toml where every block names its alternative, and an invalid or unparseable config degrading to the built-in rules instead of turning guardrails off. Design: `docs/
hooks/context-meter/register.tsx 399 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage } from 'claude-code'
3
4import type { AgentReading, TaskCost } from './types'
5import {
6 agentWindow,
7 agentsTail,
8 bar,
9 categoryColors,
10 CELESTIAL,
11 cappedPath,
12 cells,
13 compact,
14 fillColor,
15 inputTokens,
16 lastDelta,
17 pushHistory,
18 reached,
19 recordStep,
20 shortType,
21 sparkline,
22 statusLine,
23 taskCost,
24 toReading,
25 topConsumers,
26 turnsToCompact,
27} from './meter'
28
29const PANE = 'context-meter'
30
31const reading = atom({ plugin: 'ratchet', key: 'contextReading' } as const, null)
32const alerted = atom({ plugin: 'ratchet', key: 'contextAlerted' } as const, 0)
33const agents = atom({ plugin: 'ratchet', key: 'contextAgents' } as const, {})
34const history = atom({ plugin: 'ratchet', key: 'contextHistory' } as const, [])
35const task = atom({ plugin: 'ratchet', key: 'contextTask' } as const, null)
36// Whether the session opted in to ratchet; decided again on every session.start.
37const metered = atom({ plugin: 'ratchet', key: 'contextMetered' } as const, false)
38
39/** The directory and each ancestor, nearest first, splitting on both separators. */
40export function ancestors(cwd: string): string[] {
41 const parts = cwd.split(/[\\/]/)
42 const dirs: string[] = []
43 for (let n = parts.length; n >= 1; n--) {
44 const dir = parts.slice(0, n).join('/')
45 dirs.push(dir === '' ? '/' : dir)
46 }
47 return dirs
48}
49
50async function optedIn($: EngineInterface, cwd: string): Promise<boolean> {
51 for (const dir of ancestors(cwd)) {
52 try {
53 const stat = await $.fs.stat(`${dir === '/' ? '' : dir}/ratchet.toml`)
54 if (stat.kind === 'file') return true
55 } catch {
56 // A stat that rejects counts as absent.
57 }
58 }
59 return false
60}
61
62/** The plugin's own binary: `ratchet.exe` where it exists as a file (Windows), else `ratchet`. */
63async function binary($: EngineInterface): Promise<string> {
64 const exe = `${$.plugin.root}/bin/ratchet.exe`
65 try {
66 if ((await $.fs.stat(exe)).kind === 'file') return exe
67 } catch {
68 // A stat that rejects counts as absent.
69 }
70 return `${$.plugin.root}/bin/ratchet`
71}
72
73/** A command's stdout, or the whole output from the file when ratchet capped it. */
74async function fullOutput($: EngineInterface, stdout: string): Promise<string> {
75 const path = cappedPath(stdout)
76
77 return path === null ? stdout : await $.fs.read(path)
78}
79
80/**
81 * Reads what the held task has cost so far; off the hot path, on session start and /ctx only:
82 * a report past ratchet's output cap leaves a file under ~/.ratchet/out on every read.
83 */
84async function refreshTask($: EngineInterface): Promise<void> {
85 try {
86 const ratchet = await binary($)
87 const session = await $.session.id()
88 const listed = await $.process.run([ratchet, 'task', 'list', '--mine', '--json', '--session', session], {
89 timeoutMs: 15_000,
90 })
91 if (listed.exitCode !== 0) return
92 const held = (JSON.parse(await fullOutput($, listed.stdout)) as { id: string; title: string; status: string }[]).find(
93 one => one.status === 'in_progress',
94 )
95 if (held === undefined) {
96 await update($, task, () => null)
97 return
98 }
99 const used = await $.process.run([ratchet, 'usage', held.id, '--json'], { timeoutMs: 30_000 })
100 if (used.exitCode !== 0) return
101 const cost = taskCost(JSON.parse(await fullOutput($, used.stdout)), held.id)
102 // A report with nothing for the task keeps the last figures.
103 if (cost !== null) await update($, task, () => ({ id: held.id, title: held.title, ...cost }))
104 } catch {
105 // The meter never fails the session; the pane keeps the last figures.
106 }
107}
108
109async function running($: EngineInterface): Promise<AgentReading[]> {
110 const readings = await read($, agents)
111 const listed = await $.agent.list()
112
113 return listed
114 .filter(agent => agent.status === 'running')
115 .flatMap(agent => readings[agent.id] ?? [])
116}
117
118async function pin($: EngineInterface): Promise<void> {
119 const now = await read($, reading)
120 if (now === null) return
121 const turns = await read($, history)
122 // A failing agent list leaves the line without its tail.
123 const tail = await running($).then(
124 list => agentsTail(list, now.window),
125 () => '',
126 )
127 $.ui.status(statusLine(now) + lastDelta(turns) + tail)
128}
129
130async function show($: EngineInterface, context: SessionContextUsage): Promise<void> {
131 const now = toReading(context)
132
133 const level = reached(now.percent)
134 const before = await read($, alerted)
135 if (level > before) {
136 $.ui.toast(`Context at ${now.percent}%: run /compact, or write a handoff with ratchet task handoff and continue in a fresh session`, {
137 timeoutMs: 8000,
138 })
139 }
140 // A drop (compaction, /clear) re-arms the thresholds below it.
141 if (level !== before) await update($, alerted, () => level)
142
143 await update($, reading, () => now)
144 await pin($)
145}
146
147export const register: Register = on => {
148 on('session.start', async ($, e, next) => {
149 try {
150 const opted = await optedIn($, e.cwd)
151 await update($, metered, () => opted)
152 if (opted) {
153 // Without the command the line is still worth pinning.
154 await $.command
155 .register({
156 name: 'ctx',
157 description: 'Show the context window broken down by category, live, in a pane',
158 immediate: true,
159 })
160 .catch(() => undefined)
161 void refreshTask($)
162 await show($, (await $.session.usage()).context)
163 }
164 } catch {
165 // The meter never fails the session.
166 }
167
168 return next(e)
169 })
170
171 on('command.run', { command: 'ctx' }, async $ => {
172 await $.ui.open({ id: PANE, title: 'Context' })
173 void refreshTask($)
174
175 return { text: 'Context pane opened.' }
176 })
177
178 on('session.measure', async ($, e, next) => {
179 if (e.changed.includes('context') && (await read($, metered))) {
180 try {
181 // One point per turn: the trend and the status line's move read these.
182 const tokens = e.context.tokens
183 if (tokens !== undefined) await update($, history, all => pushHistory(all, tokens))
184 } catch {
185 // A failed history must not keep the line from refreshing.
186 }
187 try {
188 await show($, e.context)
189 } catch {
190 // The meter never fails the session.
191 }
192 }
193
194 return next(e)
195 })
196
197 // A /clear empties the window: what was tracked for the old one goes with it.
198 on('session.end', async ($, e, next) => {
199 if (e.reason === 'clear') {
200 try {
201 await update($, agents, () => ({}))
202 await update($, history, () => [])
203 await update($, task, () => null)
204 } catch {
205 // The meter never fails the session.
206 }
207 }
208
209 return next(e)
210 })
211
212 // Every model request, main or subagent, reports what it was answered over:
213 // the main loop's moves the status line within a turn, a subagent's is the
214 // only window onto that agent's context.
215 on('turn.step', async function* ($, e, next) {
216 const result = yield* next(e)
217 if (result.usage === null || result.usage === undefined) return result
218
219 try {
220 if (!(await read($, metered))) return result
221 const tokens = inputTokens(result.usage)
222 if (e.agentId === undefined) {
223 const { window } = (await $.session.usage()).context
224 await show($, { window, tokens, percent: Math.round((tokens / window) * 100) })
225 } else {
226 const id = e.agentId
227 const at = await $.clock.now()
228 const who = await $.agent.list().then(
229 list => list.find(agent => agent.id === id),
230 () => undefined,
231 )
232 await update($, agents, all => recordStep(all, id, tokens, result.usage?.model ?? '', at, who))
233 await pin($)
234 }
235 } catch {
236 // The meter never fails a model request.
237 }
238
239 return result
240 })
241
242 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
243 const { Box, Text } = $.ui.resolve(e)
244 try {
245 // Read to subscribe: each new reading redraws the pane.
246 await read($, reading)
247 const readings = await read($, agents)
248 const turns = await read($, history)
249 const held = await read($, task)
250 const said = await $.session.messages({ as: 'api' }).catch(() => [])
251 const consumers = topConsumers(Array.isArray(said) ? said : [], 5)
252 const context = await $.session.usage({ breakdown: 'summary' }).then(
253 usage => usage.context,
254 () => undefined,
255 )
256 const breakdown = context?.breakdown
257
258 if (context === undefined || breakdown === undefined) {
259 return <Text dimColor>No breakdown yet: it arrives with the first response.</Text>
260 }
261
262 const rows = breakdown.categories.filter(row => row.kind !== 'deferred')
263 const width = Math.max(10, e.props.bodyColumns - 2)
264 const widths = cells(
265 rows.map(row => row.tokens),
266 breakdown.rawMaxTokens,
267 width,
268 )
269 const nameWidth = Math.max(...rows.map(row => row.name.length))
270 const colors = categoryColors(rows.map(row => row.kind))
271 // The agent list drops an agent once it is done; the readings keep who it was.
272 const listed = await $.agent.list().catch(() => [])
273 const isRunning = new Set(listed.filter(agent => agent.status === 'running').map(agent => agent.id))
274 const roleCells = held === null ? [] : cells(held.roles.map(role => role.tokens), held.total, width)
275 const recent = Object.entries(readings).sort(([, a], [, b]) => b.at - a.at)
276
277 return (
278 <Box flexDirection="column">
279 <Text bold>
280 <Text color={fillColor(breakdown.percentage)}>{breakdown.percentage}%</Text> · {compact(breakdown.totalTokens)} /{' '}
281 {compact(breakdown.rawMaxTokens)}
282 <Text dimColor> {breakdown.model}</Text>
283 </Text>
284 <Box flexDirection="row">
285 {rows.map((row, i) => (
286 <Text color={colors[i]} dimColor={row.kind === 'free'}>
287 {(row.kind === 'used' ? '█' : row.kind === 'buffer' ? '▒' : '░').repeat(widths[i] ?? 0)}
288 </Text>
289 ))}
290 </Box>
291 {rows.map((row, i) => (
292 <Box flexDirection="row">
293 <Text color={colors[i]}>{row.kind === 'used' ? '■ ' : '□ '}</Text>
294 <Text dimColor={row.kind !== 'used'}>
295 {row.name.padEnd(nameWidth)} {compact(row.tokens).padStart(5)}{' '}
296 {((row.tokens / breakdown.rawMaxTokens) * 100).toFixed(1).padStart(5)}%
297 </Text>
298 </Box>
299 ))}
300 {breakdown.isAutoCompactEnabled && breakdown.autoCompactThreshold !== undefined && (
301 <Text dimColor>auto-compact at {compact(breakdown.autoCompactThreshold)}</Text>
302 )}
303 {turns.length > 1 && (
304 <Text bold color={CELESTIAL.accent}>
305 Trend
306 </Text>
307 )}
308 {turns.length > 1 && (
309 <Text>
310 <Text color={fillColor(breakdown.percentage)}>{sparkline(turns, context.window, width - lastDelta(turns).length)}</Text>
311 {lastDelta(turns)}
312 </Text>
313 )}
314 {turns.length > 1 && breakdown.autoCompactThreshold !== undefined && (
315 <Text dimColor>
316 {(() => {
317 const left = turnsToCompact(turns, breakdown.autoCompactThreshold)
318 if (left === null) return 'not growing'
319 if (left === 0) return 'at the auto-compact threshold'
320 return `~${left} turn${left === 1 ? '' : 's'} to auto-compact at this pace`
321 })()}
322 </Text>
323 )}
324 {consumers.length > 0 && (
325 <Text bold color={CELESTIAL.accent}>
326 Top consumers
327 </Text>
328 )}
329 {consumers.map((one, i) => (
330 <Box flexDirection="row">
331 <Text color={CELESTIAL.series[i % CELESTIAL.series.length]}>{one.tool.padEnd(6)} </Text>
332 <Text wrap="truncate-end">
333 {compact(one.tokens).padStart(5)} {one.label}{' '}
334 </Text>
335 </Box>
336 ))}
337 {consumers.some(one => one.tool === 'Read' && one.tokens >= 5_000) && (
338 <Text dimColor>→ reads this size can go to ratchet:reader</Text>
339 )}
340 {held !== null && held.total > 0 && (
341 <Text bold color={CELESTIAL.accent}>
342 {held.id} <Text dimColor>· {compact(held.total)} tokens so far</Text>
343 </Text>
344 )}
345 {held !== null && held.total > 0 && (
346 <Box flexDirection="row">
347 {roleCells.map((count, i) => (
348 <Text color={CELESTIAL.series[i % CELESTIAL.series.length]}>{'█'.repeat(count)}</Text>
349 ))}
350 </Box>
351 )}
352 {held !== null && held.total > 0 && (
353 <Text wrap="truncate-end">
354 {held.roles.slice(0, 4).map((role, i) => (
355 <Text color={CELESTIAL.series[i % CELESTIAL.series.length]}>
356 {i > 0 ? ' · ' : ''}
357 {shortType(role.role)} {Math.round((role.tokens / held.total) * 100)}%
358 </Text>
359 ))}
360 </Text>
361 )}
362 {recent.length > 0 && (
363 <Text bold color={CELESTIAL.accent}>
364 Subagents
365 </Text>
366 )}
367 {recent.map(([id, one]) => {
368 const isLive = isRunning.has(id)
369 const window = agentWindow(one.peak, context.window)
370 const percent = Math.round((one.tokens / window) * 100)
371 const peak = one.peak > one.tokens ? ` · peak ${compact(one.peak)}` : ''
372 const numbers = ` ${percent}% · ${compact(one.tokens)}/${compact(window)} · ${one.steps} req${peak}`
373 const room = Math.max(6, Math.min(20, e.props.bodyColumns - 2 - numbers.length))
374 return (
375 <Box flexDirection="column">
376 <Text dimColor={!isLive} wrap="truncate-end">
377 {isLive ? '● ' : '○ '}
378 <Text bold>{one.type}</Text>
379 {one.description ? ` ${one.description}` : ''}
380 </Text>
381 <Box flexDirection="row">
382 <Text>{' '}</Text>
383 <Text color={fillColor(percent)} dimColor={!isLive}>
384 {bar(percent, room)}
385 </Text>
386 <Text dimColor={!isLive}>{numbers}</Text>
387 </Box>
388 </Box>
389 )
390 })}
391 </Box>
392 )
393 } catch {
394 // Whatever fails while drawing, the pane still says something.
395 return <Text dimColor>No breakdown yet: it arrives with the first response.</Text>
396 }
397 })
398}
399hooks/context-meter/meter.ts 269 lines1import type { ModelUsage, SessionContextUsage } from 'claude-code'
2
3import type { AgentReading, Reading } from './types'
4
5export const THRESHOLDS = [70, 85]
6const AGENTS_KEPT = 20
7
8/** What a request was answered over: uncached, cache-written and cache-read input together. */
9export function inputTokens(usage: ModelUsage): number {
10 return usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
11}
12
13/**
14 * The window a subagent runs in. The engine reports none per agent, so take the
15 * session model's window, or the agent's peak when it went past that.
16 */
17export function agentWindow(peak: number, sessionWindow: number): number {
18 return Math.max(peak, sessionWindow)
19}
20
21/** The Celestial theme's palette (a darker Horizon), by role. */
22export const CELESTIAL = {
23 calm: '#29D398',
24 warm: '#FAB795',
25 hot: '#E95678',
26 accent: '#B877DB',
27 muted: '#6C6F93',
28 series: ['#26BBD9', '#B877DB', '#F09483', '#EE64AC', '#59E1E3', '#FAB795', '#29D398'],
29} as const
30
31/** The colour of a fill: calm, then warm at the first threshold, hot at the second. */
32export function fillColor(percent: number): string {
33 if (percent >= THRESHOLDS[1]!) return CELESTIAL.hot
34 if (percent >= THRESHOLDS[0]!) return CELESTIAL.warm
35
36 return CELESTIAL.calm
37}
38
39/** One colour per breakdown row: the series for what is used, muted for the buffer and free space. */
40export function categoryColors(kinds: readonly string[]): string[] {
41 let used = 0
42
43 return kinds.map(kind =>
44 kind === 'used' ? CELESTIAL.series[used++ % CELESTIAL.series.length]! : CELESTIAL.muted,
45 )
46}
47
48/** Folds one request of a subagent into the readings, keeping the most recent few. */
49export function recordStep(
50 agents: Record<string, AgentReading>,
51 id: string,
52 tokens: number,
53 model: string,
54 at: number,
55 who?: { type: string; description: string },
56): Record<string, AgentReading> {
57 const before = agents[id]
58 const next = {
59 ...agents,
60 [id]: {
61 tokens,
62 peak: Math.max(tokens, before?.peak ?? 0),
63 steps: (before?.steps ?? 0) + 1,
64 model,
65 at,
66 type: who?.type ?? before?.type ?? 'agent',
67 description: who?.description ?? before?.description ?? '',
68 },
69 }
70 // Ties on `at` keep the agent just updated.
71 const kept = Object.entries(next)
72 .sort(([keyA, a], [keyB, b]) => b.at - a.at || Number(keyB === id) - Number(keyA === id))
73 .slice(0, AGENTS_KEPT)
74
75 return Object.fromEntries(kept)
76}
77
78/** An agent type without its plugin prefix: `ratchet:reader` reads `reader`. */
79export function shortType(type: string): string {
80 return type.slice(type.indexOf(':') + 1)
81}
82
83const TAIL_AGENTS = 3
84
85/** The status line's tail: a small bar per running subagent, the fullest first, `+N` past three. */
86export function agentsTail(running: readonly AgentReading[], sessionWindow: number): string {
87 if (running.length === 0) return ''
88 const shown = [...running].sort((a, b) => b.tokens - a.tokens).slice(0, TAIL_AGENTS)
89 const parts = shown.map(agent => {
90 const percent = Math.round((agent.tokens / agentWindow(agent.peak, sessionWindow)) * 100)
91 return `${shortType(agent.type)} ${bar(percent, 5)} ${percent}%`
92 })
93 const more = running.length > shown.length ? ` +${running.length - shown.length}` : ''
94
95 return ` │ ${parts.join(' · ')}${more}`
96}
97
98export function compact(tokens: number): string {
99 if (tokens >= 1_000) {
100 const thousands = Math.round(tokens / 1_000)
101 // 999600 rounds to 1000k: that is a million.
102 if (thousands < 1_000) return `${thousands}k`
103 return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
104 }
105
106 return String(tokens)
107}
108
109export function bar(percent: number, width: number): string {
110 const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
111
112 return '█'.repeat(filled) + '░'.repeat(width - filled)
113}
114
115export function toReading(context: SessionContextUsage): Reading {
116 return {
117 tokens: context.tokens ?? null,
118 window: context.window,
119 percent: context.percent ?? null,
120 }
121}
122
123export function statusLine(reading: Reading): string {
124 if (reading.tokens === null || reading.percent === null) {
125 return `ctx ${bar(0, 10)} –/${compact(reading.window)}`
126 }
127
128 return `ctx ${bar(reading.percent, 10)} ${reading.percent}% · ${compact(reading.tokens)}/${compact(reading.window)}`
129}
130
131/** The highest threshold the fill has reached; 0 below all of them or with no reading. */
132export function reached(percent: number | null): number {
133 return THRESHOLDS.filter(t => (percent ?? 0) >= t).at(-1) ?? 0
134}
135
136/** Splits `width` cells among `parts` in proportion to `total`, rounding cumulatively so the cells add up. */
137export function cells(parts: readonly number[], total: number, width: number): number[] {
138 let sum = 0
139 let start = 0
140
141 return parts.map(part => {
142 sum += part
143 const end = Math.min(width, Math.round((sum / total) * width))
144 const count = Math.max(0, end - start)
145 start = Math.max(start, end)
146 return count
147 })
148}
149
150const HISTORY_KEPT = 60
151const LEVELS = '▁▂▃▄▅▆▇█'
152
153/** Appends one turn's main-window tokens, keeping the most recent sixty. */
154export function pushHistory(history: readonly number[], tokens: number): number[] {
155 return [...history, tokens].slice(-HISTORY_KEPT)
156}
157
158/** The last turn's move, `+12k` or `-30k`; empty with fewer than two turns or no move. */
159export function lastDelta(history: readonly number[]): string {
160 const last = history.at(-1)
161 const before = history.at(-2)
162 if (last === undefined || before === undefined || last === before) return ''
163
164 return last > before ? ` +${compact(last - before)}` : ` -${compact(before - last)}`
165}
166
167/** One block per turn, its height the window's fill at that turn. */
168export function sparkline(history: readonly number[], window: number, width: number): string {
169 return history
170 .slice(-width)
171 .map(tokens => LEVELS[Math.min(7, Math.floor((tokens / window) * 8))])
172 .join('')
173}
174
175/**
176 * Turns left before auto-compaction at the pace of the last few turns since the
177 * window last shrank (a compaction or /clear); null when it is not growing.
178 */
179export function turnsToCompact(history: readonly number[], threshold: number): number | null {
180 const growth: number[] = []
181 for (let i = history.length - 1; i > 0 && growth.length < 5; i--) {
182 const step = history[i]! - history[i - 1]!
183 if (step < 0) break
184 growth.push(step)
185 }
186 const pace = growth.reduce((a, b) => a + b, 0) / Math.max(1, growth.length)
187 const now = history.at(-1) ?? 0
188 if (now >= threshold) return 0
189 if (pace <= 0) return null
190
191 return Math.ceil((threshold - now) / pace)
192}
193
194/** A rough token count of a tool result as the model read it: four characters a token. */
195export function estimateTokens(text: string): number {
196 return Math.ceil(text.length / 4)
197}
198
199/** What a tool call was about, in a few words: the file, the command, the pattern. */
200export function toolLabel(tool: string, input: Record<string, unknown>): string {
201 const text = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : '')
202 const path = text('file_path') || text('notebook_path') || text('path')
203 if (path !== '') return path.split(/[\\/]/).slice(-3).join('/')
204 const command = text('command').split('\n')[0] ?? ''
205 if (command !== '') return command.length > 48 ? `${command.slice(0, 47)}…` : command
206
207 return text('pattern') || text('url') || text('description') || text('subagent_type') || text('query')
208}
209
210export type Consumer = { tool: string; label: string; tokens: number }
211
212type ApiBlock = { type: string; [field: string]: unknown }
213
214/** A tool result's text: its content when a string, else its text blocks joined. */
215function resultText(content: unknown): string {
216 if (typeof content === 'string') return content
217 if (!Array.isArray(content)) return ''
218
219 return (content as ApiBlock[])
220 .filter(block => block.type === 'text' && typeof block.text === 'string')
221 .map(block => block.text as string)
222 .join('')
223}
224
225/** The heaviest tool results among Messages API messages, largest first. */
226export function topConsumers(messages: readonly { role: string; content: readonly ApiBlock[] }[], count: number): Consumer[] {
227 const blocks = messages.flatMap(message => (Array.isArray(message.content) ? message.content : []))
228 const uses = new Map<string, { name: string; input: Record<string, unknown> }>()
229 for (const block of blocks) {
230 if (block.type === 'tool_use' && typeof block.id === 'string') {
231 uses.set(block.id, { name: String(block.name), input: (block.input ?? {}) as Record<string, unknown> })
232 }
233 }
234
235 return blocks
236 .flatMap(block => {
237 const use = block.type === 'tool_result' ? uses.get(String(block.tool_use_id)) : undefined
238 if (use === undefined) return []
239 return [{ tool: use.name, label: toolLabel(use.name, use.input), tokens: estimateTokens(resultText(block.content)) }]
240 })
241 .sort((a, b) => b.tokens - a.tokens)
242 .slice(0, count)
243}
244
245/** The path a capped output names: its last non-empty line reads `… (<n> lines in <path>)`. */
246export function cappedPath(stdout: string): string | null {
247 const lines = stdout.trimEnd().split('\n')
248 const match = /^… \(\d+ lines in (.+)\)$/.exec(lines.at(-1) ?? '')
249
250 return match?.[1] ?? null
251}
252
253/** Sums `ratchet usage <id> --json`'s buckets by role, input to output (thinking is inside output). */
254export function taskCost(json: unknown, id: string): { total: number; roles: { role: string; tokens: number }[] } | null {
255 const tasks = (json as { tasks?: { id?: string; buckets?: { role?: string; tokens?: Record<string, number> }[] }[] })?.tasks
256 const task = tasks?.find(one => one.id === id)
257 if (task === undefined) return null
258 const byRole = new Map<string, number>()
259 for (const bucket of task.buckets ?? []) {
260 const t = bucket.tokens ?? {}
261 const sum = (t['input'] ?? 0) + (t['cache_write'] ?? 0) + (t['cache_read'] ?? 0) + (t['output'] ?? 0)
262 const role = bucket.role ?? 'unknown'
263 byRole.set(role, (byRole.get(role) ?? 0) + sum)
264 }
265 const roles = [...byRole].map(([role, tokens]) => ({ role, tokens })).sort((a, b) => b.tokens - a.tokens)
266
267 return { total: roles.reduce((a, r) => a + r.tokens, 0), roles }
268}
269hooks/context-meter/types.d.ts 39 lines1export type Reading = {
2 tokens: number | null
3 window: number
4 percent: number | null
5}
6
7/** One subagent's context, as its last model request reported it. */
8export type AgentReading = {
9 tokens: number
10 peak: number
11 steps: number
12 model: string
13 at: number
14 /** Captured while the agent was still in `$.agent.list()`, which drops it once done. */
15 type: string
16 description: string
17}
18
19/** What the task this session holds has cost so far, from `ratchet usage <id> --json`. */
20export type TaskCost = {
21 id: string
22 title: string
23 total: number
24 roles: { role: string; tokens: number }[]
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 ratchet: {
30 contextReading: Reading | null
31 contextAlerted: number
32 contextAgents: Record<string, AgentReading>
33 contextHistory: number[]
34 contextTask: TaskCost | null
35 contextMetered: boolean
36 }
37 }
38}
39