Ask Context Gate
Hard PreToolUse gate on AskUserQuestion that blocks questions whose decision context is invisible to the user. Three deterministic gates: deictic references to unseen context ('with that context', 'see above'), identical re-asks after the user pushed back in free text, and mid-workstream questions whose payload (question text, option descriptions, previews) and turn text both carry too little context. Block messages teach the exact recovery (put the context in the payload, then re-call). Fail-open on transcript errors; CCGM_ASK_CONTEXT_OFF=1 escape hatch.
Tags
README
ask-context
Hard enforcement that every AskUserQuestion the agent asks carries visible decision context. A deterministic PreToolUse hook blocks the question before it renders whenever the supporting context exists only in the agent's thinking blocks or collapsed tool output — the two surfaces the user can never read.
The Failure This Prevents
Mid-workstream, an agent runs a long chain of tool calls, forms a clear picture in its thinking, then asks: "With that context: disposition for PR #2967?" — and the user's screen shows a bare question pointing at context that was never displayed. When the user answers (via Other) "you didn't give me any context," the agent — which genuinely believes it provided context — re-presents the identical question. Advisory rules cannot fix a false belief; only a gate that measures the surfaces that reach the user can.
At question time exactly one surface reaches the user verbatim: the question payload itself (question text, option labels/descriptions, previews). Plain assistant text is a second surface only where it opens the turn or ends it. Text written between tool calls is not reliable: on Claude Fable 5.1 the API returns it as a progress-update thinking block, which Claude Code renders as a one-line status and persists as thinking. Thinking is invisible. Raw tool output is collapsed noise.
What This Module Does
Installs ask-context-gate.py, a PreToolUse hook wired (via settings.json merge) to AskUserQuestion. Three gates, each an exit-2 hard block whose message contains the exact recovery recipe (put the context in the payload, then re-call):
| Gate | Basis | Blocks |
|---|---|---|
| G1 Deictic | Payload only — always runs | Question text or option descriptions referencing invisible context: "with that context", "given the above", "as described above", "see above", "the analysis above", … |
| G2 Repeat | Transcript | Re-presenting a question set identical to a prior ask the user answered in free text (Other), dismissed, or never answered. Prior asks answered by picking an offered option label stay allowed — recurring approval questions in loop workflows are legitimate. |
| G3 Invisible context | Payload + transcript | Asking mid-workstream (≥1 tool call since the user's last real message) when the payload carries fewer than 200 characters of context (question text + option descriptions + previews) and fewer than 200 characters of assistant text reached the transcript this turn (ASK_CONTEXT_MIN_CHARS overrides both). First-action questions right after a user message are exempt — the user's own message is the context. |
Transcript parsing details: every content block of an assistant response flushes as its own JSONL line before the tool executes, so the gates can see the current turn at PreToolUse time. Sidechain (subagent) entries are ignored; the in-flight call's own flushed tool_use entry is excluded so the hook never trips on itself. A turn starts at the human's message — user-role entries the harness appends (skill expansions and image companions marked isMeta, <system-reminder> wrappers, <local-command-*> output) do not start one. A typed slash command does.
Known Transcript Limitation
On Claude Code 2.1.267 with Claude Fable 5.1 (verified 2026-09-09 against the raw Messages API and in live sessions), assistant text written between a tool result and the next tool call is not stored as a text block. The API returns it as a progress-update thinking block — empty under the default display, a one-line summary under display: "updates", which Claude Code requests for first-party sessions and renders as a status line. Only text that opens the turn or ends it is stored verbatim. G3 therefore treats the payload as the primary surface and the transcript count as a second way to pass; it never counts thinking blocks, because a summarized status line is not the brief.
Fail-Open Contract
- Missing/unreadable transcript → transcript gates (G2/G3) stand down; G1 still runs (payload-only)
- Malformed transcript lines → skipped individually
- Any unexpected exception → allow (a UX gate must never brick a session)
- Escape hatch:
CCGM_ASK_CONTEXT_OFF=1— for debugging the hook, not for skipping the context
Files
| File | Type | Description |
|---|---|---|
hooks/ask-context-gate.py |
hook | The PreToolUse gate (dependency-free, stdlib only) |
rules/ask-context.md |
rule | The visibility model, required ask pattern, banned phrasings, re-ask protocol |
settings.partial.json |
config (merge) | PreToolUse wiring for AskUserQuestion |
Manual Installation
mkdir -p ~/.claude/hooks ~/.claude/rules
cp modules/ask-context/hooks/ask-context-gate.py ~/.claude/hooks/
cp modules/ask-context/rules/ask-context.md ~/.claude/rules/
# Merge modules/ask-context/settings.partial.json into ~/.claude/settings.json
# (adds a PreToolUse matcher for AskUserQuestion)
Or via the installer: bash start.sh --add ask-context
Testing
bash modules/ask-context/tests/test-ask-context-gate.sh
23 cases: deictic blocks (question + option description), first-action exemption, in-flight self-exclusion, mid-workstream dark blocks, transcript-brief passes, payload-surface passes, sidechain text exclusion, the evidence-shaped Fable transcript (context-bearing payload passes, bare payload still blocks, turn-opening text counted back to the human's message), harness-injected entry skipping with its two over-skip guards, repeat-after-free-text / rejection / interruption blocks, offered-option re-ask allowance, escape hatch, fail-open on missing/malformed transcripts, threshold override on both surfaces.
Will install
| Path | Action | Target | Type |
|---|---|---|---|
hooks/ask-context-gate.py | → | hooks/ask-context-gate.py | hook |
rules/ask-context.md | → | rules/ask-context.md | rule |
settings.partial.json | merge | settings.json | config |
Dependencies
Required by
No other module depends on this one.
Included in presets
Install this module
Agent prompt
Recommended for agent users -- hands the whole install off to your assistant.
Fetch https://ccgm.dev/modules/ask-context.md and install this module into my Claude Code setup.
Native plugin marketplace
One command via the native plugin marketplace -- additive, does not merge settings.json.
claude plugin install ask-context@ccgm
The marketplace path is additive, not a replacement: it installs commands, agents, and skills as native plugin components, but it does not perform the bash installer's deep settings.json merge, and it does not write the always-loaded global CLAUDE.md context. Rules are only injected via an opt-in SessionStart hook rather than being auto-loaded. Use the bash installer when those pieces matter to you.
Files
rule (1)
rules/ask-context.md
# Ask Context: Every Question Carries Its Own Context Never ask the user a question whose context they cannot see on screen. Mid-workstream, the question payload is the only surface that reaches them verbatim. A PreToolUse hook (`ask-context-gate.py`) blocks (exit 2) AskUserQuestion calls that violate this. ## What the User Sees | Surface | Visible? | |---------|----------| | The question payload: question text, option labels, option descriptions, previews | Always, verbatim | | Plain response text that opens the turn (before the first tool call) or ends it | Yes | | Response text written between tool calls | Not reliably. On Fable 5.1 it arrives as a one-line progress summary and is stored as `thinking` | | Thinking blocks | Never | | Raw tool output | Collapsed, not readable context | | Earlier turns or a subagent's context | Gone | Two failures follow. The classic one: a long workstream forms a clear picture in thinking, then asks "disposition for PR #2967?" and the user sees a bare question. The newer one: a multi-paragraph brief written right before the call reaches the user as one summarized sentence. In both cases "I wrote it" is not "they saw it." ## The Required Pattern Before any AskUserQuestion that follows tool work, put the context in the payload: 1. **Make the question text stand alone.** Name the thing (repo, PR number, file, symptom), say why the decision surfaced now, and restate the key evidence as 2 to 6 short facts from tool output, never references to it. 2. **Put each option's consequences in its `description`.** That field is guaranteed-visible; use it for stakes, not adjectives. 3. **Use `preview` for bulky evidence** (code, diffs, mockups). 4. **Count response text only where it is delivered verbatim**: turn-opening or turn-ending text. A brief written between tool calls may not reach the user. The gate measures the payload as question text plus option descriptions plus previews. Labels and headers name a choice; they do not explain it. ## Banned Phrasings Question text and option descriptions must not point at the scrollback: "with that context", "given the above", "given this analysis", "as described/shown/mentioned above", "see above", "per the above", "the analysis/findings/summary above", "the context I provided", "in light of the above", "based on my analysis above". Inline the fact instead. ## Re-Ask Protocol If the user answers via Other with free text (especially "what context?", "explain", "I don't have enough to decide") or dismisses the question, never re-send the same payload; the gate blocks identical re-asks after a free-text or dismissed response. Re-call with a rewritten payload that embeds the context and answers what they said. A question the user answered by picking an offered option may be asked again later. ## Gate Mechanics | Gate | Trigger | Fires when | |------|---------|-----------| | G1 Deictic | Payload only | Question or description references invisible context | | G2 Repeat | Transcript | Identical question set re-asked after a free-text, dismissed, or interrupted response | | G3 Invisible context | Payload + transcript | At least one tool call since the user's last message, the payload carries under 200 chars of context, and under 200 chars of your text reached the transcript this turn (`ASK_CONTEXT_MIN_CHARS` overrides both) | The transcript gates fail open (unreadable transcript allows); G1 always runs. A turn starts at the human's message; harness-appended user messages (skill expansions, image companions, system-reminder wrappers, local-command output) do not start one. `CCGM_ASK_CONTEXT_OFF=1` disables the hook for debugging it, never for skipping the context. When the gate blocks, move the context into the payload and re-call; the block message contains the recipe. Writing a brief between tool calls and re-calling does not pass, because that brief never reaches the transcript as text.
hook (1)
hooks/ask-context-gate.py
#!/usr/bin/env python3
"""
PreToolUse hook that HARD-BLOCKS AskUserQuestion calls whose decision context
is invisible to the user.
Why: mid-workstream, an agent asks a multiple-choice question believing it has
already provided the supporting context — but that context lives in its
thinking blocks (never rendered) or in collapsed tool output. The user's screen
shows a bare question like "With that context: disposition for PR #2967?" and
nothing else. When the user replies "you didn't give me any context" via the
Other field, the agent re-presents the identical question, because nothing
forces the context onto a visible surface. An advisory rule cannot fix this:
the agent genuinely believes the context was shown. Only a deterministic gate
that measures the surfaces that reach the user can.
At question time exactly one surface is guaranteed to reach the user verbatim:
(a) the AskUserQuestion payload itself — question text, option labels,
option descriptions, previews.
Plain assistant text is a second surface, but only where it is delivered
verbatim:
(b) text that opens the turn (before the first tool call) or ends it.
Text written BETWEEN tool calls is not reliable. On Claude Fable 5.1 the API
returns it as a progress-update `thinking` block — empty under the default
display, a one-line summary under `display: "updates"` (which Claude Code
requests for first-party sessions and renders as a status line). The
transcript records that block as `thinking`, not `text`. Thinking is
invisible. Raw tool output is collapsed noise. This hook enforces that at
least one surface that actually reaches the user carries the context.
Classification: bypass-retained. Denials use exit 2 (the semantics of
hook_utils.hard_block(), inlined here so the hook is dependency-free and works
under both the symlink install and the plugin projection).
THREE GATES, in order:
G1 — DEICTIC REFERENCE (payload-only, always on)
Blocks when the question text or an option description references context
that is not in the payload: "with that context", "as described above",
"the analysis above", "see above", "given the above", etc. A question that
points at the scrollback is not self-contained by definition.
G2 — IDENTICAL RE-ASK (transcript-based)
Blocks re-presenting a question set identical to a previous ask that the
user answered in their own words (Other free text), rejected, or never
answered. That is the observed loop: user says "no context", agent re-asks
verbatim. A previous identical ask answered with an OFFERED option label
stays allowed — recurring approval questions in loop workflows are
legitimate.
G3 — INVISIBLE CONTEXT (payload + transcript)
Blocks when the agent is mid-workstream (>= 1 tool call since the user's
last real message) AND neither surface carries MIN_VISIBLE_CHARS of
context: the payload (question text + option descriptions + previews) is
thin, and fewer than MIN_VISIBLE_CHARS of assistant text reached the
transcript this turn. The payload check is the reliable one — it is the
only surface delivered verbatim mid-turn; the transcript count is a
second way to pass for a brief that opened the turn, or for models that
still return between-tool-call text as `text` blocks. A question asked as
the first action after a user message is exempt — the user's own message
is the context.
ALLOWS:
- Any tool other than AskUserQuestion (defensive; the matcher already scopes)
- CCGM_ASK_CONTEXT_OFF=1 in the environment (escape hatch / debugging)
- Payloads that pass G1 when the transcript is missing or unparseable —
the transcript gates FAIL OPEN; a gate that cannot read the session must
not brick it. G1 needs only the payload, so it always runs.
The block messages are the real mechanism: each one tells the model exactly
how to recover (put the context in the payload — question text that names the
thing and restates the evidence, option descriptions that state consequences,
previews for bulky evidence — then re-call), so the retry lands correctly
without the user having to prompt for context ever again.
Transcript shape this hook relies on: every content block of an assistant
response is its own JSONL entry, flushed before the tool executes. The current
call's own tool_use entry is usually already flushed; both transcript gates
exclude the final entry matching the current tool_input so the hook never
trips on its own call. What the transcript does NOT reliably hold is assistant
text written between tool calls (see above): only first-of-turn and turn-final
text is guaranteed to appear as a `text` block. The harness also appends
user-role entries the human never typed — skill expansions and image
companions (`isMeta: true`), `<system-reminder>` wrappers, `<local-command-*>`
output — and a turn starts at the human's message, not at one of those.
"""
from __future__ import annotations
import json
import os
import re
import sys
DEFAULT_MIN_VISIBLE_CHARS = 200
# Phrasings that reference context outside the payload. Deliberately narrow:
# every pattern is a pure pointer at the scrollback ("above", "that context",
# "the analysis I gave"), never a phrase with a legitimate self-contained use.
DEICTIC_PATTERNS = [
r"\bwith\s+(?:all\s+)?(?:that|this)\s+(?:context|analysis)\b",
r"\bwith\s+the\s+(?:above|prior|earlier)\s+(?:context|analysis)\b",
r"\bgiven\s+(?:the\s+)?above\b",
r"\bgiven\s+(?:that|this)\s+(?:context|analysis)\b",
r"\b(?:as|per)\s+(?:described|shown|mentioned|discussed|noted|outlined|summarized|explained|detailed)\s+(?:above|earlier|previously)\b",
r"\bsee\s+above\b",
r"\bper\s+the\s+above\b",
r"\bbased\s+on\s+(?:the|my)\s+(?:above|(?:analysis|context|findings|summary)\s+above)\b",
r"\bthe\s+(?:context|analysis|details?|findings|summary|evidence)\s+(?:above|below)\b",
r"\b(?:context|analysis|details?|findings|summary|evidence)\s+i\s+(?:just\s+)?(?:provided|gave|shared|showed|outlined)\b",
r"\bin\s+light\s+of\s+the\s+above\b",
]
DEICTIC_RE = re.compile("|".join(DEICTIC_PATTERNS), re.IGNORECASE)
ANSWERED_MARKER = "Your questions have been answered"
REJECTED_MARKER = "doesn't want to proceed"
# Spans the harness injects into user-role entries. A user entry whose text is
# nothing but these was not typed by the human. <command-name> is deliberately
# absent: a typed slash command is a human action and therefore a turn
# boundary, even though the harness wraps it.
INJECTED_SPAN_RE = re.compile(
r"<(system-reminder|local-command-caveat|local-command-stdout|local-command-stderr)\b[^>]*>"
r"[\s\S]*?</\1\s*>"
r"|\[Image: source: [^\]]*\]",
re.IGNORECASE,
)
def hard_block(message: str) -> None:
"""Exit-2 denial: stderr is fed back to the model as the tool error."""
sys.stderr.write(message)
sys.exit(2)
# ─── Payload helpers ─────────────────────────────────────────────────────────
def iter_question_strings(tool_input):
"""Yield (where, text) for every user-visible string in the payload."""
questions = tool_input.get("questions")
if not isinstance(questions, list):
return
for q in questions:
if not isinstance(q, dict):
continue
text = q.get("question")
if isinstance(text, str):
yield "question", text
options = q.get("options")
if not isinstance(options, list):
continue
for opt in options:
if isinstance(opt, dict) and isinstance(opt.get("description"), str):
yield "option description", opt["description"]
def normalized_question_set(tool_input):
"""Whitespace/case-normalized question texts, order-independent."""
out = []
questions = tool_input.get("questions")
if isinstance(questions, list):
for q in questions:
if isinstance(q, dict) and isinstance(q.get("question"), str):
out.append(re.sub(r"\s+", " ", q["question"]).strip().lower())
return tuple(sorted(out))
def option_labels(tool_input):
"""All offered option labels across every question in a payload."""
labels = []
questions = tool_input.get("questions")
if isinstance(questions, list):
for q in questions:
if not isinstance(q, dict):
continue
for opt in q.get("options") or []:
if isinstance(opt, dict) and isinstance(opt.get("label"), str):
labels.append(opt["label"])
return labels
def payload_context_chars(tool_input):
"""Characters of decision context the payload itself carries.
Counts question text, option descriptions, and option previews — the
fields that explain a choice. Labels and headers name a choice, they do
not explain it, so they are excluded.
"""
total = 0
questions = tool_input.get("questions")
if not isinstance(questions, list):
return 0
for q in questions:
if not isinstance(q, dict):
continue
if isinstance(q.get("question"), str):
total += len(q["question"].strip())
for opt in q.get("options") or []:
if not isinstance(opt, dict):
continue
for key in ("description", "preview"):
if isinstance(opt.get(key), str):
total += len(opt[key].strip())
return total
# ─── Transcript parsing ──────────────────────────────────────────────────────
def load_main_chain(transcript_path):
"""Parse the transcript JSONL into main-chain user/assistant entries.
Returns None when the transcript cannot be read at all (fail open).
Individual malformed lines are skipped; sidechain (subagent) entries and
non-message entry types are dropped.
"""
if not transcript_path:
return None
try:
with open(transcript_path, "r", encoding="utf-8", errors="replace") as fh:
lines = fh.read().splitlines()
except OSError:
return None
entries = []
for line in lines:
if not line.strip():
continue
try:
entry = json.loads(line)
except (json.JSONDecodeError, ValueError):
continue
if not isinstance(entry, dict):
continue
if entry.get("isSidechain") is True:
continue
if entry.get("type") not in ("user", "assistant"):
continue
entries.append(entry)
return entries
def content_blocks(entry):
content = (entry.get("message") or {}).get("content")
if isinstance(content, list):
return [b for b in content if isinstance(b, dict)]
return []
def human_authored_text(text):
"""True if anything remains once harness-injected wrappers are stripped."""
return bool(INJECTED_SPAN_RE.sub("", text).strip())
def is_real_user_message(entry):
"""True for a message the USER authored.
False for tool_result envelopes and for entries the harness appends as
user messages: skill expansions and image companions (`isMeta: true`),
`<system-reminder>` wrappers, and local-command output. A real message
that merely carries an appended reminder block is still real.
"""
if entry.get("type") != "user":
return False
if entry.get("isMeta") is True:
return False
content = (entry.get("message") or {}).get("content")
if isinstance(content, str):
return human_authored_text(content)
if isinstance(content, list):
for block in content:
if not isinstance(block, dict):
continue
if block.get("type") == "image":
return True
if block.get("type") == "text" and human_authored_text(block.get("text") or ""):
return True
return False
def collect_asks(entries):
"""All AskUserQuestion tool_use blocks, in order: (index, id, input)."""
asks = []
for i, entry in enumerate(entries):
if entry.get("type") != "assistant":
continue
for block in content_blocks(entry):
if block.get("type") == "tool_use" and block.get("name") == "AskUserQuestion":
tin = block.get("input")
if isinstance(tin, dict):
asks.append((i, block.get("id"), tin))
return asks
def drop_current_call(asks, tool_input):
"""Remove the final flushed occurrence of the in-flight call, if present."""
current = json.dumps(tool_input, sort_keys=True)
if asks and json.dumps(asks[-1][2], sort_keys=True) == current:
return asks[:-1]
return asks
def find_result_text(entries, start_index, tool_use_id):
"""The tool_result string for a tool_use id, or None if never answered."""
if not tool_use_id:
return None
for entry in entries[start_index + 1:]:
if entry.get("type") != "user":
continue
for block in content_blocks(entry):
if block.get("type") != "tool_result" or block.get("tool_use_id") != tool_use_id:
continue
content = block.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [
c.get("text", "")
for c in content
if isinstance(c, dict) and c.get("type") == "text"
]
return "\n".join(parts)
return ""
return None
def answered_with_offered_option(result_text, prev_input):
"""True iff every question was answered by picking an offered label.
The harness renders answers as: Your questions have been answered:
"<question>"="<answer>", ... — an answer that matches an offered option
label means the user accepted the framing; anything else (Other free text)
means they pushed back. Unrecognized formats return True (fail open —
never block on a format guess).
"""
if ANSWERED_MARKER not in result_text:
return True
if '="' not in result_text:
return True
labels = option_labels(prev_input)
if not labels:
return True
questions = prev_input.get("questions") or []
for q in questions:
if not isinstance(q, dict):
continue
q_labels = [
opt["label"]
for opt in (q.get("options") or [])
if isinstance(opt, dict) and isinstance(opt.get("label"), str)
]
if not q_labels:
continue
if not any('="{}"'.format(label) in result_text for label in q_labels):
return False
return True
def current_turn(entries):
"""Entries after the human's last real message (the whole list if none)."""
for i in range(len(entries) - 1, -1, -1):
if is_real_user_message(entries[i]):
return entries[i + 1:]
return entries
def turn_visibility(turn_entries, tool_input):
"""(visible_text_chars, prior_tool_use_count) for the current turn.
Only `text` blocks count; a progress-update block stored as `thinking`
is a summary the user may have seen as a status line, never the brief
itself. The in-flight AskUserQuestion is usually already flushed as the
turn's final tool_use; it is excluded from the prior-tool count.
"""
visible_chars = 0
tool_uses = []
for entry in turn_entries:
if entry.get("type") != "assistant":
continue
for block in content_blocks(entry):
btype = block.get("type")
if btype == "text":
visible_chars += len((block.get("text") or "").strip())
elif btype == "tool_use":
tool_uses.append(block)
current = json.dumps(tool_input, sort_keys=True)
for block in reversed(tool_uses):
if (
block.get("name") == "AskUserQuestion"
and json.dumps(block.get("input"), sort_keys=True) == current
):
tool_uses.remove(block)
break
return visible_chars, len(tool_uses)
# ─── Gates ───────────────────────────────────────────────────────────────────
def gate_deictic(tool_input):
for where, text in iter_question_strings(tool_input):
match = DEICTIC_RE.search(text)
if match:
hard_block(
"ASK-CONTEXT GATE: this {where} references context the user "
"cannot see (matched: \"{phrase}\").\n\n"
"The user's screen shows this question payload verbatim; text "
"you wrote between tool calls may reach them only as a one-line "
"progress update, your thinking never does, and raw tool output "
"is collapsed — \"I analyzed it\" is not \"they saw it\".\n\n"
"Fix: re-call AskUserQuestion with the context IN THE PAYLOAD — "
"question text that stands alone (name the thing: repo, PR "
"number, symptom; say why it surfaced now; restate the key "
"evidence as 2-6 short facts — no \"above\", no \"that "
"context\"), option descriptions that state what each choice "
"implies, and a preview for bulky evidence.".format(
where=where, phrase=match.group(0)
)
)
def gate_repeat(entries, tool_input):
asks = drop_current_call(collect_asks(entries), tool_input)
wanted = normalized_question_set(tool_input)
if not wanted:
return
for index, tool_use_id, prev_input in reversed(asks):
if normalized_question_set(prev_input) != wanted:
continue
result_text = find_result_text(entries, index, tool_use_id)
if result_text is None:
reason = "the question was interrupted before they answered"
elif REJECTED_MARKER in result_text:
reason = "they dismissed it"
elif not answered_with_offered_option(result_text, prev_input):
reason = (
"they answered in their own words instead of picking an "
"option — usually a request for more context"
)
else:
return # answered by picking an offered option: re-asks are fine
hard_block(
"ASK-CONTEXT GATE: you already asked this exact question and "
"{reason}. Re-presenting the identical question ignores their "
"reply.\n\n"
"Fix: re-call AskUserQuestion with a REWRITTEN payload that embeds "
"the context — what is being decided, the key evidence as 2-6 "
"short facts, and what each option implies, in the question text, "
"option descriptions, or a preview — and that answers what the "
"user actually said. Never re-send an unchanged payload after the "
"user pushed back on it.".format(reason=reason)
)
def gate_invisible_context(entries, tool_input, min_visible):
turn = current_turn(entries)
visible_chars, prior_tools = turn_visibility(turn, tool_input)
if prior_tools < 1:
return # first action after the user's message: their message is the context
payload_chars = payload_context_chars(tool_input)
if visible_chars >= min_visible or payload_chars >= min_visible:
return
hard_block(
"ASK-CONTEXT GATE: you are mid-workstream ({tools} tool call(s) since "
"the user's last message), this question payload carries only "
"{pchars} characters of context (question text + option descriptions "
"+ previews), and only {chars} characters of your text reached the "
"transcript this turn. Whatever analysis exists is in your thinking "
"and collapsed tool output — the user cannot see any of it.\n\n"
"Text written between tool calls is not a reliable surface: the API "
"may deliver it as a one-line progress update and the transcript "
"records it as thinking, so writing a brief and re-calling will not "
"pass this gate. Put the context IN THE PAYLOAD, then re-call "
"AskUserQuestion:\n"
"- question text names the thing (repo, PR number, file, symptom) and "
"says why the decision surfaced now;\n"
"- each option's description states what choosing it implies;\n"
"- the key evidence, restated as 2-6 short facts, goes in the question "
"text or an option preview.\n"
"At least {min} characters across those fields passes "
"(ASK_CONTEXT_MIN_CHARS overrides).".format(
tools=prior_tools, pchars=payload_chars, chars=visible_chars,
min=min_visible,
)
)
# ─── Entry point ─────────────────────────────────────────────────────────────
def main():
try:
data = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
sys.exit(0)
if not isinstance(data, dict) or data.get("tool_name") != "AskUserQuestion":
sys.exit(0)
if os.environ.get("CCGM_ASK_CONTEXT_OFF") == "1":
sys.exit(0)
tool_input = data.get("tool_input")
if not isinstance(tool_input, dict):
sys.exit(0)
# G1 needs only the payload — always runs.
gate_deictic(tool_input)
entries = load_main_chain(data.get("transcript_path"))
if entries is None:
sys.exit(0) # fail open: no transcript, no transcript gates
gate_repeat(entries, tool_input)
try:
min_visible = int(os.environ.get("ASK_CONTEXT_MIN_CHARS", DEFAULT_MIN_VISIBLE_CHARS))
except ValueError:
min_visible = DEFAULT_MIN_VISIBLE_CHARS
gate_invisible_context(entries, tool_input, min_visible)
sys.exit(0)
if __name__ == "__main__":
try:
main()
except SystemExit:
raise
except Exception:
# Fail open: a gate that crashes must not brick the session.
sys.exit(0)
config (1)
settings.partial.json
Merged into ~/.claude/settings.json -- a fragment, not a replacement.
{
"hooks": {
"PreToolUse": [
{
"matcher": "AskUserQuestion",
"hooks": [
{
"type": "command",
"command": "python3 $HOME/.claude/hooks/ask-context-gate.py",
"timeout": 5000
}
]
}
]
}
}