#!/usr/bin/env bash
# loop-stop.sh — Stop hook for Z-Search loop.
#
# Activated by .claude/settings.json. Triggered when the agent attempts to
# stop. Receives Claude Code's hook JSON on stdin (we mostly don't need it).
#
# Behavior:
# 1. Locate state.json by walking up from cwd.
# 2. If state.json absent or stage is terminal (done/aborted) → exit 0 (allow stop).
# 3. Otherwise run rule checks (R2-R5) and block (exit 2) if violations found.
#
# Rules (see .claude/skills/evolution-world-model/references/state_schema.md):
# R2.1 stage=round_generate but partials are running/pending
# R2.x stage=round_refine/round_react/round_checkpoint but evaluation_results.json missing
# R3 drift_status=replan_required and stage in {round_select, round_generate}
# R4 must_run_before_next_round non-empty and stage=round_select
# R5 state.evo_dir disagrees with hook-detected evo_dir
# R14 ledger entries (lineage.jsonl) have stale null diagnosis vs world_model.json
# R15 open_exploration node has non-empty strategy_combination (must stay [])
# R16 open_exploration node has a stub description (<80 chars) after dispatch
#
# Escape hatch: LINGXI_LOOP_HOOK_DISABLE=1 downgrades to warn-only.
set -uo pipefail
HOOK_NAME="loop-stop"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
# shellcheck source=lib/state.sh
source "${SCRIPT_DIR}/lib/state.sh"
# Read stdin (we don't strictly need it but slurp to avoid SIGPIPE)
read_stdin_json || true
evo_dir="$(find_evo_dir)"
if [[ -z "${evo_dir}" ]]; then
# No active evolution loop — never block stops
hook_allow
fi
# Re-infer state from filesystem before validating rules. This ensures
# R2/R3/R4 check against hook-derived state, not LLM-written state.
# Failure is non-fatal — fall through to rule check with whatever state exists.
if [[ -f "${STATE_OPS_PY}" ]]; then
python3 "${STATE_OPS_PY}" infer --evo-dir "${evo_dir}" --quiet 2>/dev/null || \
hook_log "state_ops infer failed (non-fatal, proceeding with existing state)"
fi
stage="$(read_state_field "${evo_dir}" "stage")"
# Stop_hook_active guard: per Claude Code docs, hook input has a
# stop_hook_active=true field on the second invocation in a row.
# Don't recurse into ourselves.
stop_hook_active="$(json_get "stop_hook_active")"
if [[ "${stop_hook_active}" == "True" || "${stop_hook_active}" == "true" ]]; then
hook_allow
fi
# Collect wm.json integrity issues (R15/R16). Stage-independent — runs even
# at terminal stages so a corrupted open_exploration node blocks 'done'.
wm_integrity="$(check_wm_integrity_rules "${evo_dir}")"
# Collect state.json invariant issues (R2-R13). Skipped at terminal stages
# since R6/R7/R8/etc. assume an in-progress run.
case "${stage}" in
done|aborted|"")
state_violations=""
;;
*)
state_violations="$(check_stop_rules "${evo_dir}")"
;;
esac
# Combine and block if any rule fired.
all_violations=""
if [[ -n "${wm_integrity}" ]]; then
all_violations="${wm_integrity}"
fi
if [[ -n "${state_violations}" ]]; then
if [[ -n "${all_violations}" ]]; then
all_violations="${all_violations}
${state_violations}"
else
all_violations="${state_violations}"
fi
fi
if [[ -n "${all_violations}" ]]; then
reason="$(echo "${all_violations}" | head -20)"
hook_block "Stop blocked due to invariant violations:
${reason}
Hint: run 'python3 .claude/skills/evolution-world-model/scripts/state_ops.py read --evo-dir ${evo_dir}' to inspect current state, then resolve the issue (complete missing partials, run wm_ops.py refine, finalize-ledger, fix wm corruption, etc.) before retrying.
To bypass in emergency only: export LINGXI_LOOP_HOOK_DISABLE=1"
fi
hook_allow