#!/usr/bin/env bash
# Stop hook (Task 719): after the admin agent finishes a turn, flag the turn when
# the per-turn routing directive fired, the turn was non-trivial, and NO route was
# taken — i.e. the agent answered from memory instead of dispatching a specialist,
# loading a skill, or calling the owning plugin's tool. This is the standing
# compliance signal the directive hook (prompt-optimiser-directive.sh) cannot
# self-emit, because "was a route taken" is only knowable after the turn ends.
#
# Classification (all derived from the just-finished turn in the transcript):
#   directive fired = the marker "PROMPT-OPTIMISER DIRECTIVE" appears in the turn
#                     slice (robust to CC-version differences in how the
#                     UserPromptSubmit additionalContext is recorded: a
#                     type:"attachment"/hook_success on 2.1.x, a
#                     type:"hook_additional_context" on the Pi), OR the prompt is
#                     a <channel>-wrapped reframe turn (## Context/## Instruction
#                     sections, Task 805) — the directive hook suppresses itself
#                     on channel turns, so the reframe is directive-equivalent.
#   route taken     = the turn contains a tool_use that is a genuine route:
#                     Agent/Task dispatch, native Skill, or a *__skill-load MCP
#                     tool. ToolSearch (a schema load) and the channel transport
#                     reply tools (mcp__*-channel__reply/-document) are NOT routes
#                     — counting them was the Task 747 bug that blinded the
#                     detector on every channel turn (the reply is mandatory).
#   trivial         = the prompt is a slash-command or a one-word confirmation.
# "Direct continuation" is NOT detectable from the transcript and is a stated
# known limitation, not guessed.
#
# Emits, only on (directive fired AND not trivial AND no route):
#   <ts> [prompt-optimiser-compliance] directive-fired no-route-taken session=<id8> tools=<csv> prompt="<clip>"
#   tools=<csv> is the comma-joined tool names seen in the slice (empty on a
#   no-tool freestyle turn), so a future false-negative shows what was (mis)counted.
# to $LOG_DIR/prompt-optimiser-directive.log (same log as the fired breadcrumb,
# so the two interleave into one greppable timeline) and to stderr.
#
# Input (stdin, Stop contract): { "transcript_path": "...", "session_id": "...", ... }
# Fail-open everywhere: no python3, no transcript_path, unreadable transcript, or
# any parse error -> exit 0 with no output. A Stop hook must never block a turn.

set -uo pipefail

INPUT=$(cat 2>/dev/null || true)
command -v python3 >/dev/null 2>&1 || exit 0

RESULT=$(printf '%s' "$INPUT" | python3 -c '
import json, sys, os, datetime, re

def fail_open():
    sys.exit(0)

try:
    inp = json.load(sys.stdin)
except Exception:
    fail_open()

tp = inp.get("transcript_path")
session = inp.get("session_id") or "?"
if not isinstance(tp, str) or not tp or not os.path.exists(tp):
    fail_open()

rows = []
try:
    with open(tp, "r", errors="replace") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                rows.append(json.loads(line))
            except Exception:
                continue
except OSError:
    fail_open()

# Flatten a message.content into plain text; mark whether it is purely tool_result
# (a tool output row, not a user prompt).
def flatten(content):
    if isinstance(content, str):
        return content, False
    if isinstance(content, list):
        only_tool_result = bool(content)
        parts = []
        for b in content:
            if not isinstance(b, dict):
                only_tool_result = False
                if isinstance(b, str):
                    parts.append(b)
                continue
            t = b.get("type")
            if t != "tool_result":
                only_tool_result = False
            if t == "text" and isinstance(b.get("text"), str):
                parts.append(b["text"])
        return " ".join(parts), only_tool_result
    return "", False

# A row is synthetic (not a genuine prompt) when Claude Code stamps it isMeta /
# isSidechain / etc. Claude Code injects such user rows AFTER the real prompt
# within the same turn — skill bodies, system-reminders, additionalContext
# echoes — so a naive "last text-bearing user row" would land on the injection,
# truncate the slice, and read the injected body as the prompt.
def is_synthetic(r):
    return any(r.get(k) for k in ("isMeta", "isSidechain", "isCompactSummary", "isVisibleInTranscriptOnly"))

# Boundary: the last user row that carries a genuine prompt. A genuine prompt is
# either a non-synthetic user turn (typed claude.ai) OR a synthetic turn whose
# text is channel-wrapped — WhatsApp/native channels stamp the inbound prompt
# isMeta:true and wrap it in <channel ...>, and that inbound IS the prompt. Every
# other synthetic row (skill body, system-reminder) is skipped. The turn slice is
# everything from the boundary to EOF.
boundary = None
prompt_text = ""
for i, r in enumerate(rows):
    if not isinstance(r, dict) or r.get("type") != "user":
        continue
    msg = r.get("message") if isinstance(r.get("message"), dict) else {}
    text, only_tool_result = flatten(msg.get("content"))
    if only_tool_result:
        continue
    stripped = text.strip()
    if not stripped:
        continue
    if is_synthetic(r) and not stripped.startswith("<channel"):
        continue
    boundary = i
    prompt_text = stripped

if boundary is None:
    fail_open()

slice_rows = rows[boundary:]

# Strip leading <channel ...>...</channel> wrapper(s) to reach the inner
# prompt. WhatsApp/native-channel prompts arrive wrapped once; a multi-target
# (unified-server, Task 853) prompt arrives DOUBLE-wrapped — the harness wraps
# with the registration key `channel` and the server embeds an inner
# <channel source="<door>"> tag — so iterate until the payload surfaces.
inner = prompt_text
is_channel = False
while inner.startswith("<channel"):
    end = inner.find(">")
    close = inner.rfind("</channel>")
    if end == -1 or close == -1 or close <= end:
        break
    inner = inner[end + 1:close].strip()
    is_channel = True

# Task 1505 — mirror the reader anchor (parse-transcript.ts COMPOSED_ADMIN_FRAME).
# For admin channel turns Task 1486 prepends the account active standing-rules block
# (`## Standing rules\n- …\n\n`) above `## Context`, so a rules-present channel turn
# no longer begins with `## Context` and both the reframe detection and the trivial
# strip below silently stopped firing on exactly those turns. Strip an optional
# leading standing-rules block — anchored to the `## Standing rules` header (not any
# prefix), non-greedy up to the `\n\n` immediately before `## Context` — so both see
# the `## Context` payload as before rules injection. Channel-only and match-gated on
# a following `## Context`, so a rules-absent channel turn is byte-identical to before
# and a typed (non-channel) prompt is untouched.
if is_channel:
    inner = re.sub(r"^## Standing rules\n[\s\S]*?\n\n(?=## Context\n)", "", inner, count=1)

# Task 805 — a channel-wrapped reframe turn (the ## Context / ## Instruction
# sections of composeAdminContent) is directive-equivalent: the directive hook suppresses
# itself on channel turns (Task 753), so the reframe is the only routing
# instruction the turn carries, and the marker check alone left every channel
# turn unevaluated. A channel-wrapped turn WITHOUT the reframe (webchat today,
# Task 776) is not directive-equivalent. For the trivial check the prompt is the
# Context payload, not the wrapper — the always-present instruction text would
# otherwise make a one-word confirmation look non-trivial.
is_reframe = is_channel and inner.startswith("## Context\n") and "\n## Instruction\n" in inner
if is_reframe:
    head, _, _rest = inner.partition("\n## Instruction\n")
    inner = head[len("## Context"):].strip()

# Trivial-turn filter.
is_slash = inner.startswith("/")
tokens = inner.split()
is_one_word = len(tokens) <= 1

# Directive fired = marker present anywhere in the slice (any record shape) OR
# the prompt is a channel-wrapped reframe turn (Task 805).
marker = "PROMPT-OPTIMISER DIRECTIVE"
directive_fired = is_reframe or any(marker in json.dumps(r, ensure_ascii=False) for r in slice_rows)

# Route taken = an assistant tool_use that is a genuine route: specialist
# dispatch (Agent/Task) or skill load (native Skill, or the *__skill-load MCP
# tool under either admin namespace). ToolSearch is a schema load, and the channel
# transport reply tools (mcp__*-channel__reply / reply-document) are mandatory
# transport, not routes — neither counts. Every tool name in the slice is
# collected for the tools= breadcrumb so a future false-negative shows what was
# (mis)counted.
ROUTE_TOOLS = ("Agent", "Task", "Skill")
def is_route(name):
    if not isinstance(name, str):
        return False
    return name in ROUTE_TOOLS or name.endswith("__skill-load")

tool_names = []
for r in slice_rows:
    if not isinstance(r, dict) or r.get("type") != "assistant":
        continue
    msg = r.get("message") if isinstance(r.get("message"), dict) else {}
    content = msg.get("content")
    if isinstance(content, list):
        for b in content:
            if isinstance(b, dict) and b.get("type") == "tool_use":
                nm = b.get("name")
                if isinstance(nm, str):
                    tool_names.append(nm)

route_taken = any(is_route(n) for n in tool_names)

if directive_fired and not is_slash and not is_one_word and not route_taken:
    clip = prompt_text.replace("\n", " ")[:80]
    ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    sid8 = session[:8] if isinstance(session, str) else "?"
    tools_csv = ",".join(tool_names)
    print("%s [prompt-optimiser-compliance] directive-fired no-route-taken session=%s tools=%s prompt=\"%s\"" % (ts, sid8, tools_csv, clip))
' 2>/dev/null) || exit 0

[ -n "$RESULT" ] || exit 0

# Durable + stderr. Fail-open on an unwritable/absent LOG_DIR.
if [ -n "${LOG_DIR:-}" ] && [ -d "$LOG_DIR" ]; then
  printf '%s\n' "$RESULT" >> "$LOG_DIR/prompt-optimiser-directive.log" 2>/dev/null || true
fi
printf '%s\n' "$RESULT" >&2
exit 0
