You have no access to the existing code. This document contains everything: what to build, why, every file's complete source, and how to verify. Build from this doc alone.
A mechanism that measures how full the AI session's context window is and stops the agent from starting expensive multi-step work when it's too full (it would run out of room mid-task and lose state).
Two delivery forms, both built here:
- Hook (
context-gate.py) — a PreToolUse hook Claude Code runs automatically before the agent invokes a Skill. It can hard-block the tool call. This is the enforcer. - Skill (
context-check) — the agent (or user) runs it on demand to ask "how much context is left?". It cannot block — it only returns text the agent is told to obey. This is the advisory check.
Keep both. Only the hook can truly block. The skill adds visibility and on-demand checks.
Claude Code writes a transcript of the session as JSON-lines (.jsonl), one
JSON object per line. Each assistant entry carries a usage object:
input_tokenscache_read_input_tokenscache_creation_input_tokens
Sum of those three = tokens currently occupying the context window for that turn. Divide by the model's context-window size → percent full.
Context-window size depends on the model string (message.model in the
transcript):
- Anything containing
[1m]→ 1,000,000 tokens. claude-opus-4-orclaude-sonnet-4-prefix → 1,000,000 (the 4.x families ship 1M; the transcript strips the[1m]suffix so match the prefix).- Older
claude-opus/claude-sonnet/claude-haiku→ 200,000. - Unknown / missing → 200,000 default.
Getting the window wrong is the main historical bug: hardcoding 200k over-reported a 1M session ~5x and falsely blocked work.
Threshold: block above 30% used.
~/.claude/hooks/context_measure.py # shared math — single source of truth
~/.claude/hooks/context-gate.py # the hook (imports context_measure)
~/.claude/skills/context-check/SKILL.md
~/.claude/skills/context-check/scripts/check_context.py # the skill (imports context_measure)
Log file (created at runtime, both hook and skill append to it):
~/.claude/hooks/context-gate.jsonl
All Python files use the PEP 723 inline-metadata header and run via uv run.
Zero third-party dependencies.
Shared module. The token math and model-window table live here and nowhere else. Both the hook and the skill import from this file. Never copy these functions into another file — two copies will drift.
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Shared context measurement.
Pure logic only — no stdin parsing, no blocking, no verdict printing.
Imported by:
- context-gate.py (the PreToolUse hook / enforcer)
- context-check skill script (the advisory checker)
Single source of truth for: token math, per-model context windows, the
block threshold, and the JSONL logger.
"""
import json
from datetime import datetime
from pathlib import Path
THRESHOLD = 30 # percentage — block/stop above this
DEFAULT_CONTEXT_WINDOW = 200_000 # tokens — used when model is unknown
LOG_FILE = Path.home() / ".claude" / "hooks" / "context-gate.jsonl"
# Per-model context window in tokens. Matched against the assistant entry's
# `message.model` string, substring match, ORDER-SENSITIVE — first entry that
# appears in the model string wins, so keep most specific patterns first.
#
# - Any model string containing "[1m]" runs the 1M extended window.
# - The 4.x Opus/Sonnet families ship 1M by default, but the transcript's
# `message.model` strips the "[1m]" suffix, so match the family prefix.
# - Older/smaller models fall back to the 200k window.
MODEL_CONTEXT_WINDOWS: list[tuple[str, int]] = [
("[1m]", 1_000_000),
("claude-opus-4-", 1_000_000),
("claude-sonnet-4-", 1_000_000),
("claude-opus", 200_000),
("claude-sonnet", 200_000),
("claude-haiku", 200_000),
]
def context_window_for(model: str | None) -> int:
"""Context-window size in tokens for the given model string.
Falls back to DEFAULT_CONTEXT_WINDOW if model is None/empty or no pattern
matches. Matching is substring-based and order-sensitive — the first
MODEL_CONTEXT_WINDOWS entry found in `model` wins, so keep specific
patterns (e.g. "[1m]") above family defaults.
"""
if not model:
return DEFAULT_CONTEXT_WINDOW
for pattern, window in MODEL_CONTEXT_WINDOWS:
if pattern in model:
return window
return DEFAULT_CONTEXT_WINDOW
def log(event: str, detail: dict | None = None) -> None:
"""Append one JSON line to the shared debug log. Best-effort."""
entry = {"timestamp": datetime.now().isoformat(), "event": event}
if detail:
entry.update(detail)
try:
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
with LOG_FILE.open("a") as f:
f.write(json.dumps(entry) + "\n")
except Exception:
pass # logging must never break the caller
def measure_context(transcript_path: str) -> float | None:
"""Read the transcript JSONL and compute context usage percentage.
Finds the most recent main-chain assistant entry with usage data.
Skips sidechain entries (subagents) and entries without usage.
Returns percentage (0-100), or None if it cannot be measured.
"""
if not transcript_path:
return None
path = Path(transcript_path)
if not path.exists():
return None
try:
lines = path.read_text().strip().split("\n")
except Exception:
return None
# Walk newest-first; the latest assistant turn reflects current context.
for line in reversed(lines):
if not line.strip():
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if entry.get("isSidechain", False):
continue # subagent turn, not the main context
if entry.get("type") != "assistant":
continue
msg = entry.get("message", {})
usage = msg.get("usage")
if not usage:
continue
input_tokens = usage.get("input_tokens", 0)
cache_read = usage.get("cache_read_input_tokens", 0)
cache_create = usage.get("cache_creation_input_tokens", 0)
total = input_tokens + cache_read + cache_create
if total == 0:
continue
model = msg.get("model")
window = context_window_for(model)
pct = (total / window) * 100
log("measured", {
"model": model,
"context_window": window,
"input_tokens": input_tokens,
"cache_read": cache_read,
"cache_create": cache_create,
"total": total,
"pct": round(pct, 1),
})
return pct
return NoneThe hook. Imports the shared module. Keeps only hook-specific concerns: reading the stdin payload Claude Code sends, filtering to the gated execution skills, and the deny mechanism.
Critical: the deny path prints
hookSpecificOutput.permissionDecision = "deny"JSON and exits 0. Do not use exit code 2 (known bug) and do not usedecision: "block"(deprecated). Wrong schema = silent no-op, the gate does nothing.
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Context Gate: PreToolUse hook. Independently measures context usage from the
session transcript and hard-blocks execution skills when over threshold.
Claude Code passes a JSON payload on stdin containing `tool_name`,
`tool_input`, and `transcript_path`. Blocking uses
hookSpecificOutput.permissionDecision = "deny" (exit 0 + JSON). NOT exit
code 2 (known bug). NOT decision: "block" (deprecated).
"""
import json
import sys
from pathlib import Path
# Import the shared measurement module sitting next to this file.
sys.path.insert(0, str(Path(__file__).parent))
from context_measure import measure_context, THRESHOLD, log # noqa: E402
# Only these skills are gated — they kick off long, multi-step work that
# must not start with little context remaining.
EXECUTION_SKILLS = {
"superpowers:executing-plans",
"superpowers:subagent-driven-development",
"superpowers:dispatching-parallel-agents",
}
def deny(reason: str) -> None:
"""Hard-block the tool call using the correct permission schema."""
log("denied", {"reason": reason})
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}))
sys.exit(0)
raw = sys.stdin.read()
try:
data = json.loads(raw)
except json.JSONDecodeError:
log("parse_error", {"raw": raw[:200]})
sys.exit(0)
tool_name = data.get("tool_name", "")
tool_input = data.get("tool_input", {})
skill_name = tool_input.get("skill", "")
transcript_path = data.get("transcript_path", "")
# Ignore everything that is not a gated execution skill.
if tool_name != "Skill" or skill_name not in EXECUTION_SKILLS:
log("hook_fired", {"tool_name": tool_name, "skill": skill_name, "matched": False})
sys.exit(0)
log("hook_fired", {"tool_name": tool_name, "skill": skill_name, "matched": True})
pct = measure_context(transcript_path)
if pct is None:
# Cannot measure — allow but warn rather than block blindly.
log("gate_pass", {"reason": "unmeasurable", "skill": skill_name})
print("Context gate: could not measure context (no transcript). Proceeding with caution.")
sys.exit(0)
if pct > THRESHOLD:
deny(
f"Context gate DENIED — {pct:.0f}% used (threshold: {THRESHOLD}%). "
f"Save MEMORY.md and FLOW.md with current state, then tell the user: "
f"'Context at {pct:.0f}%. State saved. Start a fresh session to continue.'"
)
else:
log("gate_pass", {"pct": round(pct, 1), "skill": skill_name})
print(f"Context gate: {pct:.0f}% — CLEAR to proceed.")
sys.exit(0)Add (or merge) this under hooks:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Skill",
"hooks": [
{
"type": "command",
"command": "uv run ~/.claude/hooks/context-gate.py"
}
]
}
]
}
}If a PreToolUse array already exists, append this object to it — don't
overwrite siblings.
The skill's script. The skill gets no stdin payload (unlike the hook), so
it must locate the current session transcript itself, then reuse the same
measure_context. It cannot block — it always exits 0 and prints a verdict
the agent is instructed (by SKILL.md) to obey.
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
context-check skill script.
Unlike the hook, a skill receives no transcript_path. Claude Code stores
session transcripts at:
~/.claude/projects/<slug>/<session>.jsonl
where <slug> is the current working directory with every "/" replaced by
"-". We pick the newest *.jsonl in that directory as the current session.
CAVEAT: "newest by mtime" is correct in normal single-session use but is
ambiguous if multiple sessions run in the same project directory at once.
The hook never had this problem because Claude Code handed it the
authoritative transcript_path.
"""
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path.home() / ".claude" / "hooks"))
from context_measure import measure_context, THRESHOLD, log # noqa: E402
def find_transcript() -> str | None:
cwd = os.getcwd()
slug = cwd.replace("/", "-")
project_dir = Path.home() / ".claude" / "projects" / slug
if not project_dir.is_dir():
return None
jsonls = sorted(
project_dir.glob("*.jsonl"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
return str(jsonls[0]) if jsonls else None
transcript = find_transcript()
pct = measure_context(transcript) if transcript else None
if pct is None:
log("skill_measured", {"result": "unmeasurable"})
print("Context check: could not measure (no transcript found). Proceed with caution.")
sys.exit(0)
if pct > THRESHOLD:
log("skill_measured", {"pct": round(pct, 1), "result": "over"})
print(
f"Context check: {pct:.0f}% used — OVER threshold {THRESHOLD}%.\n"
f"STOP. Save MEMORY.md and FLOW.md with current state, then tell the user:\n"
f'"Context at {pct:.0f}%. State saved. Start a fresh session to continue."'
)
sys.exit(0)
log("skill_measured", {"pct": round(pct, 1), "result": "clear"})
print(f"Context check: {pct:.0f}% used (threshold {THRESHOLD}%). CLEAR to proceed.")
sys.exit(0)The frontmatter description controls when the agent auto-invokes the skill.
Body tells the agent to run the script and obey the verdict verbatim.
---
name: context-check
description: Use before starting any multi-step plan execution, parallel
agent dispatch, or other long task, OR when the user asks "how much
context is left" / "are we running out of room". Measures real
context-window usage from the session transcript and returns CLEAR or a
STOP-and-save-state instruction.
---
# Context Check
Run the script, then obey the verdict exactly as printed.
\`\`\`
uv run ~/.claude/skills/context-check/scripts/check_context.py
\`\`\`
- **CLEAR** → continue with the work.
- **OVER threshold** → do exactly what the verdict says: save MEMORY.md and
FLOW.md with current state, tell the user to start a fresh session, and do
NOT start the execution work.
- **Unmeasurable** → tell the user it could not measure; proceed only on
explicit user confirmation.(Note: in the real file, remove the backslashes before the triple backticks — they're only escaped here so this handoff renders.)
Run each check, record actual output, do not assume.
-
Module imports.
uv run ~/.claude/hooks/context_measure.py— runs with no error (no output expected; it's a library). -
Hook denies when over. In a session above 30% used, the agent attempting
superpowers:executing-plansmust be denied with theContext gate DENIED — N% usedmessage. Confirm adeniedline in~/.claude/hooks/context-gate.jsonl. -
Hook allows when under. Below 30% →
Context gate: N% — CLEAR to proceed.and agate_passlog line. -
Skill standalone. From a real project directory:
uv run ~/.claude/skills/context-check/scripts/check_context.pyprints a percentage. It must match themeasuredvalue the hook logged for the same session (same transcript → same number). -
Window regression. A
claude-opus-4-7[1m]session must resolve to a 1,000,000 window (≈5x lower percent than if 200k were assumed). Confirmcontext_window_for("claude-opus-4-7[1m]")returns1000000andcontext_window_for("claude-3-haiku")returns200000. -
Log has both sources. After running both,
context-gate.jsonlcontainsmeasured/gate_pass/denied(hook) andskill_measured(skill) entries.
- Token math + model-window table exist only in
context_measure.py. Never duplicate them into the hook or skill. - Threshold is
THRESHOLDincontext_measure.pyonly. Nobody hardcodes30anywhere else. - Hook deny path:
hookSpecificOutput.permissionDecision = "deny"+ JSON +exit 0. Never exit code 2. Neverdecision: "block". - Skill always
exit 0. It advises; it cannot block. Only the hook enforces. - All Python files: PEP 723 self-contained, run via
uv run, zero dependencies. - Keep the hook. Adding the skill does not replace it — losing the hook loses the only real hard block.