#!/bin/bash
# subagent-return-schema-guard.sh — JSON-Schema validator for subagent returns
#
# Hook    : PostToolUse:Agent
# Mode    : WARN by default. Env-gated strict mode (SCHEMA_RETURN_GUARD=strict)
#           promotes a validation failure to a BLOCK (exit 2 + re-dispatch
#           message). The default stays WARN until calibrated.
# State   : appends one JSON line per validation to
#           <project>/.achilles/schema-guard-log.jsonl ({role, valid, errors[]})
# Env     : SCHEMA_RETURN_GUARD=strict  → exit 2 (block) on validation failure
#
# Flip criterion (WARN → strict default)
# --------------------------------------
# Promote the DEFAULT to strict only after the logged validations show a
# false-positive rate below 2% over at least 200 logged validations
# (read .achilles/schema-guard-log.jsonl across representative runs;
# `valid:false` entries whose return was actually conformant are the
# false positives). Until that bar is met the default remains WARN; strict
# is opt-in via the env var.
#
# Rule
# ----
# Validates subagent returns against JSON-Schema definitions in
# schemas/subagent-returns/<role>.schema.json. Validation runs through
# hooks/lib/validator.bundle.mjs — a self-contained bundle (Ajv 8,
# draft 2020-12, all schemas inlined) generated by `npm run build:validator`.
# No node_modules or repo-root resolution required at hook runtime.
#
# Schema coverage:
#   composer-<slug>            → schemas/subagent-returns/composer.schema.json
#   reviewer-<slug>            → schemas/subagent-returns/reviewer-inloop.schema.json
#   probe-<slug>               → schemas/subagent-returns/probe.schema.json
#   phase-validator-<N>        → schemas/subagent-returns/phase-validator.schema.json
#   workflow-reviewer-<unit>   → schemas/subagent-returns/workflow-reviewer.schema.json
#   phase4-prioritise-author*  → schemas/subagent-returns/phase4-prioritise-author.schema.json
#   phase4-cycle-<N>-*         → schemas/subagent-returns/section-agent.schema.json
#
# No-schema roles (silent allow on schema step; envelope-sanity only):
#   process-validator-*, phase1-*, stage2-*, cleanup-*, companion-*, fd-*,
#   bare j-/sj-
#
# The handover envelope is validated as part of the schema (handover is a
# required nested key in every role schema).
#
# Canonical reference
# -------------------
# schemas/subagent-returns/*.schema.json  — single source of truth
# skills/element-interactions/references/subagent-return-schema.md §4.2
#
# Failure → action
# ----------------
# Schema validation failure → WARN (systemMessage with validator error details)
# Handover envelope issues  → WARN (same channel)

set -euo pipefail

# Resolve jq.
JQ="$(dirname "${BASH_SOURCE[0]}")/bin/jq"
[ -x "$JQ" ] || JQ="$(command -v jq || true)"
if [ -z "$JQ" ]; then
  echo "[$(basename "${BASH_SOURCE[0]}")] FATAL: jq not found at \$HOOK_DIR/bin/jq nor on PATH." >&2
  exit 1
fi

# Resolve node (must be on PATH for the validator bundle).
NODE="$(command -v node || true)"
if [ -z "$NODE" ]; then
  echo "[$(basename "${BASH_SOURCE[0]}")] FATAL: node not found on PATH. Install Node.js." >&2
  exit 1
fi

# Shared no-skip messaging library.
# shellcheck source=lib/no-skip-messaging.sh
HOOK_LIB_DIR="$(dirname "${BASH_SOURCE[0]}")/lib"
if [ -f "$HOOK_LIB_DIR/no-skip-messaging.sh" ]; then
  source "$HOOK_LIB_DIR/no-skip-messaging.sh"
else
  no_skip_messaging_block() { echo ""; }
fi

# Path to the self-contained validator bundle (co-located in lib/;
# generated by `npm run build:validator`, shipped in the package tarball).
VALIDATOR="$HOOK_LIB_DIR/validator.bundle.mjs"

emit_warn() {
  local msg="$1
$(no_skip_messaging_block)"
  "$JQ" -n --arg m "$msg" '{
    "systemMessage": $m,
    "suppressOutput": false
  }'
}

# --- input ---
INPUT=$(cat)

# Session-scope gate: this hook applies only to achilles-activated
# sessions; plain dev sessions silent-allow (lib/achilles-activation.sh).
. "$(dirname "${BASH_SOURCE[0]}")/lib/achilles-activation.sh"
achilles_require_active "$INPUT"
TOOL_NAME=$(echo "$INPUT" | "$JQ" -r '.tool_name // empty')
[ "$TOOL_NAME" != "Agent" ] && exit 0

DESCRIPTION=$(echo "$INPUT" | "$JQ" -r '.tool_input.description // ""')

# Shared role-mapping. Single source of truth — same file is sourced by
# the PreToolUse half of the contract (subagent-schema-preread-gate.sh).
# workflow-reviewer-* is mapped here too: the reviewer-brief contract
# now requires citing workflow-reviewer.schema.json, so both halves use
# resolve_schema_role (migration note in lib/schema-role-map.sh).
# Unknown prefix → silent allow (out of scope). Known prefix with no
# schema (process-validator-*) → empty SCHEMA_ROLE, continues to the
# handover-envelope warn path below; the schema step is skipped via
# the `if [ -n "$SCHEMA_ROLE" ]` guard.
# shellcheck source=lib/schema-role-map.sh
# shellcheck disable=SC1091
. "$HOOK_LIB_DIR/schema-role-map.sh"
if ! SCHEMA_ROLE=$(resolve_schema_role "$DESCRIPTION"); then
  exit 0
fi

# Extract the subagent's textual return.
# PostToolUse:Agent payloads carry the return in a few shapes.
RESPONSE=$(
  echo "$INPUT" | "$JQ" -r '
    [
      (.tool_response.output? | if type == "array" then map(.text? // (. | tostring)) | join("\n") elif type == "string" then . else (. | tostring) end),
      (.tool_response.result? // empty | tostring),
      (if (.tool_response | type) == "string" then .tool_response else empty end)
    ] | map(select(. != null and . != "")) | unique | join("\n")
  ' 2>/dev/null || echo ""
)

if [ -z "$RESPONSE" ]; then
  RESPONSE=$(echo "$INPUT" | "$JQ" -r '
    if (.tool_response // null) == null then ""
    elif (.tool_response | type) == "string" then .tool_response
    else (.tool_response | tostring)
    end
  ' 2>/dev/null || echo "")
fi

case "$RESPONSE" in
  ""|"null"|"{}"|"[]") exit 0 ;;
esac

# === Handover envelope parse ===============================================
# Extract the envelope's cycle field for the numeric sanity check below.
# Deeper envelope structure is validated by the JSON-Schema step.
# The handover block is YAML-indented under a top-level `handover:` line.
HANDOVER_CYCLE=""
HANDOVER_WARNS=()

if echo "$RESPONSE" | grep -qE '(^|\n)handover:[[:space:]]*$'; then
  HANDOVER_BLOCK=$(echo "$RESPONSE" | awk '
    /^handover:[[:space:]]*$/ { in_block = 1; next }
    in_block {
      if (/^[[:space:]]+/ || /^$/) { print; next }
      exit
    }
  ' || true)
  HANDOVER_CYCLE=$(echo "$HANDOVER_BLOCK" | grep -E '^[[:space:]]+cycle:' | head -1 | sed -E 's/^[[:space:]]+cycle:[[:space:]]*//' | tr -d '[:space:]' || true)
fi

# Numeric cycle sanity.
if [ -n "$HANDOVER_CYCLE" ] && ! echo "$HANDOVER_CYCLE" | grep -qE '^[0-9]+$'; then
  HANDOVER_WARNS+=("handover: cycle: '${HANDOVER_CYCLE}' is not a non-negative integer (§2.0)")
fi

# === JSON-Schema validation via the bundled validator ======================
# Only run for roles that have a schema. process-validator has no schema
# at this version — it falls through to the handover-only warn path below.
SCHEMA_ERRORS=""
if [ -n "$SCHEMA_ROLE" ]; then
  # Write RESPONSE to a temp file for the validator bundle.
  TMPFILE=$(mktemp /tmp/subagent-schema-guard-XXXXXX.yaml)
  trap 'rm -f "$TMPFILE"' EXIT
  printf '%s' "$RESPONSE" > "$TMPFILE"

  # Invoke the self-contained validator bundle (all schemas inlined — no
  # repo-root cwd or node_modules needed). Success prints nothing, so
  # SCHEMA_ERRORS stays empty on valid returns.
  if [ -f "$VALIDATOR" ]; then
    SCHEMA_ERRORS=$("$NODE" "$VALIDATOR" validate "$SCHEMA_ROLE" "$TMPFILE" 2>&1) || true
  else
    SCHEMA_ERRORS="validator bundle missing at ${VALIDATOR} — run npm run build:validator (in-repo) or reinstall @civitas-cerebrum/achilles"
  fi
fi

# === Calibration log ======================================================
# Append one JSON line per validation ({role, valid, errors[]}) to
# <project>/.achilles/schema-guard-log.jsonl. This is the dataset the
# WARN→strict flip criterion is measured against (see header). Best-effort:
# a logging failure must never affect the hook's verdict.
GUARD_CWD=$(echo "$INPUT" | "$JQ" -r '.cwd // "."' 2>/dev/null || echo ".")
GUARD_PROJECT_ROOT=$(cd "$GUARD_CWD" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null || echo "$GUARD_CWD")
LOG_DIR="$GUARD_PROJECT_ROOT/.achilles"
LOG_FILE="$LOG_DIR/schema-guard-log.jsonl"
VALID_FLAG=true
[ -n "$SCHEMA_ERRORS" ] && VALID_FLAG=false
[ ${#HANDOVER_WARNS[@]} -gt 0 ] && VALID_FLAG=false
{
  mkdir -p "$LOG_DIR" 2>/dev/null && \
  "$JQ" -nc \
    --arg role "${SCHEMA_ROLE:-${DESCRIPTION%%[-:]*}}" \
    --argjson valid "$VALID_FLAG" \
    --arg errs "$SCHEMA_ERRORS" \
    --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
    '{ts:$ts, role:$role, valid:$valid, errors:(($errs | split("\n")) | map(select(length>0)))}' \
    >> "$LOG_FILE" 2>/dev/null
} || true

# === Emit warning if any issues found =====================================
if [ ${#HANDOVER_WARNS[@]} -eq 0 ] && [ -z "$SCHEMA_ERRORS" ]; then
  exit 0
fi

# === Strict mode (opt-in): promote the validation failure to a BLOCK ======
# SCHEMA_RETURN_GUARD=strict turns a return-schema failure into a blocking
# exit-2 with a re-dispatch instruction on stderr (the SubagentStop / Agent
# block contract). The default stays WARN — see the flip criterion in the
# header.
if [ "${SCHEMA_RETURN_GUARD:-warn}" = "strict" ]; then
  {
    echo "[BLOCK] Subagent return failed schema validation (SCHEMA_RETURN_GUARD=strict)."
    echo "Description: \"${DESCRIPTION}\""
    echo "Role:        ${SCHEMA_ROLE:-${DESCRIPTION%%[-:]*}}"
    [ -n "$SCHEMA_ERRORS" ] && { echo "Schema errors (schemas/subagent-returns/${SCHEMA_ROLE}.schema.json):"; echo "$SCHEMA_ERRORS"; }
    if [ ${#HANDOVER_WARNS[@]} -gt 0 ]; then
      echo "Handover envelope (§2.0):"
      for item in "${HANDOVER_WARNS[@]}"; do echo "  - ${item}"; done
    fi
    echo "Re-dispatch the subagent with a brief that quotes the schema constraints verbatim."
  } >&2
  exit 2
fi

WARNING="[WARN] Subagent return validation surfaced issues.

Description: \"${DESCRIPTION}\"
Role:        ${SCHEMA_ROLE:-${DESCRIPTION%%[-:]*}}"

if [ -n "$SCHEMA_ERRORS" ]; then
  WARNING="${WARNING}

Schema validation errors (schemas/subagent-returns/${SCHEMA_ROLE}.schema.json):
${SCHEMA_ERRORS}"
fi

if [ ${#HANDOVER_WARNS[@]} -gt 0 ]; then
  WARNING="${WARNING}

Handover envelope (§2.0):"
  for item in "${HANDOVER_WARNS[@]}"; do
    WARNING="${WARNING}
  - ${item}"
  done
fi

WARNING="${WARNING}

The canonical return schemas are at:
  schemas/subagent-returns/<role>.schema.json

Re-dispatch the subagent with a brief that quotes the schema constraints
verbatim. This warning is non-blocking; a follow-up release will promote
it to BLOCK once the false-positive rate is calibrated."

emit_warn "$WARNING"

exit 0
