Guidance for any coding agent (Codex, Claude Code, etc.) working on this repository.
Naming note. burnstop instruments Claude Code, so "Claude Code" below refers to that product (the source of the JSONL transcripts and the host of the hooks) — not to the agent reading this file. The agent working on the codebase is "the coding agent" or "you".
A native, ToS-safe token budget for Claude Code. Arm a cap on a session and burnstop halts the run cleanly when spend (parent + subagents) crosses it — no proxy, no API key, no OAuth forwarding. It's a PreToolUse hook plus a tiny CLI. See README.md for the user-facing pitch and docs/spikes.md for why it's a hook and not a proxy.
Three Python files, stdlib only, no pip install step. Python 3.8+.
- meter.py — read-only accounting. Sums
message.usageacross a session's JSONL transcripts (parent + subagents), dedupes bymessage.id, prices each turn by its own model (PRICING/get_pricing/turn_cost), and surfaces recent tool-call signatures for loop detection. HoldsVERSION. - hook.py — the fuse:
PreToolUse/Stop/SubagentStopentrypoint. Resolves the budget (tokens or dollars), recomputes spend, and a pureevaluate()returns a verdict (halt/warn/allow) thatrender()turns into the right output for the event. - dispatch.py —
UserPromptExpansiononly: auto-arms$50on/goal(forwards tocli.main). The/budgetcommands do NOT go through here — they run the CLI directly via Bash (current Claude Code won't let a hook block a slash command). See docs/spikes.md §4b. - cli.py —
arm/reset/disarm/status/default/install.installgenerates the/budget-*command files (direct-run) and registers the four hooks with forward-slash paths.
Ships as a Claude Code plugin: .claude-plugin/plugin.json + marketplace.json, hooks/hooks.json, commands/budget*.md. Full design: docs/architecture.md.
Use python on Windows, python3 on macOS/Linux.
python cli.py install # register all 5 hooks + the /budget command into ~/.claude/settings.json
python cli.py install --scope project # ...or into ./.claude/settings.json
python cli.py arm $1 # cap at $1 of model spend from now (incl. subagents) — run INSIDE a session
python cli.py arm 200k # ...or cap by tokens
python cli.py reset # fresh allowance, keep the cap
python cli.py status # spend (tokens and $) vs cap for the current session
python cli.py disarm # remove the cap
python cli.py --version
# end users install as a plugin instead: /plugin marketplace add phuryn/burnstop && /plugin install burnstop
# and use /budget arm $1 ... in-session (auto-arms $50 on /goal)
python -m unittest discover -s tests -v # full suite (CI runs this)
python -m unittest tests.test_meter -v # one file
python -m unittest tests.test_hook.TestDecide.test_trip_on_budget # one test
You can also arm at launch with no file at all: BURNSTOP_BUDGET=200k claude.
The full design — data flow, transcript schema, the per-model cost model, budget resolution, the Stop-vs-/goal precedence, the /budget dispatcher and /goal auto-arm, subagent handling, the plugin manifest, and the bounded limitations — lives in docs/architecture.md. Read it before changing meter.py / hook.py / dispatch.py.
One-line shape: five hooks (hook.py on PreToolUse/Stop/SubagentStop; dispatch.py on UserPromptSubmit/UserPromptExpansion) call meter.session_spend(), which recomputes spend from Claude Code's own transcripts on every call — no ledger, nothing written during a run, so there's no staleness and no read-modify-write race across concurrent subagent hooks. The halt is Stop returning {"continue": false}, which beats /goal. Two load-bearing invariants to preserve: dedupe transcript records by message.id (keep last), and price each turn by its own model then sum (never aggregate tokens first).
- Tests use a temp projects root (
meter.session_spend(..., root=tmp)) andunittest.mockfor env/arm-file/cli.main— they never read the user's real~/.claude. - The pure functions (
hook.evaluate/hook.render,dispatch.handle_*) carry the logic with no I/O, so trip/warn/allow, the/budgetsentinel, and/goalauto-arm are all unit-testable without a live session. tests/test_version.pyenforcesmeter.VERSION ==the top CHANGELOG heading and.claude-plugin/plugin.jsonversion. Bump them together.
SemVer. CHANGELOG.md is the canonical version reference; tags and GitHub Releases are automatic projections of it. Adapted from phuryn/claude-usage (minus the .vsix build — burnstop has no extension, so the release just tags and publishes the CHANGELOG section as notes).
Release flow:
- Work accumulates under a
## vX.Y.Z — TBDheading at the top ofCHANGELOG.md. Bumpmeter.VERSIONto match when you write the heading (the parity test enforces it). - To release: change
TBD→ today's date, merge tomain, push. .github/workflows/tag-on-merge.ymlsees the new## vX.Y.Zheading in the push diff, creates the lightweight tag, and publishes a GitHub Release with that CHANGELOG section as the notes. Idempotent; no-ops on pushes that don't add a heading.
The workflow trusts the format. Every release entry:
## vX.Y.Z — TBD
### <Area>
- One bullet per change, past tense, link a PR/issue with #N, credit contributors with `thanks @login`.
| Field | Required form | Why |
|---|---|---|
| Heading | ## vX.Y.Z — exactly two #, v prefix, three numeric parts |
The workflow regex ^## v[0-9]+\.[0-9]+\.[0-9]+ ignores anything else. |
| Date | TBD while accumulating; real YYYY-MM-DD at merge-to-main |
A TBD shipped to main reads unfinished forever. |
| Bump | Patch by default; minor for a user-visible feature; major only for a break | No automation picks the bump; you do, when writing the heading. |
The TBD → date swap is the one thing a human must remember at release time.
- Keep user-facing CLI output and code string literals ASCII (no em dashes / fancy punctuation) — a Windows console is cp1252 and renders them as junk (
�). Markdown prose can use em dashes. - Match the surrounding code: stdlib only, type-free Python, docstrings that explain the non-obvious invariant rather than restating the signature.