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.
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.
Red line = the 85% imminent bar.
Copies a prompt that re-derives every number on this page from your own telemetry and CC binary, rather than trusting mine.
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
Every verdict below came off the shipped
2.1.228 binary's payload builders, then was checked against real telemetry on this machine.
| Claim | Filed as | What the binary + data actually say |
|---|---|---|
| 2 | counter 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. |
| 3 | accumulator 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. |
| 4 | warner 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. |
// 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
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}]
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.
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.
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.
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.8bbc44026. Claim 1 (the PostCompact additionalContext
contract) is out of scope here and tracked separately.