Projects · session tooling

Three hooks and a ratchet: Session Optimizer, traced through its source

Three plugins sit on three points of a Claude Code session. One reads your prompt before the work starts and asks for the vague words to be bound to real artefacts. One watches the size of the conversation and stops it twice, once to reflect and once to hand over. One puts the numbers you would otherwise have to guess at on a single line of your terminal. They share no process and no database, only a few files under your home directory, and nothing they compute leaves the machine.6

Hooksthree events in all: UserPromptSubmit1, SessionStart2, and Stop with SubagentStop3
Never blocks youthe gate always exits zero4; the guard exits zero on any parse or IO error5
What is measuredcontext tokens on the main thread only: input plus both cache fields14
Thresholdswarning at 120k to 180k, hard at 160k to 200k tokens depending on the model family, and your own file wins16
What is writtena checkpoint stub, two per-session files under /tmp, and one ledger row192535
What leavesnothing. No network call anywhere in the three plugins6
Exhibit 01 · the seams

Three plugins, three moments in a session

None of these plugins is a program you run. Each one registers on a point the host already emits, does a bounded amount of work, and gets out of the way. The refine gate reads a prompt before it reaches the model.1 The statusline re-installs itself at session start and is re-rendered by the host on a short interval.2 The context guard runs at the end of each turn, and once more when a subagent finishes.3

The design rule shared by all three is that failing quietly beats interfering. The gate is explicit about it: a gate that can block your prompt is worse than no gate at all.4 The guard exits zero on any parse or IO error, because a Stop hook that wedges a session is worse than a missed checkpoint.5 Neither behaviour is a promise in a README; both are the first thing their files say.

SESSION TIMELINE SessionStart install.sh sync ONCE, AT START re-syncs the install, idempotent and silent when nothing changed UserPromptSubmit refine_gate.py EVERY PROMPT reads the prompt text, injects instructions or prints nothing at all statusline refresh statusline-command.sh EVERY ~10 s host payload, git, local records, then lines fitted to width SubagentStop subagent-tracker.py EACH SUBAGENT reads the child transcript, upserts by agent id, recomputes Stop stop-context-guard.py END OF EACH TURN reads the transcript tail, compares it to this model's thresholds WHAT IS WRITTEN, AND WHERE Checkpoint stubs ~/.claude/memories/checkpoints/ <session_id>.md · latest.md git state captured, five sections left for the model to fill Per-session state /tmp/zetetic-subagents-<id>.json /tmp/zetetic-ctxguard-<id>.json child spend, and the highest threshold already triggered Cost ledger and cache costs.sh ledger telemetry cache, 15 s TTL one row per session, read back rather than recomputed writes Nothing here calls the network. Every figure is computed on your machine, from the host payload, your git and these files. A prompt that matches nothing costs no context at all: on no match the gate prints nothing.
Where the state lives. The checkpoint stub and its latest.md copy go under your home directory; the child-spend aggregate and the guard's own level file are per-session files under /tmp, which means they do not survive a reboot.1925 The privacy statement is unambiguous about the boundary: no plugin makes any network call.6 One claim in that same file does not hold at this revision, and the dossier says so rather than repeating it: refine-gate is credited with a local state file, and its hook writes nothing at all.7
Exhibit 02 · the gate

What the refine gate hears, and what it says back

“The previous fix is still broken, make it work like before.” Every noun in that sentence points at something the model cannot see. The gate recognises the shape of that problem rather than its subject matter: seven regular expressions over five classes of unbound reference, drawn from mis-bindings that actually happened rather than from imagination.8

Its second tier is the more interesting one, because it is structural. A prompt that reads as a request for work but contains no path, no filename, no commit hash and no line reference is a prompt nobody can check the result of.9 The reasoning is written into the file: no gate can enumerate every domain vocabulary, so it tests for grounding instead, and a prompt you grounded yourself is left alone.10 On a match it injects instructions; on no match it prints nothing, so the common case costs no context at all.11

HOOK INPUT the raw prompt payload["prompt"] 5 s timeout Empty, or starts with / ? a slash command carries its own instructions TIER 1 Reference markers? 7 regexes, IGNORECASE drawn from real mis-bindings, not invented TIER 2 Work request, no anchor? WORK and not ANCHOR structural, not a vocabulary of domains INJECTED, TIER 1 1 · bind every reference 2 · symptom, goal, non-goals 3 · pick one strategy 4 · external acceptance signals INJECTED, TIER 2 1 · which module IS “the X system”? bind each name 2 · recall earlier attempts then 3, 4, 5 as above No output at all the host flow is untouched, and no context is spent THE FIVE MARKER CLASSES prior-artifact shorthand named-but-unlocated solution comparison to unstated referent exact-behavior reference repeat-failure WHAT COUNTS AS AN ANCHOR a/path/with/slashes a file.ext from a fixed list a 7 to 40 hex commit sha a :line reference Ground the prompt yourself and the gate stays out of the way. It never rewrites your prompt, and it always exits zero. yes no match no match yes no
It asks for work, it does not do the work. What is injected is a procedure: bind each reference to a file, commit or process with evidence; separate the symptom from the goal; choose one execution strategy rather than stacking several; and define acceptance as an external signal rather than the model re-reading its own output.12 Two of those rules cite measurements against scaffolding, which is an unusually falsifiable thing for a prompt to claim. One number in the same file does not add up: the text announces fifteen strategies and the table carries seventeen rows.13
Exhibit 03 · the ratchet

Two thresholds, and a ratchet that only turns one way

At the end of every turn the guard reads the tail of your transcript rather than the whole file, stepping backwards in 64 KiB blocks under a hard 4 MiB cap. That size is not a guess: the file records the measurement it came from, a 24.5 MB transcript whose last usage record sat 7 591 bytes from the end.15 From that record it sums three fields into one figure, the input tokens plus both cache fields, and compares it to the thresholds for the model you are on.1416

What happens next is the part worth understanding before you install it. Both thresholds return the same verdict to the host: block.17 The warning is not advice you can ignore; it is a single interruption that writes a checkpoint and asks for a reflection pause, after which the session continues.20 The hard threshold interrupts once more and asks you to clear and resume.21 Neither can fire twice, because the highest level reached is remembered per session.18

AT EVERY Stop Read the transcript tail 64 KiB steps, 4 MiB cap sized from a measured 24.5 MB transcript: the last usage record sat 7 591 bytes from the end THE FIGURE COMPARED input_tokens + cache_creation_input_tokens + cache_read_input_tokens the main thread only none silent exit, no side effect warn block once, write the stub, then carry on working hard block once, finish the checkpoint, ask for /clear The stub is mechanical git state for free; five sections left as “to be filled” FALLBACK THRESHOLDS fable 120k / 160k mythos 120k / 160k haiku 120k / 170k sonnet 180k / 200k opus 180k / 200k default 180k / 200k warn / hard, in context tokens. Your own file at ~/.claude/ctxguard- thresholds.json wins over this table. ctx ≥ warn ctx ≥ hard THE RATCHET ONLY TURNS ONE WAY A level already triggered never fires again: the highest level reached is kept in a per-session file, and a Stop that is already a forced continuation exits immediately. Both crossings return the same verdict to the host, "decision": "block"; the warning is a single pause for reflection, not a suggestion, and any parse or IO error exits zero rather than wedging the session.
The stub is mechanical, and says so. The hook captures what git can give it for free and leaves five sections marked “to be filled”: goals, file references, errors and fixes, current state, next steps.19 Filling them is semantic work, and the hook does not do it: it asks the model to spawn a small writer agent, and falls back to writing the file directly if that agent is not installed.22 That agent is held to 16K of context and forbidden from inventing anything absent from its input.24 Scoped-memory wording only appears when a memory tool is actually present on the machine.23 A file on disk is therefore not yet proof of a useful handover.
Exhibit 04 · the numbers

Three measurements that must not be added together

A large current context and a large cumulative spend are different facts. The context figure is main-thread only and deliberately excludes the children, because mixing subagent tokens into it would trigger checkpoints at the wrong moment; the children's spend is reported beside it instead.1425 The aggregate that holds it is keyed by agent id, so a repeated event updates an entry rather than doubling it, and sibling transcripts are swept in case an earlier event was missed.26 One layer down, assistant turns are deduplicated by message id with the largest usage winning, because the host re-logs the same turn across forks and resumes.27

The statusline then applies a rule that is easy to state and easy to get wrong: counts and token volumes come from the aggregate, and every dollar figure comes from the ledger alone, so the same spend is never priced twice on one screen.313235 When the terminal is narrow it drops from the tail, keeping the leading segments, because the host payload carries no width field and the layout has to probe for one.2930

WHAT IS READ WHAT IS KEPT LOCALLY WHAT READS IT Host statusLine payload model · context_window rate_limits · cost · pr no terminal width field Main transcript <session_id>.jsonl priced once, then read back rather than recomputed Child transcripts subagents/agent-<id>.jsonl siblings swept too, in case an earlier event was missed Telemetry cache 15 s TTL, written in the background under a lock Cost ledger costs.sh, one row per session the single source of every dollar figure on screen Child-spend aggregate /tmp/zetetic-subagents-<id>.json keyed by agent id, so a repeat event updates instead of doubling The statusline counts and token volumes from the aggregate, every dollar from the ledger. Never both. The checkpoint notice reports child spend beside the context figure, and keeps it out of the threshold decision the ledger already counts the children THREE MEASUREMENTS, NOT ONE NUMBER A large current context and a large cumulative spend across child agents are different facts, and the code keeps them apart. The cost of a full statusline refresh, in milliseconds, is not measured anywhere in the sources: only the paths that avoid work are.
Two prices for one piece of work would be a bug, not a feature. The rule that every displayed dollar comes from a single ledger is what keeps the statusline honest when a session has spawned a dozen children.32 The telemetry behind the throughput and cache segments is refreshed at most about once per refresh-and-a-half, in the background, under a lock.33

What these three plugins do not prove

The end-to-end result is a better-grounded request, visible session signals and a written restart point. The code supports that workflow; it does not establish that the instructions were obeyed, that the implementation that followed was correct, or that a summary kept every fact that mattered. Those need review. Three further limits are worth knowing: the pattern matching is English and syntactic, not an understanding of your sentence8; the temporary files are under /tmp and do not survive a reboot25; and the cost of a full statusline refresh, in milliseconds, is measured nowhere in the sources, which document only the paths that avoid work.33 One detail is a deliberate choice rather than an omission: there are no emoji anywhere, because a glyph has to be learned and several break the column budget.34 The lines are rendered identity first.28

1plugins/refine-gate/hooks/hooks.json:3:3-14 — one event, UserPromptSubmit, with a 5 s timeout
2plugins/statusline/hooks/hooks.json:3:3-18 — one event, SessionStart matching startup|resume, which re-runs the installer rather than rendering anything
3plugins/context-guard/hooks/hooks.json:3:3-24 — the only two events, Stop and SubagentStop, each with a 10 s timeout
4plugins/refine-gate/hooks/refine_gate.py:15:14-16 — “Always exits 0 — a gate that can block the user’s prompt is worse than no gate.”
5plugins/context-guard/hooks/stop-context-guard.py:52:52 — a Stop that is already a forced continuation exits at once; any parse or IO error exits 0 rather than wedging the session
6PRIVACY.md:24:22-26 — “Nothing. No plugin in this marketplace makes any network call, sends telemetry, or transmits any content…”
7PRIVACY.md:17:17 credits refine-gate with “a small local state file used to rate-limit the gate”. At this revision refine_gate.py opens no file and writes nothing at all, so either the file was removed or it was never there
8plugins/refine-gate/hooks/refine_gate.py:30:30-50 — seven regexes over five labels, matched case-insensitively; :27-29 records that they come from mis-bindings actually observed rather than from invention
9plugins/refine-gate/hooks/refine_gate.py:61:61-76 — the work-request vocabulary, and the four things that count as an anchor: a path containing a slash, a filename with a known extension, a 7-to-40 character commit hash, or a :line reference
10plugins/refine-gate/hooks/refine_gate.py:56:56-60 — “The gate cannot enumerate every domain vocabulary, so the test is structural instead… If the user grounded the prompt themselves, the gate stays out of the way.”
11plugins/refine-gate/hooks/refine_gate.py:144:141-151 — the injected block follows the host’s UserPromptSubmit contract; :139-140 prints nothing at all on no match
12plugins/refine-gate/skills/refine/SKILL.md:123:123-170 — the strategy table and its four selection rules, including that scaffolding a simple task degrades the output and that external evidence beats the model re-reading itself
13plugins/refine-gate/skills/refine/SKILL.md:127:127 announces 15 strategies; the table under it carries 17 rows. Nothing in the sources explains the gap, so do not read 15 as a count of what ships
14plugins/context-guard/hooks/stop-context-guard.py:173:173-178 — input_tokens plus cache_creation_input_tokens plus cache_read_input_tokens, taken from the last usage record
15plugins/context-guard/hooks/stop-context-guard.py:138:138-149 — the reader steps backwards in 64 KiB blocks under a 4 MiB cap, sized against a measured 24.5 MB transcript whose last usage record sat 7 591 bytes from the end
16plugins/context-guard/hooks/stop-context-guard.py:85:83-92 — the fallback table; :80-82 prefers your own ~/.claude/ctxguard-thresholds.json, and :111-128 resolves a model by substring, falling back when the pair is not ordered
17plugins/context-guard/hooks/stop-context-guard.py:405:405-418 — hard, then warn, then a silent exit; both crossings return "decision": "block" at :434 and :444
18plugins/context-guard/hooks/stop-context-guard.py:131:131 — the level order. The highest level already reached is kept per session, and only an upward move acts
19plugins/context-guard/hooks/stop-context-guard.py:330:330-351 — the five sections left as placeholders; :302 is the directory the stub and its latest.md copy are written to
20plugins/context-guard/hooks/checkpoint_protocol.py:49:49-55 — “⚠ CHECKPOINT THRESHOLD … This is a reflection pause, NOT the end of the session.”
21plugins/context-guard/hooks/checkpoint_protocol.py:112:112-128 — the soft-cap message, and the literal three lines the answer has to end on before the user is asked to clear
22plugins/context-guard/hooks/checkpoint_protocol.py:66:66-76 — the instruction asks the model to spawn the writer, with a fallback if the agent is not installed. The hook spawns nothing itself
23plugins/context-guard/hooks/checkpoint_protocol.py:31:31-44 — the scoped wording is emitted only when a memory tool is actually present, so an outside user never reads store-specific verbs
24plugins/context-guard/agents/memory-writer.md:9:9 — a hard 16K context budget, and “You persist; you do not think up new content.”
25plugins/context-guard/hooks/subagent-tracker.py:114:114 — the aggregate is keyed by agent id, so a repeated event updates an entry instead of doubling it; :87 recomputes the totals
26plugins/context-guard/hooks/subagent-tracker.py:9:9-13 — the payload carries the child’s own transcript path; sibling transcripts are swept as well, so an event missed earlier is still recovered
27plugins/context-guard/tools/subagent_usage.py:203:203-207 — assistant turns are deduplicated by message.id, largest usage wins, because the host re-logs the same turn across forks and resumes
28plugins/statusline/assets/statusline-command.sh:163:163-182 — the order the lines are rendered in, identity first
29plugins/statusline/assets/statusline-lib/fit.sh:131:127-135 — the longest leading run of whole segments that fits the width; the tail is what gets dropped
30plugins/statusline/assets/statusline-lib/layout.sh:18:18-22 — verified against a live payload: it carries no width field of any kind, so :47-57 probes four sources in turn
31plugins/statusline/assets/statusline-lib/session_state.sh:125:120-132 — the count and token volume come from the aggregate file
32plugins/statusline/assets/statusline-lib/render.sh:167:167 — the children’s dollars are not repeated, because they are already inside the session figure
33plugins/statusline/assets/statusline-lib/session_state.sh:76:76 — the telemetry cache refreshes at most about once per refresh-and-a-half, and is written in the background under a lock
34plugins/statusline/assets/statusline-lib/render.sh:23:23-28 — no emoji anywhere: a glyph has to be learned, and several render double-width and break the column budget
35plugins/statusline/assets/costs.sh:9:9 — one ledger row per session, upserted idempotently