#!/usr/bin/env python3
"""orch_pre_bash.py — PreToolUse rail for /orchestrate (fail-closed).

Wired in ~/.claude/settings.json on the Bash matcher. Reads the tool call JSON on
stdin; exit 0 = allow, exit 2 = BLOCK (stderr shown to the model).

Two mechanical rails (the two failure classes that bit us 2026-06-18):
  1. MERGE-GATE (only with an active orch run): the engine may auto-merge its own PR,
     but ONLY after orchestrate_guard's merge gate passes (live-verify recorded +
     AWAIT_MERGE state — i.e. deterministic gates green + ultracode review clean +
     verify done). Until then `gh pr merge` / `git merge <ref>` are BLOCKED. This is the
     auto-merge-after-verify protocol (JT 2026-06-22): with merge gates + linters + the
     ultracode review + a live-verify in place, surfacing a clean PR for a manual merge
     is just friction. Does NOT touch `git push origin main` (this monorepo commits to
     main by design). When no run is active this rail is a pure no-op.
  2. DISPATCH-GATE (only with an active orch run): block the harness/dispatch entrypoint
     unless orchestrate_guard says spec_selfgate==READY and no human gate is pending
     (mechanically enforces /spec Step 9.9 — no un-gated dispatch, the 5-cap problem).

Fail-closed: any error reading state on a guarded command -> BLOCK. ORCH_BYPASS=1 in the
command env skips both rails for one call (logged by the post-hook).
"""
import json
import os
import re
import subprocess
import sys
from pathlib import Path

GUARD = Path.home() / "CLAUDE_PROJECTS" / "recoil" / "pipeline" / "tools" / "orchestrate_guard.py"
ACTIVE = Path.home() / ".claude" / "orchestrate" / "active_run"

# Merge = combining a PR or a branch (human-only). NOT a plain push-to-main commit.
# `(?![-\w])` keeps read-only plumbing (`git merge-base`, `git merge-tree`,
# `git merge-file`) from false-matching `git merge` (REC-245).
_MERGE_RE = re.compile(r"\bgh\s+pr\s+merge\b|\bgit\s+merge\b(?![-\w])(?!\s*--abort)")


def _block(msg: str) -> int:
    sys.stderr.write(f"[orchestrate] BLOCK: {msg}\n")
    return 2


def main() -> int:
    try:
        payload = json.load(sys.stdin)
    except Exception:
        return 0  # not our concern if we can't parse; other hooks/safety still apply
    cmd = (payload.get("tool_input") or {}).get("command", "") or ""
    if not cmd:
        return 0

    # Explicit, logged break-glass.
    if re.search(r"\bORCH_BYPASS=1\b", cmd):
        return 0

    # Both rails are ACTIVE-RUN-SCOPED: when no orchestrate run is active, this hook is a
    # pure no-op, so wiring it globally in settings.json changes NOTHING for normal/other
    # sessions (zero blast radius). Auto-merge is only possible DURING a run, and only once
    # the guard's merge gate passes — which is exactly when Rail 1 stops blocking. Outside a
    # run JT's own directed merges are never touched.
    if not ACTIVE.exists():
        return 0
    try:
        run_dir = ACTIVE.read_text(encoding="utf-8").strip()
    except OSError:
        return 0
    if not run_dir:
        return 0

    # Rail 1 — MERGE-GATE (during a run): auto-merge is permitted ONLY after the guard's
    # merge gate passes (verify recorded + AWAIT_MERGE). Block otherwise (fail-closed).
    if _MERGE_RE.search(cmd):
        try:
            r = subprocess.run([sys.executable, str(GUARD), "gate-check",
                                "--run-dir", run_dir, "--gate", "merge"],
                               capture_output=True, text=True, timeout=15)
        except Exception as e:  # fail-closed on a guarded command
            return _block(f"merge gate-check failed to run ({e})")
        if r.returncode != 0:
            return _block(r.stderr.strip() or "merge not permitted by orchestrate_guard "
                          "(verify/review gate unmet) — converge + verify before merging.")
        return 0  # merge gate satisfied — auto-merge permitted

    # Rail 2 — DISPATCH-GATE: block the harness/dispatch entrypoint unless the guard allows.
    is_dispatch = bool(re.search(r"harness_orchestrator\.sh|session_workspace\.sh\s+create|dispatch_status\.py\s+(init|transition)", cmd))
    if not is_dispatch:
        return 0
    try:
        r = subprocess.run([sys.executable, str(GUARD), "gate-check",
                            "--run-dir", run_dir, "--gate", "dispatch"],
                           capture_output=True, text=True, timeout=15)
    except Exception as e:  # fail-closed on a guarded command
        return _block(f"dispatch gate-check failed to run ({e})")
    if r.returncode != 0:
        return _block(r.stderr.strip() or "dispatch not permitted by orchestrate_guard")
    return 0


if __name__ == "__main__":
    sys.exit(main())
