#!/usr/bin/env bash
# AskUserQuestion investigation gate.
#
# Blocks `AskUserQuestion` when no read-only investigation tool has fired
# since the latest real user turn in the session JSONL. The structural fix
# for the failure class where the agent jumps to a fabricated menu before
# evidence-gathering — see session c085ec2c-46fb-4b73-8865-68cf85866ea8
# (2026-05-22) where the agent menu'd "Admin PIN / Cloudflare tunnel / WiFi"
# in response to "change remote access password", then on correction
# immediately fired remote-auth-status → ToolSearch → set-password — proving
# the agent knew the moves; it just skipped them.
#
# Prose IDENTITY/SKILL rules are narration, not enforcement
# (feedback_doctrine_paragraph_is_not_a_gate.md). This hook removes the LLM
# from the per-decision path.
#
# Contract:
#   Input:  PreToolUse stdin JSON envelope from Claude Code
#   Output: exit 0 (allow) or exit 2 (block) per hook protocol
#   Logs:   one stderr line per call, format
#           [ask-gate] decision=<allow|block> sessionId=<id8> seen=<csv|->
#                      reason=<allowlist-hit|no-investigation|fail-open-no-transcript|fail-open-parse-error>
#
# Fail-open philosophy: any infrastructure error (missing transcript, parse
# failure, non-AskUserQuestion tool) nudges the agent through, never bricks
# the UI. Block is reserved for the deterministic case "AskUserQuestion fired
# this turn with zero investigation evidence in the JSONL".

set -uo pipefail

# ----- Read stdin, identify event ------------------------------------------
if [ -t 0 ]; then
  # No envelope — cannot enforce. Fail open per nudge-not-brick rule.
  echo "[ask-gate] decision=allow sessionId=- seen=- reason=fail-open-no-transcript" >&2
  exit 0
fi
INPUT=$(cat)

HOOK_EVENT=$(printf '%s' "$INPUT" | python3 -c '
import sys, json
try:
    d = json.load(sys.stdin)
    print(d.get("hook_event_name", "") or "")
except Exception:
    print("")
' 2>/dev/null)

TOOL_NAME=$(printf '%s' "$INPUT" | python3 -c '
import sys, json
try:
    d = json.load(sys.stdin)
    print(d.get("tool_name", "") or "")
except Exception:
    print("")
' 2>/dev/null)

# Only applies to PreToolUse on AskUserQuestion. Anything else: silent exit.
if [ "$HOOK_EVENT" != "PreToolUse" ] || [ "$TOOL_NAME" != "AskUserQuestion" ]; then
  exit 0
fi

SESSION_ID=$(printf '%s' "$INPUT" | python3 -c '
import sys, json
try:
    d = json.load(sys.stdin)
    print(d.get("session_id", "") or "")
except Exception:
    print("")
' 2>/dev/null)
SESSION_ID8="${SESSION_ID:0:8}"
if [ -z "$SESSION_ID8" ]; then SESSION_ID8="-"; fi

TRANSCRIPT_PATH=$(printf '%s' "$INPUT" | python3 -c '
import sys, json
try:
    d = json.load(sys.stdin)
    print(d.get("transcript_path", "") or "")
except Exception:
    print("")
' 2>/dev/null)

# Allow `ASK_GATE_TRANSCRIPT_PATH` env override for tests — never used in
# production; the SDK always passes transcript_path in the envelope.
if [ -n "${ASK_GATE_TRANSCRIPT_PATH:-}" ]; then
  TRANSCRIPT_PATH="$ASK_GATE_TRANSCRIPT_PATH"
fi

if [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ]; then
  echo "[ask-gate] decision=allow sessionId=${SESSION_ID8} seen=- reason=fail-open-no-transcript" >&2
  exit 0
fi

# ----- Walk JSONL --------------------------------------------------------
# Returns one of three lines on stdout:
#   ALLOW\t<seen-csv>
#   BLOCK\t<seen-csv-or-dash>
#   PARSE_ERROR\t-
#
# "Real user turn" = type=user, message.role=user, content is either a
# string OR a list with at least one non-tool_result block. Tool-result-only
# user lines are tool replies, not turn starts.
RESULT=$(TRANSCRIPT_PATH="$TRANSCRIPT_PATH" python3 - <<'PY'
import os, json, sys

ALLOWLIST = {
    "ToolSearch", "Grep", "Glob", "Read", "LS", "NotebookRead",
    "Bash", "WebFetch", "WebSearch",
    "mcp__admin__skill-find",
    "mcp__admin__agent-list",
    "mcp__admin__agent-config-read",
    "mcp__admin__plugin-read",
    "mcp__admin__skill-load",
    "mcp__admin__system-status",
    "mcp__admin__logs-read",
    "mcp__admin__remote-auth-status",
    "mcp__admin__premium-list",
    "mcp__memory__memory-find-candidates",
    "mcp__memory__profile-read",
    "mcp__memory__conversation-list",
    "mcp__memory__memory-list-attachments",
    "mcp__memory__memory-read-attachment",
}

# Pre-compute suffix matchers for namespaced aliases.
# mcp__admin__skill-find -> suffix "__skill-find"
SUFFIX_MATCHERS = []
for entry in ALLOWLIST:
    if "__" in entry:
        tool = entry.rsplit("__", 1)[1]
        if tool:
            SUFFIX_MATCHERS.append("__" + tool)
# Deduplicate
SUFFIX_MATCHERS = sorted(set(SUFFIX_MATCHERS))

def is_allowed(name: str) -> bool:
    if name in ALLOWLIST:
        return True
    for suf in SUFFIX_MATCHERS:
        if name.endswith(suf):
            return True
    return False

def is_real_user(d: dict) -> bool:
    if d.get("type") != "user":
        return False
    msg = d.get("message", {}) or {}
    if msg.get("role") != "user":
        return False
    content = msg.get("content")
    if isinstance(content, str):
        return True
    if isinstance(content, list):
        for b in content:
            if isinstance(b, dict) and b.get("type") != "tool_result":
                return True
        return False
    # Missing / null content — treat as real-user start (defensive).
    return True

def assistant_tool_uses(d: dict):
    if d.get("type") != "assistant":
        return []
    msg = d.get("message", {}) or {}
    if msg.get("role") != "assistant":
        return []
    content = msg.get("content")
    if not isinstance(content, list):
        return []
    names = []
    for b in content:
        if isinstance(b, dict) and b.get("type") == "tool_use":
            n = b.get("name")
            if isinstance(n, str) and n:
                names.append(n)
    return names

path = os.environ["TRANSCRIPT_PATH"]
records = []
try:
    with open(path, "r", encoding="utf-8") as f:
        for raw in f:
            raw = raw.strip()
            if not raw:
                continue
            records.append(json.loads(raw))
except json.JSONDecodeError:
    print("PARSE_ERROR\t-")
    sys.exit(0)
except Exception:
    # Any non-JSON I/O failure: fail open as no-transcript shape — but the
    # outer bash already gates file existence, so this is a defence in depth.
    print("PARSE_ERROR\t-")
    sys.exit(0)

last_user = -1
for i in range(len(records) - 1, -1, -1):
    if is_real_user(records[i]):
        last_user = i
        break

seen = []
if last_user >= 0:
    for r in records[last_user + 1:]:
        seen.extend(assistant_tool_uses(r))

# Allow if any seen name matches the allowlist.
allow = any(is_allowed(n) for n in seen)

# Filter `AskUserQuestion` itself out of the seen-csv when nothing else fired —
# its presence in JSONL before the gate fires can happen if the SDK records
# the assistant message before dispatching. It does not count as investigation,
# but listing only "AskUserQuestion" in the log is misleading. Drop self-name
# from the displayed CSV (it does not affect the allow/block decision because
# AskUserQuestion is not in the allowlist).
display = [n for n in seen if n != "AskUserQuestion"]
csv = ",".join(display) if display else "-"

print(("ALLOW" if allow else "BLOCK") + "\t" + csv)
PY
)

# ----- Parse python result + emit decision ---------------------------------
REASON=""
DECISION=""
SEEN_CSV="-"

case "$RESULT" in
  ALLOW*)
    DECISION="allow"
    REASON="allowlist-hit"
    SEEN_CSV="${RESULT#ALLOW	}"
    ;;
  BLOCK*)
    DECISION="block"
    REASON="no-investigation"
    SEEN_CSV="${RESULT#BLOCK	}"
    ;;
  PARSE_ERROR*)
    DECISION="allow"
    REASON="fail-open-parse-error"
    SEEN_CSV="-"
    ;;
  *)
    DECISION="allow"
    REASON="fail-open-parse-error"
    SEEN_CSV="-"
    ;;
esac

echo "[ask-gate] decision=${DECISION} sessionId=${SESSION_ID8} seen=${SEEN_CSV} reason=${REASON}" >&2

if [ "$DECISION" = "block" ]; then
  echo "Blocked: AskUserQuestion requires at least one investigation tool (ToolSearch, Grep, Read, *-list, *-status, *-read, skill-find, ...) earlier in this turn. Search the operator's literal phrase first." >&2
  exit 2
fi

exit 0
