How gaff works
Count, arm, flush
gaff reads every hook event as JSON on stdin and answers on stdout. Each event goes through up to three stages:
- Count. An event that carries a counted unit appends one line to
the per-session append-only ledger. Tool calls deduplicate on
tool_use_id. - Arm. When a cadence divides the new count, gaff writes a pending marker. gaff emits nothing here. The crossing usually happens on PostToolUse, whose output channel is unsafe to decorate.
- Flush. The safe events are SessionStart, UserPromptSubmit, Stop,
and PostToolBatch. At the next one, gaff merges the pending entries
under the byte cap and emits them as
additionalContext. gaff consumes an entry only when it emits that entry.
Sessions
The counters are per session. gaff reads the session_id from the event
payload. A subagent has its own session and its own counters. gaff remind runs inside a session and resolves the session from --session,
then GAFF_SESSION_ID, then the host’s own variable, which each adapter
names (Claude Code exports CLAUDE_CODE_SESSION_ID to a shell
subprocess).
Compaction
A context compaction erases everything that gaff already delivered. On a
SessionStart whose source is compact, gaff re-arms the consumed
one-shots and injects every section again. The ledger survives, and the
cadences continue.
Failure posture
No gaff failure blocks a session. Every internal failure degrades to a
silent passthrough at exit 0, or to a non-blocking error at exit 1. No
failing path emits the blocking exit code 2, and the tests enforce that.
A degradation is loud. It writes a warning on stderr and a degraded
marker in the state directory, and gaff doctor reports it.