Skip to content

Instantly share code, notes, and snippets.

@devonartis
Created May 19, 2026 19:11
Show Gist options
  • Select an option

  • Save devonartis/5ef8f3b70e253479c1aaa001d4d47af3 to your computer and use it in GitHub Desktop.

Select an option

Save devonartis/5ef8f3b70e253479c1aaa001d4d47af3 to your computer and use it in GitHub Desktop.
Context-Gate Hook + Skill — self-contained build handoff

Handoff: Context-Gate Hook + Skill (fully self-contained)

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.


1. What this is

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.


2. How measurement works (the concept)

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_tokens
  • cache_read_input_tokens
  • cache_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- or claude-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.


3. File layout to create

~/.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.


4. FILE: ~/.claude/hooks/context_measure.py

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 None

5. FILE: ~/.claude/hooks/context-gate.py

The 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 use decision: "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)

Wire the hook in ~/.claude/settings.json

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.


6. FILE: ~/.claude/skills/context-check/scripts/check_context.py

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)

7. FILE: ~/.claude/skills/context-check/SKILL.md

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.)


8. Verify before claiming done

Run each check, record actual output, do not assume.

  1. Module imports. uv run ~/.claude/hooks/context_measure.py — runs with no error (no output expected; it's a library).

  2. Hook denies when over. In a session above 30% used, the agent attempting superpowers:executing-plans must be denied with the Context gate DENIED — N% used message. Confirm a denied line in ~/.claude/hooks/context-gate.jsonl.

  3. Hook allows when under. Below 30% → Context gate: N% — CLEAR to proceed. and a gate_pass log line.

  4. Skill standalone. From a real project directory: uv run ~/.claude/skills/context-check/scripts/check_context.py prints a percentage. It must match the measured value the hook logged for the same session (same transcript → same number).

  5. 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). Confirm context_window_for("claude-opus-4-7[1m]") returns 1000000 and context_window_for("claude-3-haiku") returns 200000.

  6. Log has both sources. After running both, context-gate.jsonl contains measured/gate_pass/denied (hook) and skill_measured (skill) entries.


9. Hard constraints — do not violate

  • Token math + model-window table exist only in context_measure.py. Never duplicate them into the hook or skill.
  • Threshold is THRESHOLD in context_measure.py only. Nobody hardcodes 30 anywhere else.
  • Hook deny path: hookSpecificOutput.permissionDecision = "deny" + JSON + exit 0. Never exit code 2. Never decision: "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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment