Design notes

September 19, 2026 · View on GitHub

The idea in one line

Claude Code's context fills with tool output the task never needed. A System One model can answer "is this block needed?" for a hundred blocks in one call, with a probability instead of a guess. winnow puts that judgment between the tool and the context.

Why a judge and not a summarizer

Existing Claude Code context plugins decide what to keep with byte thresholds, duplicate counters, or an LLM summarizing the whole output. Thresholds are blind to relevance. Summarizing everything is lossy and slow. A calibrated per-block probability lets code make the keep/hide decision deterministically, and lets the threshold be tuned against a measurable regret rate. The summarizer is still there, but it only runs on what the judge already decided to hide, so it is cheap and its mistakes are recoverable.

Hook mechanics that shaped the design

  • A function-hook tool.call handler (Claude Code 2.1.260+, behind CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1) runs the tool with next(e) and returns { result }, the structured record Claude will read. Until 0.5.0 winnow did the same through PostToolUse and hookSpecificOutput.updatedToolOutput (2.1.121+). Either way the replacement must match the tool's output shape exactly or it is ignored, which is why extract.py has one explicit rebuilder per tool.
  • prompt.submit appends context entries the model reads beside the prompt (next({ ...e, context: [...] })). The classic UserPromptSubmit capped its output at 10,000 characters; the selector still budgets WINNOW_CONTEXT_MAX_CHARS below that.
  • PreCompact can only block compaction, not shape the summary. Compaction stays Anthropic's.
  • MCP tool schemas are already deferred natively when they exceed 10% of context (tool search). winnow does not touch tool selection.
  • The module reads the task from the live session ($.session.messages()). The transcript file, which classic hooks named by transcript_path, is the fallback when a caller sends no task.

State engineering

TypeSafe's CEO called state engineering the hard part, and it is. The judge's state is:

{
  "task":   {"user_request": "...", "assistant_intent": "..."},
  "tool":   {"name": "Read", "input": {"file_path": "..."}},
  "blocks": {"b001": "...", "b002": "..."}
}

task is reconstructed from the transcript tail: the last non-meta user message and the last assistant text before the tool call. A new user turn resets the intent. This is v0; better signals are the current todo list, the last edited file, and the names the assistant mentioned in its intent.

Questions reference the state by path, as TypeSafe recommends, so the model reads the data rather than its priors:

Is blocks.b007 needed to accomplish task? Judge it against task and tool.

Two state bugs found by reviewing live stubs (17 Sep 2026), both fixed in 0.3.4:

  • Subagent calls were judged against the orchestrator's task. Inside a subagent the hook's transcript_path is the parent session's file, so "the last user message" was whatever the human last typed to the orchestrator (often a bare "1" or "done"). The judge now reads the subagent's own transcript at <session>/subagents/agent-<id>.jsonl, whose first user message is the delegation prompt.
  • Long prompts were truncated from the wrong end. A 1,500-character cap kept the tail of the user request, so a long delegation prompt arrived as its closing details with the ask cut off. User requests now keep their head; assistant intents keep their tail, which is where the next step is stated.

Both affected every subagent-heavy session, which for this developer is most of them. The stubs reviewed before the fix were judged under worse conditions than the stubs that will follow.

Thresholds

ProbabilityDecision
p >= keep (0.5)keep
drop (0.1) <= p < keepkeep, logged as uncertain
p < drophide

Plus two gates: the error question (hide nothing if P(error) >= keep) and the minimum prune ratio (do not rewrite for a small saving). Start conservative, then move drop up as the regret rate stays low. On the hand-labeled replay numbers below, drop=0.1 is the default: it is the only bin that was clean.

Regret as the metric

Every rewrite logs its key. Every winnow_recall logs its key. Regret = recalled keys / pruned keys. It is the honest measure of whether the judge hides the right things, and plotted against drop it is a calibration curve on your own workload. Nobody has published one for Jev yet.

Shadow mode (WINNOW_MODE=shadow) collects the same decisions without changing anything Claude sees, so the log fills up before anyone has to trust the judge.

Replay: weak labels from your own history

winnow replay builds the benchmark offline from Claude Code transcripts. For every large Read/Bash/Grep result it records the task at that moment (last user request, last assistant sentence) and the next 12 assistant events (--window). Each 25-line block is then labeled:

LabelRule
needed / linea significant line of the block (12+ chars, 6+ alphanumerics, whitespace-normalized) reappears in any later assistant text, edit, write, or command
needed / identa distinctive identifier (underscore, digit, inner capital, or 10+ chars) that appears in at most two blocks of the output is mentioned in later prose, a command, a grep, or an edit's old_string
not_needed / noneneither
unknownno assistant events followed the result before the next human turn

Design choices that matter: Write content and edit new_string count for line overlap but not for identifier mentions, because rewriting a file would otherwise mark every block of it as needed through incidental names; the window stops long autonomous turns from making everything "used eventually"; subagent (sidechain) entries are ignored.

Known bias: Claude can read a block, use it to understand the code, and never quote it. Those blocks are labeled not needed, so regret from replay is an upper bound and savings an estimate. Per-block reason is recorded so the label mix can be audited.

First numbers: Jev vs the lexical baseline

300 cases from this machine's transcripts (16 Sep 2026, window 12, 1,515 labeled blocks, needed fraction 0.48; reasons line 271 / ident 458 / none 786). Both judges saw identical cases. Raw score files are in docs/results/2026-09-16/.

Jev (jev-latest)lexical baseline
Expected calibration error0.140.31
Median latency per case86 msn/a
Cost for all 300 cases$0.036 (863k input tokens)0
At drop=0.3: text hidden22.6%81.5%
At drop=0.3: regret (upper bound)21.3%85.8%
At drop=0.3: hidden precision59%26%

Jev's calibration table (mean predicted P(needed) vs observed rate under the weak label):

binnmean pneeded rate
0.0–0.1390.070.26
0.1–0.21260.150.38
0.2–0.32160.240.45
0.3–0.54080.400.49
0.5–0.85980.630.51
0.8–1.01280.840.58

Reading it honestly:

  • Jev is a real judge and the baseline is not; the ordering is right and the low bins are genuinely lower. That is the headline.
  • The observed rate never gets below 0.26 even where Jev says 0.07. The hand labels below show that this is mostly the weak label's fault, not Jev's.
  • The question phrasing and the task state are the levers. This harness makes every change to the question set or to transcript.read_task a one-command experiment costing a few cents.

Shadow mode cannot measure regret: nothing is hidden, so nothing is recalled. Live regret needs active mode at a conservative threshold plus the recall counter.

Hand labels: separating label noise from judge error

winnow replay sample drew 100 blocks from the Jev-judged cases, stratified (50 from p < 0.2, 25 from 0.2–0.5, 25 from ≥ 0.5), shuffled, and written out blind. Claude (Fable 5.1, in this repo's own session) labeled them from the task and the block alone: 47 needed, 50 not needed, 3 unsure. A 20-block audit by the human owner is the next step; until then treat these as a strong model's opinion, not ground truth.

Weak label vs hand label on the 97 decided blocks: agreement 69%. The disagreements go both ways: 20 blocks the weak label called not needed were needed (Claude used them without quoting them), and 10 it called needed were not (incidental identifier mentions). So the weak label both under- and over-marks, and its regret numbers are noisy in both directions rather than a clean upper bound.

Jev's calibration against hand labels, default question set:

binnmean pneeded rate
0.0–0.1100.070.00
0.1–0.2370.150.32
0.2–0.390.240.44
0.3–0.5160.420.69
0.5–0.7180.570.72
0.7–1.070.801.00

Jev's most confident "no" bin is perfectly clean, and the ordering is right everywhere. Above 0.1 it is systematically underconfident: observed rates run 0.15–0.30 above the stated probability. That is a much better problem than the reverse, and it says the safe operating point is drop=0.1, not 0.2: the 0.1–0.2 bin is a third needed.

Question sets, measured

Three phrasings of the per-block question, same 300 cases, same judge, scored on the hand labels at the operating point:

setblocks p < 0.1needed among themtext hidden at 0.1 (population, weak label)ECE (hand)
default (one-line criteria)100%1.8%0.18
structured (what / not_for / examples)230%4.6%0.26
strict ("directly about the task")3523%10.9%0.28

structured moves more than twice as much text below the safe threshold while keeping that bin clean; strict makes Jev overconfident and unsafe. structured is now the default (WINNOW_QUESTIONS). Its worse ECE is all in the middle bins, where it is even more underconfident, which does not matter for a 0.1 threshold. Raising the threshold safely means fixing that underconfidence, which is the next tuning target; candidates are richer task state (todo list, last edited file) and a Score over relevance levels.

Expected live effect at drop=0.1 with structured: about 5% of large-result text hidden, at zero hand-label regret. Modest, honest, and the number to beat.

An open judge? jevlike on the same harness

jevlike is an independent open model with Jev's shape (context plus N options, one probability each), trainable on your own rows. Since winnow's judge interface is vendor-neutral, the obvious question is whether a small local model trained on the replay data can stand in for Jev without a key. docs/experiments/jevlike_experiment.py turns the 300 replay cases into jevlike rows (context = task + tool + block, options needed / not needed, label = the weak label), keeps whole transcripts in one split (6 transcripts; 1,043 / 277 / 195 blocks), trains, and scores the held-out 195 blocks with the same score() as everything else. Jev's and the lexical judge's records were rescored on exactly those 50 cases.

judge on the 195 held-out blocks (weak labels)ECEAUC
Jev, default questions0.1410.701
Jev, structured questions0.1750.701
lexical baseline (no model)0.3130.635
jevlike, byte encoder from scratch (16 s on CPU)0.3150.453
jevlike, frozen Qwen2.5-0.5B + head, lr 2e-3 (5 min on an RTX 4060)0.1680.566
jevlike, frozen Qwen2.5-0.5B + head, lr 2e-4 (8 min)0.1350.497

Two things came out of this, and the second matters more than the first.

The first: with a thousand weak labels, jevlike is not a judge. From scratch it is below a coin flip. With a pretrained encoder it learns something (AUC 0.57) but its ranking is bumpy and its validation loss never beat the base rate for long; the low-learning-rate run converged to predicting the base rate for every block. Files: docs/results/2026-09-18/. This is what one would expect from 1k noisy examples, not a verdict on the architecture; ten thousand hand-checked labels would be a different experiment.

The second: the lr 2e-4 run has the best ECE in the table and is useless. A judge that says 0.5 to everything is perfectly calibrated on a 48%-needed workload and can hide nothing. Calibration was the headline number in the earlier sections because the threshold question is a calibration question, but it cannot stand alone. score() now reports ROC AUC beside ECE (the chance a needed block scores above a not-needed one; 0.5 is a coin flip), and every report prints both. On these blocks Jev's ordering is 0.70 against weak labels, which is real but not dramatic; the lexical baseline's 0.64 says that task-word overlap carries a good part of the signal. What separates Jev is that its low tail is clean (the calibration bins above), which is the part a threshold uses.

Latency, measured

winnow bench on this machine (Windows 11, Python 3.14, warm disk):

PathMedian
interpreter only92 ms
small result, python -m winnow (fast path, no SDK import)274 ms
small result via uv run (what Claude Code runs)374 ms
interpreter + import typesafe_sdk566 ms

So via command hooks a result under WINNOW_MIN_CHARS costs about 370 ms, and a judged result about 850 ms before the request leaves, of which about 470 ms is importing the SDK (mostly httpx2 reading package metadata).

The resident sidecar

winnow serve is a loopback HTTP server that keeps the SDK imported and the judge's HTTP client warm. Claude Code's http hooks POST the event JSON to it and read the hook output from the response body: an empty 2xx is pass-through, a JSON 2xx is the hook output, and a connection failure is a non-blocking error. The SessionStart hook runs winnow serve --ensure, which spawns a detached server if none answers (and replaces one from an older plugin version). The server re-reads ~/.winnow/env when it changes, so a key or threshold edit takes effect without a restart, and exits after 45 idle minutes.

PathMedian
small result via uv run command hook381 ms
small result via the sidecar16 ms

Judged results also skip the ~500 ms SDK import, and TLS reuse takes the judge round trip itself from the 300–500 ms range down toward the 90 ms Jev shows in replay. If the sidecar is down, results pass through unjudged and Claude Code shows the hook error; winnow doctor and winnow serve --status both report it.

Function hooks: the same judge, in-process

Claude Code's function hooks (early access, CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, 2.1.260+) load a TypeScript module into the engine. A tool.call handler there sees the call, runs the tool with next(e), and returns { result }: the structured result Claude will read, with no JSON-on-stdout contract and no transcript path. winnow's module (hooks/winnow.ts) keeps the judge in the Python sidecar and changes two things:

  • Task state from the live session. $.session.messages() gives the conversation as the engine holds it, so the "current task" is the real last human request and the assistant's last sentence, with no file lag. Inside a subagent the messages are the subagent's own, which closes the state bug that 0.3.4 fixed on the http path by guessing the subagent's transcript file.
  • Visible decisions. The sidecar puts a summary of each rewrite in an X-Winnow response header (blocks hidden, characters before and after, the recall key) and the module shows it as a toast; the sidecar's "judge could not start" notice becomes a toast too. Http hooks had no channel for that.

0.4.0 shipped the module beside the http hooks with a dedupe layer so both could fire. Both did fire in a live session on 2026-09-18, and the http hook reached the sidecar first every time, so the module's better task state went unused. 0.5.0 dropped the http hooks (a deliberate break: no backward compatibility, so the plugin has one path and no race) and the dedupe with them. The cost is that the flag is now required; winnow doctor reports when it is missing, because a module that never loads fails silently.

Everything that was measured stays measured: the same extraction, chunking, questions, thresholds and cache, so the calibration numbers above apply unchanged. The module's own tests run under claude plugin test, which loads the plugin into an engine with no network, so they cover task reconstruction and the pass-through path (sidecar down, small result, denied call). The live check is claude --debug, which logs hooks module winnow@winnow loaded ... events: tool.call,prompt.submit and a settle time per event. First live run on 2026-09-18 (headless, 0.5.0): a 25 KB README read against a one-line question was judged in 517 ms, 27 of 28 blocks hidden, the toast logged, the whole tool.call settled in 697 ms including the Read itself, and Claude answered correctly from the block that stayed. One trap found on the way: Claude Code's Read refuses files over 256 KB with a one-line error string as the result, so the module sees a short string there and nothing reaches the sidecar.

The engine also offers session.compact (2.1.274+), where a hook can replace the compaction result. That is the layer fast-jev-compaction works at; winnow's calibrated block question could run there over whole results before the summary, and the harness can tell whether it should.

An open judge? jevlike on the same harness

jevlike is an independent open model with Jev's shape (context plus N options, one probability each), trainable on your own rows. Since winnow's judge interface is vendor-neutral, the obvious question is whether a small local model trained on the replay data can stand in for Jev without a key. docs/experiments/jevlike_experiment.py turns the 300 replay cases into jevlike rows (context = task + tool + block, options needed / not needed, label = the weak label), keeps whole transcripts in one split (6 transcripts; 1,043 / 277 / 195 blocks), trains, and scores the held-out 195 blocks with the same score() as everything else. Jev's and the lexical judge's records were rescored on exactly those 50 cases.

judge on the 195 held-out blocks (weak labels)ECEAUC
Jev, default questions0.1410.701
Jev, structured questions0.1750.701
lexical baseline (no model)0.3130.635
jevlike, byte encoder from scratch (16 s on CPU)0.3150.453
jevlike, frozen Qwen2.5-0.5B + head, lr 2e-3 (5 min on an RTX 4060)0.1680.566
jevlike, frozen Qwen2.5-0.5B + head, lr 2e-4 (8 min)0.1350.497

Two things came out of this, and the second matters more than the first.

The first: with a thousand weak labels, jevlike is not a judge. From scratch it is below a coin flip. With a pretrained encoder it learns something (AUC 0.57) but its ranking is bumpy and its validation loss never beat the base rate for long; the low-learning-rate run converged to predicting the base rate for every block. Files: docs/results/2026-09-18/. This is what one would expect from 1k noisy examples, not a verdict on the architecture; ten thousand hand-checked labels would be a different experiment.

The second: the lr 2e-4 run has the best ECE in the table and is useless. A judge that says 0.5 to everything is perfectly calibrated on a 48%-needed workload and can hide nothing. Calibration was the headline number in the earlier sections because the threshold question is a calibration question, but it cannot stand alone. score() now reports ROC AUC beside ECE (the chance a needed block scores above a not-needed one; 0.5 is a coin flip), and every report prints both. On these blocks Jev's ordering is 0.70 against weak labels, which is real but not dramatic; the lexical baseline's 0.64 says that task-word overlap carries a good part of the signal. What separates Jev is that its low tail is clean (the calibration bins above), which is the part a threshold uses.

Latency, measured

winnow bench on this machine (Windows 11, Python 3.14, warm disk):

PathMedian
interpreter only92 ms
small result, python -m winnow (fast path, no SDK import)274 ms
small result via uv run (what Claude Code runs)374 ms
interpreter + import typesafe_sdk566 ms

So via command hooks a result under WINNOW_MIN_CHARS costs about 370 ms, and a judged result about 850 ms before the request leaves, of which about 470 ms is importing the SDK (mostly httpx2 reading package metadata).

The resident sidecar

winnow serve is a loopback HTTP server that keeps the SDK imported and the judge's HTTP client warm. Claude Code's http hooks POST the event JSON to it and read the hook output from the response body: an empty 2xx is pass-through, a JSON 2xx is the hook output, and a connection failure is a non-blocking error. The SessionStart hook runs winnow serve --ensure, which spawns a detached server if none answers (and replaces one from an older plugin version). The server re-reads ~/.winnow/env when it changes, so a key or threshold edit takes effect without a restart, and exits after 45 idle minutes.

PathMedian
small result via uv run command hook381 ms
small result via the sidecar16 ms

Judged results also skip the ~500 ms SDK import, and TLS reuse takes the judge round trip itself from the 300–500 ms range down toward the 90 ms Jev shows in replay. If the sidecar is down, results pass through unjudged and Claude Code shows the hook error; winnow doctor and winnow serve --status both report it.

Function hooks: the same judge, in-process

Claude Code's function hooks (early access, CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, 2.1.260+) load a TypeScript module into the engine. A tool.call handler there sees the call, runs the tool with next(e), and returns { result }: the structured result Claude will read, with no JSON-on-stdout contract and no transcript path. winnow's module (hooks/winnow.ts) keeps the judge in the Python sidecar and changes three things:

  • Task state from the live session. $.session.messages() gives the conversation as the engine holds it, so the "current task" is the real last human request and the assistant's last sentence, with no file lag. Inside a subagent the messages are the subagent's own, which closes the state bug that 0.3.4 fixed on the http path by guessing the subagent's transcript file.
  • Visible decisions. The sidecar puts a summary of each rewrite in an X-Winnow response header (blocks hidden, characters before and after, the recall key) and the module shows it as a toast. Http hooks have no channel for that.
  • No double judging. Both paths fire for the same call when the flag is on, and the http hook may arrive with the already-rewritten text. The sidecar keys its last 512 answers by tool_use_id and returns the same answer for a repeat, so the rewrite is idempotent whichever path reaches Claude last; a prompt seen again within 30 s gets nothing back, since context must be injected once.

Everything that was measured stays measured: the same extraction, chunking, questions, thresholds and cache, so the calibration numbers above apply unchanged. The module's own tests run under claude plugin test, which loads the plugin into an engine with no network, so they cover task reconstruction and the pass-through path (sidecar down, small result, denied call). A live session with the flag on had not been run when this was written; the first one should confirm that the toast appears, that deduped climbs in winnow serve --status, and that decision lines carry "source": "function-hook".

The engine also offers session.compact (2.1.274+), where a hook can replace the compaction result. That is the layer fast-jev-compaction works at; winnow's calibrated block question could run there over whole results before the summary, and the harness can tell whether it should.

Known limits

  • Line numbers. For Read, stub line numbers are startLine + index, which matches the file when the read started at line 1. If Claude Code's Read output is already line-numbered, the numbers inside the text still agree.
  • Unknown output shapes pass through. Glob and MCP tools that return content-block lists are untouched in v0.
  • Latency. Each judged call adds the judge round trip (150 to 500 ms) plus one summarizer call per hidden group (capped at WINNOW_SUMMARY_MAX_GROUPS). The module's own cost is a loopback POST to the resident sidecar, about 16 ms; without the sidecar every call would pay a Python start (381 ms measured).
  • Jev limits are undocumented. Context window and maximum questions per call are not published. WINNOW_MAX_STATE_CHARS and WINNOW_MAX_BLOCKS are guesses to tune.
  • Windows. The SessionStart hook runs under Git Bash when present, otherwise PowerShell. Paths from Claude Code arrive with backslashes; nothing here assumes otherwise.
  • The venv lives in sidecar/.venv. A plugin's install directory changes on update; moving the environment to ${CLAUDE_PLUGIN_DATA} would make it survive.
  • Long-lived processes must not run through the winnow.exe launcher. On Windows a console-script launcher holds its own executable open for as long as the script runs. The per-session MCP server and the sidecar therefore start as python -m winnow ...; otherwise the next version bump's editable rebuild fails with "file in use" and every uv run in that environment fails with it, hooks included. Found the hard way on 0.3.0.

Roadmap

  1. Ship v0 against the adapter, then swap to Jev when the key arrives. Same code. (Blocked on any key.)
  2. Replay evaluation. Done offline (winnow replay); publish regret vs. threshold once a real judge has been run over the cases.
  3. Read narrowing (PreToolUse on Read). For a large file, ask which regions answer the assistant's stated intent and rewrite the call with offset/limit via updatedInput. Riskier because intent is inferred; do it after the pruning data exists.
  4. Done-ness gate (Stop). A Noul (TypeSafe's yes/no question type, answered with a probability) over the transcript tail: is the task complete? The open question is what state the judge needs: the original request, the todo list, test output, and the final assistant message are the candidates. Decide after looking at real Stop payloads.
  5. Resident sidecar. Done: http hooks against winnow serve, started by SessionStart. 381 ms → 16 ms per small result.
  6. Bigger, human-audited label set. 97 model-labeled blocks is enough to pick an operating point, not enough to publish a curve. The first 20-block human audit disagreed with the model labels (38% agreement) and with the weak label and Jev's ordering alike; the disagreement traced to an underspecified criterion ("Claude had to look at it to know it was irrelevant" counts every block as needed) and to a task excerpt too thin for someone who no longer remembers the session. The labeling sheet now states the criterion; the next audit should show what Claude did next, since a human judging ground truth may use hindsight even though the judge cannot.
  7. Live regret, with a human. Active mode at drop=0.1 with the recall counter is running. winnow review adds the number that matters: the owner judges recent stubs while the session is fresh, and winnow stats reports human regret alongside recall regret. First pass (17 Sep 2026): 7 stubs reviewed, 6 of 6 genuine ones fine, the seventh a synthetic test file reviewed without its task; every hidden block was at or below 0.10. Next: thirty clean reviews before claiming zero regret, then a week at drop=0.15 with the same review.
  8. Digests instead of silence. With summaries off, a stub used to say only "25 lines hidden". A hidden Grep result now names the files and counts; other tools get line count, shape (comments, imports, repetition) and the first line. Deterministic, no model.
  9. Vendor-neutral judge interface. judge.py already has it. Add a fine-tuned encoder backend when one is worth comparing.
  10. An open judge. jevlike tried on the harness (above): not usable at 1k weak labels; revisit only with a much larger, cleaner label set, and report AUC beside ECE for any candidate.
  11. Function-hook mode. Done: 0.4.0 added hooks/winnow.ts beside the http hooks; 0.5.0 made it the only path (the flag is required). Verified live on 2026-09-18. Next: a session.compact pass that judges whole results at compaction time with the same calibrated question.