#3321 · claims 2-4 · playground · 2026-08-12

A counter measuring a fraction of context, checked against the whole.

The PreCompact task-done blocker was gated on estimatedTokens ≥ 85% of the context window. That counter has exactly one writer, registered under matcher Read|Grep|Glob|WebFetch|WebSearch, so it sees nothing from Bash, Edit, Write, agent results, MCP, or conversation turns. The bar was a whole-window figure. Across 2,246 recorded PreCompact decisions since the gate landed, it passed zero times. Lowering the bar would not have helped: the denominator was wrong, so the reading was never comparable.

Try it: why lowering the threshold could not work

Drag the sliders. The estimator only ever grows from read-shaped tool output, so on a real session it tracks a small, unrelated fraction of what is actually in the window. Watch the two gates disagree.

measured
estimator

Red line = the 85% imminent bar.

Before · gated on the estimator

After · gated on measured usage

Copies a prompt that re-derives every number on this page from your own telemetry and CC binary, rather than trusting mine.

Where the real number comes from

CC reports usage per turn in the session transcript, and transcript_path is in the base payload of every hook event. The last main-chain assistant turn's prompt size is the occupied context, in the same unit as the window.

// observed in a live 2.1.228 transcript
"usage": { "input_tokens": 2,
           "cache_creation_input_tokens": 2179,
           "cache_read_input_tokens": 302054,
           "output_tokens": 111 }

occupied = 2 + 2179 + 302054 = 304,235   ← output_tokens excluded: occupancy is the prompt, not the reply

// the estimator it replaces, for the same turn:
Math.ceil(text.length / 3.5), summed over Read/Grep/Glob/WebFetch/WebSearch results only

Three dead reads, one oracle

Every verdict below came off the shipped 2.1.228 binary's payload builders, then was checked against real telemetry on this machine.

ClaimFiled asWhat the binary + data actually say
2counter structurally ~0 Counter increments fine (max recorded 707,702). The defect is a unit mismatch: subset numerator, whole-window bar. 0 of 2,246 gate passes.
3accumulator never increments Refuted for estimatedTokens. Confirmed for the cache fields: SubagentStop sends neither, and is_fork is in no hook payload at all. 0 of 11,020 rows carry cache_hit_pct.
4warner has no data source Confirmed. The StatusLine bridge file lost its writer on 2026-03-29 (2de086fa9) and the hook silently no-op'd until #3427 deleted it. Stale docs still advertised it.
The SubagentStop payload, in full no is_fork, no cache fields
// 2.1.228 payload builder
m = { ...zh(e, void 0, i),
      hook_event_name: "SubagentStop", stop_hook_active, agent_id,
      agent_transcript_path, agent_type, last_assistant_message,
      background_tasks, session_crons }

// the shared base zh() contributes:
{ session_id, transcript_path, cwd, prompt_id,
  permission_mode, agent_id, agent_type, effort }

is_fork                     absent  (exists only on CC's internal tengu_agent_tool_selected event)
cache_creation_input_tokens absent
cache_read_input_tokens     absent

Found while probing: PreCompact stdout was reaching the model

Not one of the six filed claims, and the sharpest rebuttal to the issue's title. On PreCompact, a command hook's stdout is not an envelope channel — CC collects it as the compaction summarizer's custom instructions.

// 2.1.228 PreCompact aggregator
a = results.filter(d => d.succeeded && !d.blocked && d.output.trim())
return { newCustomInstructions: a.join("\n\n"), ... }

// and for a command hook that exits 0:
D = M ? (json.reason || stderr || "") : status===0 ? stdout : stderr

// so every ork PreCompact hook was contributing this to the summary prompt:
{"continue":true,"suppressOutput":true}   ← x3, on every compaction

suppressOutput governs transcript display and does not touch this read. Verified live through the real dispatcher after the fix:

$ printf '%s' "$PRE_COMPACT" | node src/hooks/bin/run-hook.mjs <hook>

lifecycle/pre-compact-saver             stdout=[]
lifecycle/pre-compact-guard             stdout=[]
lifecycle/pre-compact-task-done-prompt  stdout=[]
lifecycle/webhook-forwarder             stdout=[]

controls — the fix is scoped by EVENT, not by hook name:
lifecycle/webhook-forwarder  (PostCompact) stdout=[{"continue":true,"suppressOutput":true}]
posttool/context-crossing-warn (PostToolUse) stdout=[{"continue":true,"suppressOutput":true}]

Why the fix arms a blocker, and what bounds it

The gate was load-bearing in the wrong direction

Fixing the units turns on a veto with zero production history. pre-compact-task-done-prompt took _input and never read trigger, so it would have blocked automatic compaction — the identical defect fixed for pre-compact-guard three commits earlier (#3452), and worse here because at the ceiling CC offers only "/compact or /clear", so a veto leaves /clear, discarding the state the nudge exists to protect. Auto now passes through as the first statement in the handler.

Demoting the block to advisory was considered and rejected on evidence

The issue floated demoting decision:'block' to a warning. The PreCompact aggregator reads d.succeeded, d.blocked, d.output and d.command — it never reads a result's systemMessage. An "advisory" on this event would be inert, which is the exact class of dead output this issue is about. Block is the only channel PreCompact has; the trigger check bounds it instead.

Why the tests did not catch any of it

The suites were green throughout, because they built the impossible input by hand and then asserted the behaviour only that input produces.

// the old fixture
readFileSync.mockReturnValue(JSON.stringify({
  estimatedTokens: 169_500,   ← a value the real writer reaches only for a session
  ...                            made entirely of file reads
}));
expect(result.decision).toBe('block');   green, forever

Same closed-loop pattern as the cacheReadTokens fixtures deleted in #3427 and the TeammateIdle duration tests deleted in the payload-key fix. Both suites now drive the measured source. transcript-context.test.ts runs against real files on disk using the literal usage block observed in a live transcript — deliberately not an fs mock, because a mock returning a hand-built shape would rebuild the same closed loop. precompact-stdout-contract.test.ts spawns the real bin/run-hook.mjs, because the stdout defect lives in the emit path and no unit test on a hook's return value could ever have seen it.

Sources: shipped Claude Code 2.1.228 payload builders and PreCompact aggregator · 2,248 rows of pre-compact-decisions.jsonl across 5 repos (2026-04-23 → 2026-08-10) · 11,020 rows of subagent-quality.jsonl · 93 real *-token-accum.json state files · a live 2.1.228 session transcript.
Claims 5 and 6 were fixed earlier by 8bbc44026. Claim 1 (the PostCompact additionalContext contract) is out of scope here and tracked separately.