#!/usr/bin/env python
"""PRD Plugin autonomous run-until-done Stop hook for Claude Code.

Wired to Stop in .claude/settings.json. When the configured autonomy tier is
`autonomous`, this keeps the agent working toward the active goal instead of
stopping for pointless checkpoints — the deterministic, plugin-managed cousin of
the native /goal command (which is per-session and judged from the transcript;
this reads real state).

It blocks the stop (returns `{"decision":"block","reason":...}`) only when ALL of:
  - automation.autonomy_level == "autonomous", and
  - automation.autonomous_run_until_done is true (default true), and
  - there is an active goal: a TRK-* tracking record whose status is not closed,
    and
  - THIS session owns that goal (see below), and
  - no pause has been requested (.prd_plugin/local/autonomy-pause absent), and
  - the per-session continue count is under automation.autonomous_continue_cap.

Multi-session safety (REQ-053, AI-Collab-v3 HLT-016): multi-agent repos spawn
headless `claude -p` child sessions that inherit this hook. They must never be
conscripted into a goal they don't own. Two layers:
  1. Hard opt-out for spawners: set PRD_STOP_GUARD=off (or 0/false/disabled) or
     PRD_WORKER_SESSION=1 in the child environment and the guard allows the
     stop unconditionally, whatever the goal state.
  2. Session ownership by default: the first session the guard blocks toward a
     goal becomes that goal's owner (persisted in
     .prd_plugin/local/stop-guard-state.json, keyed by goal id). Only the
     owning session_id is ever blocked; every other session stops freely. The
     binding is pruned when the goal closes (complete/resolved/deferred/parked/
     superseded/...) or a different goal becomes active.

Otherwise it allows the stop. It NEVER forces continuation past the consent
floor: when the agent genuinely needs the user (push/merge/publish/secrets, or a
real blocker), it writes `.prd_plugin/local/autonomy-pause` and the guard lets it
stop (then clears the marker). Any error allows the stop — it must never trap the
session in a loop.
"""

import json
import os
import sys
from pathlib import Path

CLOSED_STATUSES = {"complete", "completed", "done", "closed", "cancelled",
                   "canceled", "superseded", "abandoned", "resolved",
                   # deferred/parked are explicit owner decisions to stop —
                   # the guard must never demand work on a parked goal
                   "deferred", "parked", "finished"}
GOAL_TYPES = {"goal", "active_work"}
DEFAULT_CAP = 25
PAUSE_REL = ".prd_plugin/local/autonomy-pause"
OPT_OUT_VALUES = {"off", "0", "false", "disable", "disabled"}


def _read_json(path):
    return json.loads(Path(path).read_text(encoding="utf-8-sig"))


def _automation(root):
    cfg = _read_json(Path(root) / ".prd_plugin" / "config.json")
    return cfg.get("automation", {}) if isinstance(cfg, dict) else {}


def opted_out():
    """Spawner contract: worker sessions are never conscripted."""
    if os.environ.get("PRD_STOP_GUARD", "").strip().lower() in OPT_OUT_VALUES:
        return True
    if os.environ.get("PRD_WORKER_SESSION", "").strip().lower() in {"1", "true", "yes"}:
        return True
    return False


def active_goal(root):
    """Most-recently-updated open TRK-* goal/active_work record id, or None.

    Preferring the newest open goal avoids latching onto a stale record that an
    earlier session forgot to close.
    """
    data = _read_json(Path(root) / ".prd_plugin" / "state" / "tracking.json")
    open_goals = [
        rec for rec in data.get("records", [])
        if isinstance(rec, dict) and rec.get("type") in GOAL_TYPES
        and str(rec.get("status", "")).lower() not in CLOSED_STATUSES
    ]
    if not open_goals:
        return None
    open_goals.sort(key=lambda r: (str(r.get("updated_at") or ""),
                                   str(r.get("created_at") or ""),
                                   str(r.get("id") or "")))
    return open_goals[-1].get("id")


def is_paused(root):
    return (Path(root) / PAUSE_REL).is_file()


def clear_pause(root):
    try:
        (Path(root) / PAUSE_REL).unlink()
    except OSError:
        pass


def _stop(reason):
    return {"action": "stop", "reason": reason, "goal": None}


def decide(root, continue_count, session_id=None, owners=None):
    """Pure decision: continue or stop. Any failure resolves to stop."""
    if opted_out():
        return _stop("PRD_STOP_GUARD opt-out set — worker session, allowing stop")
    try:
        auto = _automation(root)
    except Exception:
        return _stop("could not read config")
    if auto.get("autonomy_level") != "autonomous":
        return _stop("autonomy tier is not autonomous")
    if not auto.get("autonomous_run_until_done", True):
        return _stop("autonomous_run_until_done is off")
    if is_paused(root):
        return _stop("autonomy-pause requested — allowing stop for the consent floor / a blocker")
    try:
        goal = active_goal(root)
    except Exception:
        return _stop("could not read tracking state")
    if not goal:
        return _stop("no active goal — nothing left to continue")
    # Session ownership: only the session bound to this goal may be blocked.
    # Everyone else (headless workers, advisor consults) stops freely.
    if session_id is not None and owners:
        owner = owners.get(goal)
        if owner and owner != session_id:
            return _stop(f"goal {goal} is owned by another session — this session is free to stop")
    try:
        cap = int(auto.get("autonomous_continue_cap", DEFAULT_CAP))
    except (TypeError, ValueError):
        cap = DEFAULT_CAP
    if continue_count >= cap:
        return _stop(f"autonomous continue cap reached ({cap}) — allowing stop")
    reason = (
        f"Autonomous run-until-done: goal {goal} is still active. Continue with the "
        "next incomplete step toward it — do not stop to ask 'shall I proceed?'. "
        "If you genuinely need the user (consent floor: push/merge/publish/secrets, "
        "or a real blocker), create the file .prd_plugin/local/autonomy-pause with a "
        "one-line reason and then stop; the guard will let you. Otherwise keep going."
    )
    return {"action": "continue", "reason": reason, "goal": goal}


# --- per-session state (runtime, gitignored) -------------------------------
# Schema: {"counters": {session_id: int}, "owners": {goal_id: session_id}}.
# Legacy flat {session_id: int} files are migrated on read.

def _state_path(root):
    return Path(root) / ".prd_plugin" / "local" / "stop-guard-state.json"


def _load_state(root):
    p = _state_path(root)
    data = {}
    if p.is_file():
        try:
            data = _read_json(p)
        except Exception:
            data = {}
    if not isinstance(data, dict):
        data = {}
    if "counters" not in data and "owners" not in data:
        # legacy shape: flat {session: count}
        counters = {k: v for k, v in data.items() if isinstance(v, int)}
        data = {"counters": counters, "owners": {}}
    data.setdefault("counters", {})
    data.setdefault("owners", {})
    if not isinstance(data["counters"], dict):
        data["counters"] = {}
    if not isinstance(data["owners"], dict):
        data["owners"] = {}
    return data


def _save_state(root, data):
    p = _state_path(root)
    try:
        p.parent.mkdir(parents=True, exist_ok=True)
        tmp = p.with_suffix(p.suffix + ".tmp")
        tmp.write_text(json.dumps(data), encoding="utf-8")
        os.replace(tmp, p)
    except OSError:
        pass


def _prune_owners(owners, current_goal):
    """Drop bindings for goals that are no longer the active goal (closed,
    superseded, or replaced). One goal is active at a time by design."""
    return {g: s for g, s in owners.items() if g == current_goal}


def main():
    try:
        raw = sys.stdin.read()
        payload = json.loads(raw) if raw.strip() else {}
    except Exception:
        payload = {}
    if not isinstance(payload, dict):
        payload = {}

    root = payload.get("cwd") or str(Path.cwd())
    session = payload.get("session_id") or "default"

    state = _load_state(root)
    counters = state["counters"]
    owners = state["owners"]
    # Cumulative per-session count of forced continuations; reset only on an
    # allow-stop (counting only while stop_hook_active was true let the cap
    # reset every natural stop and never bite).
    count = int(counters.get(session, 0))

    d = decide(root, count, session_id=session, owners=owners)

    if d["action"] == "continue":
        counters[session] = count + 1
        owners.setdefault(d["goal"], session)  # first blocked session owns the goal
        state["owners"] = _prune_owners(owners, d["goal"])
        _save_state(root, state)
        print(json.dumps({"decision": "block", "reason": d["reason"]}))
        return 0

    # allow stop: reset this session's counter, prune stale ownership bindings,
    # and clear any pause marker
    changed = False
    if session in counters:
        counters.pop(session, None)
        changed = True
    try:
        current = active_goal(root)
    except Exception:
        current = None
    pruned = _prune_owners(owners, current)
    if pruned != owners:
        state["owners"] = pruned
        changed = True
    if changed:
        _save_state(root, state)
    if is_paused(root):
        clear_pause(root)
    # silent allow (no output) — nothing to inject
    return 0


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