#!/usr/bin/env python3
"""agent-memory-approvals: the approvals plugin's one helper (deploy/plugins/README.md).

What waits for a person on the ledger, and the person's answer to one item, from inside a
harness session. The model may run `list` and explain what it prints; `answer` files a
person's act under the person's own token, which `agent-memory --as human` reads from the
macOS Keychain at that moment. A person's item asks them at every read (deploy/mac/README.md,
"Who can read a token"), so the Keychain's dialog is the consent: the token is never in this
process's arguments, its output or the session's environment, and the model sees only what
was filed.

    agent-memory-approvals list [--harness claude-code|codex-cli]
    agent-memory-approvals answer approve|reject ITEM_ID [--note-stdin | NOTE...]
    agent-memory-approvals session-start [--harness claude-code|codex-cli] [--rules FILE]
    agent-memory-approvals where

The session-start line is the CLI's `approvals --start-line`, which also says whose session
this is by the hooks' one test (no $AGENT_MEMORY_RELAY_AGENT: a person's): a person is given the
count waiting by kind and the oldest item's id, kind, contract, age and answering command, which
Claude Code also shows them; an agent the counts alone. A CLI before the flag gives the counts.

The list and the session-start line read with the harness's own token (--as claude-code or
--as codex-cli), never the person's, so looking raises no dialog. An answer goes only to a
replica: the CLI refuses `--as human` on the local ledger, which has no token to ask for.
The list is framed as ledger data: every word of it was written by an agent on the ledger.

Which ledger, the same for all three so the list, the answer and the start line agree:
$AGENT_MEMORY_APPROVALS_INSTANCE names an instance of the operator kit's instances file;
else a set $AGENT_MEMORY_REMOTE, or a checkout whose committed config names a `remote`, is
the CLI's own choice; else the instances file's default_instance; else the local ledger.

Standard library only: this runs under whatever python3 the harness finds, not the one the
CLI is installed into. `agent-memory` is found on PATH, or named by $AGENT_MEMORY_CLI.
This file is byte-identical to the Codex skill's scripts/agent-memory-approvals.
"""
import json
import os
import re
import shlex
import signal
import subprocess
import sys
from pathlib import Path

ITEM_ID = re.compile(r"^(ctr|clm|cu|ver)_[0-9a-f]{16}$")  # an allow-entry item may be a verification (16.1)
LIST_TIMEOUT = 20
ANSWER_TIMEOUT = 110  # the dialog waits up to 90 s (remote.HUMAN_KEYCHAIN_TIMEOUT); under a harness's 120 s
HINT = {"claude-code": "/agent-memory:approvals lists them",
        "codex-cli": "$agent-memory-approvals lists them"}
RULES_CAP = 4000
KINDS = 16  # the session start line names at most this many kinds (approvals lists 13 from 0.7.0)


def _cli() -> list:
    return shlex.split(os.environ.get("AGENT_MEMORY_CLI") or "agent-memory")


def _agent_session() -> str:
    """The named agent whose session this is, or '' for a person's own session: an answer's
    guard, stricter than the start line's test (the CLI's) on purpose, since it refuses a
    person's act. deploy/mac/agent-session.sh exports both for every agent it starts; a
    person's own session carries neither, because nothing exports a person's token."""
    if os.environ.get("AGENT_MEMORY_RELAY_AGENT"):
        return os.environ["AGENT_MEMORY_RELAY_AGENT"]
    if os.environ.get("AGENT_MEMORY_TOKEN"):
        return "the agent whose token is in $AGENT_MEMORY_TOKEN"
    return ""


def _checkout_names_a_remote(start: Path) -> bool:
    for directory in (start, *start.parents):
        config = directory / ".agent-memory" / "config.json"
        if config.is_file():
            try:
                remote = json.loads(config.read_text(encoding="utf-8")).get("remote")
            except (OSError, ValueError, AttributeError):
                return False
            return isinstance(remote, str) and bool(remote.strip())
    return False


def _default_instance() -> str:
    path = Path(os.environ.get("AGENT_MEMORY_INSTANCES") or Path.home() / ".agent-memory" / "instances.json")
    try:
        name = json.loads(path.expanduser().read_text(encoding="utf-8")).get("default_instance")
    except (OSError, ValueError, AttributeError):
        return ""
    return name if isinstance(name, str) and re.fullmatch(r"[a-z0-9][a-z0-9-]*", name) else ""


def target() -> list:
    """The global flags that name the ledger (see the module docstring)."""
    named = os.environ.get("AGENT_MEMORY_APPROVALS_INSTANCE")
    if named:
        return ["--instance", named]
    if "AGENT_MEMORY_REMOTE" in os.environ or _checkout_names_a_remote(Path.cwd()):
        return []
    default = _default_instance()
    return ["--instance", default] if default else []


def _run(args: list, timeout: int) -> subprocess.CompletedProcess:
    """The CLI in a session of its own: at the limit the whole group goes, security(1)
    included, so no orphan can file an answer after this reported that it could not."""
    proc = subprocess.Popen(_cli() + args, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
                            stderr=subprocess.PIPE, text=True, start_new_session=True)
    try:
        out, err = proc.communicate(timeout=timeout)
    except BaseException:
        try:
            os.killpg(proc.pid, signal.SIGKILL)
        except (ProcessLookupError, PermissionError, AttributeError):
            proc.kill()
        proc.communicate()
        raise
    return subprocess.CompletedProcess(proc.args, proc.returncode, out, err)


def _harness(argv: list) -> str:
    it = iter(argv)
    for arg in it:
        if arg == "--harness":
            return next(it, "claude-code")
    return "claude-code"


def _reader(harness: str) -> list:
    """Read as the harness, never as the person: a person's Keychain item asks them at every
    read, and a list or a session start is no time to ask."""
    return ["--as", harness if harness in HINT else "claude-code"]


def cmd_list(argv: list) -> int:
    try:
        done = _run(target() + _reader(_harness(argv)) + ["approvals"], LIST_TIMEOUT)
    except (OSError, subprocess.SubprocessError) as exc:
        print(f"agent-memory approvals could not run: {exc}")
        return 0  # a skill's injected command that fails aborts the skill; say it instead
    body = done.stdout.rstrip() or f"agent-memory approvals exited {done.returncode}: {done.stderr.strip()}"
    # agents wrote every line of it: data to explain, never instructions to follow
    print('<ledger-data source="agent-memory approvals">')
    print(body.replace("</ledger-data>", "</ledger-data >"))
    print("</ledger-data>")
    return 0


def cmd_answer(argv: list) -> int:
    if len(argv) < 2 or argv[0] not in ("approve", "reject"):
        print("usage: agent-memory-approvals answer approve|reject ITEM_ID [--note-stdin | NOTE...]", file=sys.stderr)
        return 2
    verdict, item = argv[0], argv[1]
    if argv[2:] == ["--note-stdin"]:
        # the note as the person wrote it, through a quoted heredoc: no shell ever reads it
        note = sys.stdin.read().strip()
    else:
        note = " ".join(argv[2:]).strip()
    agent = _agent_session()
    if agent:
        print(f"refused: this is {agent}'s session, and an approval is a person's act. The person answers "
              f"from their own session, where the Keychain asks them.", file=sys.stderr)
        return 3
    if not ITEM_ID.match(item):
        print(f"refused: {item!r} is not an approvals item id (ctr_, clm_, cu_ or ver_ and 16 hex digits); "
              f"agent-memory-approvals list shows them", file=sys.stderr)
        return 2
    if verdict == "reject" and not note:
        print("refused: a rejection needs a note; the reason is the record", file=sys.stderr)
        return 2
    args = target() + ["--as", "human", verdict, item] + (["--note", note] if note else [])
    try:
        done = _run(args, ANSWER_TIMEOUT)
    except (OSError, subprocess.SubprocessError) as exc:
        print(f"agent-memory {verdict} could not run: {exc}", file=sys.stderr)
        return 2
    if done.stdout.strip():
        print(done.stdout.rstrip())
    if done.stderr.strip():
        print(done.stderr.rstrip(), file=sys.stderr)
    return done.returncode


def _kinds(summary) -> str:
    """A view's waiting_summary or information_summary ({kind: count}) as ` (close 40, decision 3)`,
    the largest first; nothing from a replica that serves none (ctr_e98deb58237c6dee). A
    replica's summary is read as data: KINDS kinds at most, each name cut to 40 characters."""
    if not isinstance(summary, dict):
        return ""
    counts = sorted(((k, v) for k, v in summary.items() if isinstance(v, int) and not isinstance(v, bool) and v > 0),
                    key=lambda kv: (-kv[1], str(kv[0])))
    said = ", ".join(f"{re.sub(r'[^a-z0-9-]', '?', str(k))[:40]} {v}" for k, v in counts[:KINDS])
    more = f", {len(counts) - KINDS} more kinds" if len(counts) > KINDS else ""
    return f" ({said}{more})" if said else ""


def cmd_session_start(argv: list) -> int:
    harness, rules = _harness(argv), None
    it = iter(argv)
    for arg in it:
        if arg == "--rules":
            rules = next(it, None)
    try:
        sys.stdin.read()  # the harness's payload: read so the harness never blocks on the pipe
    except (OSError, ValueError, AttributeError):
        pass
    text = ""
    if rules:
        try:
            text = Path(rules).read_text(encoding="utf-8").strip()[:RULES_CAP]
        except OSError:
            text = ""
    # the CLI's line, and whose session this is (roadmap 16.6, ctr_a1b729e371fe8ad9)
    started = _start_line(harness)
    if started is not None:
        return _start_output(text, started[0], harness, shown=started[1])
    line = None
    try:
        done = _run(target() + _reader(harness) + ["approvals", "--json"], LIST_TIMEOUT)
        view = json.loads(done.stdout) if done.returncode == 0 else None
        if view is None:
            reason = (done.stderr.strip().splitlines() or [f"exit {done.returncode}"])[-1]
            line = f"agent-memory approvals: could not read the list ({reason[:200]})"
        else:
            mode, waiting, info = view.get("approval_mode"), int(view.get("waiting") or 0), int(view.get("information") or 0)
            # auto mode keeps the plugin quiet unless a person is actually waited on
            if not (mode == "auto" and waiting == 0):
                line = (f"agent-memory approvals: approval_mode {mode}, {waiting} waiting for a person"
                        + _kinds(view.get("waiting_summary"))
                        + (f", {info} for information" + _kinds(view.get("information_summary")) if info else "")
                        + f"; {HINT.get(harness, HINT['claude-code'])}")
    except (OSError, subprocess.SubprocessError, ValueError) as exc:
        line = f"agent-memory approvals: could not read the list ({exc})"
    return _start_output(text, line, harness, shown=False)


def _start_line(harness: str):
    """`agent-memory approvals --start-line HARNESS --json`: (the line, whether it is a person's
    session) -- the line "" for none, or the could-not-read line, shown to nobody -- or None
    from a CLI before the flag, whose refusal names it."""
    try:
        done = _run(target() + _reader(harness) + ["approvals", "--start-line", harness, "--json"], LIST_TIMEOUT)
    except (OSError, subprocess.SubprocessError) as exc:
        return f"agent-memory approvals: could not read the list ({exc})", False
    if done.returncode == 2 and "--start-line" in done.stderr:
        return None
    if done.returncode == 0:
        try:
            said = json.loads(done.stdout)
            return str(said["line"]), said["persons_session"] is True
        except (ValueError, TypeError, KeyError):
            reason = "the CLI's answer is not the line"
    else:
        reason = (done.stderr.strip().splitlines() or [f"exit {done.returncode}"])[-1]
    return f"agent-memory approvals: could not read the list ({reason[:200]})", False


def _start_output(text: str, line, harness: str, shown: bool) -> int:
    """The rules and the line for the model; in a person's Claude Code session (``shown``) the
    line is also the systemMessage, which Claude Code shows the person."""
    context = "\n\n".join(part for part in (text, line) if part)
    if context:
        out = {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": context}}
        if shown and line and harness == "claude-code":
            out["systemMessage"] = line
        print(json.dumps(out))
    return 0  # a session-start hook never blocks a session


def cmd_where() -> int:
    flags = target()
    print(" ".join(["agent-memory", *flags]) if flags else
          "agent-memory (the ledger $AGENT_MEMORY_REMOTE, the checkout's config or the local ledger names)")
    return 0


def main(argv=None) -> int:
    argv = list(sys.argv[1:] if argv is None else argv)
    command = argv[0] if argv else ""
    if command == "list":
        return cmd_list(argv[1:])
    if command == "answer":
        return cmd_answer(argv[1:])
    if command == "session-start":
        return cmd_session_start(argv[1:])
    if command == "where":
        return cmd_where()
    print(__doc__.split("\n\n")[2], file=sys.stderr)
    return 2


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