#!/usr/bin/env python3
"""rein -- CLI for the Rein agentic kit.

Subcommands:
  rein doctor         environment probe (plugin wiring, CLIs, config, stack);
                      --json returns {"version", "pluginRoot", "project", "plan",
                      "verifyState", "ledger", "staleness"} -- staleness is the
                      installed-vs-marketplace verdict ("verdict", "reason",
                      "installedVersion", "availableVersion")
  rein setup          probe the recommended retrieval tools; --install adds what is missing
  rein detect         resolved stack + commands as JSON (config > runner > auto)
  rein verify         actually RUN each resolved command and report the truth
  rein tasks          parsed plan as JSON (tasks.md or openspec)
  rein plan-check     mechanical findings on a drafted plan's own text, as JSON
  rein context        detect + tasks in ONE call -- what the loop's first agent runs
  rein close <id>     tick a task's checkbox in the plan
  rein next           the DETERMINISTIC gate: is there a task to claim, and may it be
  rein review         record / check a review episode (approval is state-bound)
  rein token-report   real per-model token accounting for the last agent run
  rein event <name>   record a skill invocation as an event (D3, never a run)
  rein event task <task-id> <started|verified|blocked|merged>
                      record a task TRANSITION as it happens (T002) -- rejects any
                      other word by name, without writing
  rein state          per-task transitions folded from the event log + plan (T002);
                      given a workspace, folds every member into one table
  rein ledger         summary of recorded runs (per project / per session);
                      --json returns {"runs": [...], "events_by_project": {...}} --
                      runs is the unchanged array of run rows, events_by_project is
                      skill-invocation counts (D3), counted separately and never
                      folded into a run
  rein baseline       mark/show/clear the reference run compared against
  rein dashboard      serve (or --json print) the ledger as a view model
  rein workspace      discover `.rein/workspace.json` and report every member
                      repo's branch and head; exits 0 with one line when none
                      is found
  rein sync --plane   project local state onto Plane (T006); exits 0 with one
                      line when `plane.json` is absent -- state is derived
                      locally, Plane is a projection, and this is fully
                      additive: no other subcommand reads `plane.json` (D1)

Kept dependency-free on purpose: a plugin that drags a toolchain along at install
time is a plugin nobody installs. Python 3 stdlib only.
"""

from __future__ import annotations

import json
import os
import shutil
import sys

VERSION = "0.13.2"

HERE = os.path.dirname(os.path.abspath(os.path.realpath(__file__)))
PLUGIN_ROOT = os.path.dirname(HERE)
sys.path.insert(0, os.path.join(PLUGIN_ROOT, "lib"))

import ansi as _ansi  # noqa: E402
import dashboard as _dash  # noqa: E402
import detect as _detect  # noqa: E402
import events as _events  # noqa: E402
import gate as _gate  # noqa: E402
import plan as _plan  # noqa: E402
import plan_check as _plan_check  # noqa: E402
import plane_client as _pclient  # noqa: E402
import plane_sync as _psync  # noqa: E402
import product_state as _pstate  # noqa: E402
import setup as _setup  # noqa: E402
import serve as _serve  # noqa: E402
import token_report as _tr  # noqa: E402
import verify as _verify  # noqa: E402
import version_staleness as _staleness  # noqa: E402
import workspace as _workspace  # noqa: E402

USAGE = __doc__


def _opt(argv: list[str], name: str, default: str = "") -> str:
    """Read `--name value` or `--name=value` without pulling in argparse."""
    for i, a in enumerate(argv):
        if a == f"--{name}" and i + 1 < len(argv):
            return argv[i + 1]
        if a.startswith(f"--{name}="):
            return a.split("=", 1)[1]
    return default


def _positional(argv: list[str]) -> str:
    for i, a in enumerate(argv):
        if a.startswith("-"):
            continue
        if i > 0 and argv[i - 1].startswith("--") and "=" not in argv[i - 1]:
            continue  # value of a preceding flag
        return a
    return ""


def _positionals(argv: list[str]) -> list[str]:
    """Every non-flag token, in order -- `_positional` only ever returns the
    first one, and `rein event task <id> <transition>` needs both.
    """
    out: list[str] = []
    i = 0
    while i < len(argv):
        a = argv[i]
        if a.startswith("--"):
            i += 1 if "=" in a else 2  # `--flag=value` is one token, `--flag value` is two
            continue
        out.append(a)
        i += 1
    return out


def cmd_detect(argv: list[str]) -> int:
    print(json.dumps(_detect.resolve(_positional(argv) or "."), indent=2))
    return 0


def cmd_verify(argv: list[str]) -> int:
    """Run every resolved command for real -- an inference is not a fact (D2).

    This command itself never repairs, installs, or writes anything -- it
    only runs and reports. But it runs the PROJECT'S OWN real test/lint/
    typecheck commands, in the operator's actual checkout, and those may
    write whatever they normally write (caches, coverage, build info): `rein
    verify` can leave a working tree dirtier than it found it, as a property
    of the commands being verified, not of this command.

    Exits non-zero only when a command is NOT INVOCABLE (missing binary, a
    shell resolution failure): a setup problem. A command that ran and
    reported failure, or one that timed out, is the ordinary state of a repo
    mid-change and does not fail this exit code -- conflating the two is
    exactly the misdirection this command exists to remove.

    `--only slot1,slot2` restricts execution to those slots -- the loop's
    Prepare precheck reads only test/lint/typecheck and has no use for
    running `build` (or the whole `test` suite a second time) in the
    operator's main checkout before Isolate.
    """
    root = _positional(argv) or "."
    timeout_raw = _opt(argv, "timeout", str(_verify.DEFAULT_TIMEOUT))

    # --plan: the per-task Verification commands, not the project's slots.
    # Nobody ran these before implementers were paid, and two tasks in a real
    # plan named a test module that did not exist -- they "passed" against
    # `no tests collected` and the defect surfaced two hours later at review.
    if "--plan" in argv:
        try:
            timeout = float(timeout_raw)
        except ValueError:
            timeout = _verify.DEFAULT_TIMEOUT
        resolved_root = os.path.abspath(root)
        plan_doc = _plan.read_plan(resolved_root, _opt(argv, "change", ""))
        report = _verify.verify_plan(resolved_root, plan_doc.get("tasks") or [], timeout=timeout)
        if "--json" in argv:
            print(json.dumps(report, indent=2))
        else:
            print(f"plan verifications for {report['root']}")
            if not report["results"]:
                print("  (the plan declares no tasks)")
            for tid, r in report["results"].items():
                mark = "✗" if r["outcome"] in (_verify.OUTCOME_PROVES_NOTHING,
                                               _verify.OUTCOME_NOT_INVOCABLE) else "·"
                print(f"  {mark} {tid:<6} {r['outcome']:<15} {r['command'][:56]}")
                if r.get("reason"):
                    print(f"           {r['reason'][:90]}")
            if report["unusable"]:
                print(f"\n  UNUSABLE: {', '.join(report['unusable'])} — these cannot confirm their "
                      f"own criteria, so an implementer would 'pass' without proving anything")
            else:
                print("\n  every verification can prove something (failing is fine; proving nothing is not)")
        # Non-zero ONLY for unusable. A verification that fails before the work
        # exists is the normal state of a plan and must not stop anything.
        return 0 if report["allUsable"] else 1
    try:
        timeout = float(timeout_raw)
    except ValueError:
        print(f"invalid --timeout: {timeout_raw!r}")
        return 2
    only_raw = _opt(argv, "only")
    only = {s.strip() for s in only_raw.split(",") if s.strip()} if only_raw else None

    resolved = _detect.resolve(root)
    report = _verify.verify_commands(resolved, timeout, only=only)
    _verify.write_state(resolved["root"], report)

    if "--json" in argv:
        print(json.dumps(report, indent=2))
        return 0 if report["allInvocable"] else 1

    print(f"verify: {resolved['root']}")
    if not report["results"]:
        print("  (no commands resolved -- nothing to verify)")
        return 0
    for slot, res in sorted(report["results"].items()):
        code = res["exitCode"] if res["exitCode"] is not None else "-"
        print(f"  {slot:<10} [{res['outcome']:<13}] exit={code}  {res['elapsedMs']}ms  $ {res['command']}")
        if res["outcome"] in (_verify.OUTCOME_NOT_INVOCABLE, _verify.OUTCOME_TIMEOUT) and res["error"]:
            print(f"    {res['error']}")
        for line in res["outputHead"][:5]:
            print(f"    | {line}")
    if not report["allInvocable"]:
        not_invocable = [s for s, r in report["results"].items() if not r["invocable"]]
        print(f"\n  NOT INVOCABLE: {', '.join(sorted(not_invocable))} -- a setup problem, fix the environment")
    return 0 if report["allInvocable"] else 1


def cmd_tasks(argv: list[str]) -> int:
    root = _positional(argv) or "."
    cfg = _detect.resolve(root)
    print(
        json.dumps(
            _plan.read_plan(
                root,
                source=_opt(argv, "source") or cfg["plan"]["source"],
                change=_opt(argv, "change"),
                configured=_opt(argv, "path") or cfg["plan"].get("path", ""),
            ),
            indent=2,
        )
    )
    return 0


def cmd_plan_check(argv: list[str]) -> int:
    """Mechanical, pre-agent findings on a drafted plan's own text (T001:
    the-plan-checks-itself) -- the shipped entry point for `plugins/rein/lib/
    plan_check.py`, which is otherwise unreachable in production.

    `rein plan-check <plan-file>` reads PLAN-FILE as plan markdown -- the
    drafted text before it is written to `plan.path`, or an already-written
    tasks.md -- and prints its BLOCKING/IMPORTANT findings as JSON. It never
    parses argv beyond the one positional path: no root resolution, no
    `flow.config.json` lookup, because the drafted text this exists to check
    usually is not on disk at `plan.path` yet.

    D5: unavailable is never a stop. A missing path, an unreadable file, or
    text that fails to parse each yield an empty `findings` list with the
    problem named in `error` -- this command always exits 0, so a caller can
    never be blocked by the check itself, only by what it finds.
    """
    path = _positional(argv)
    error = ""
    findings: list[dict] = []
    if not path:
        error = "usage: rein plan-check <plan-file>"
    elif not os.path.exists(path):
        error = f"no file at {path}"
    else:
        try:
            with open(path, encoding="utf-8", errors="replace") as fh:
                text = fh.read()
        except OSError as exc:
            error = str(exc)
        else:
            findings = _plan_check.mechanical_findings(text)

            # The half that needs the repository: a verification naming a test

            # module that neither exists nor is promised. Decidable, so BLOCKING.

            findings += _plan_check.unbacked_findings(text, os.path.dirname(os.path.abspath(path)) or '.')
    print(json.dumps({"path": path, "error": error, "findings": findings}, indent=2))
    return 0


def cmd_context(argv: list[str]) -> int:
    """Everything the loop needs to start, in one bash round-trip.

    The whole thesis is that turns cost context re-reads. Two commands is two
    round-trips; one is one.
    """
    root = _positional(argv) or "."
    cfg = _detect.resolve(root)
    plan = _plan.read_plan(
        root,
        source=_opt(argv, "source") or cfg["plan"]["source"],
        change=_opt(argv, "change"),
        configured=_opt(argv, "path") or cfg["plan"].get("path", ""),
    )
    ordered, stuck = _plan.order_by_dependencies(plan["pending"])
    print(
        json.dumps(
            {
                "config": cfg,
                "plan": {**plan, "tasks": plan["tasks"], "pending": ordered, "unresolvableDeps": stuck},
            },
            indent=2,
        )
    )
    return 0


def cmd_close(argv: list[str]) -> int:
    task_id = _positional(argv)
    if not task_id:
        print("usage: rein close <task-id> [root] [--change X]")
        return 2
    root = _opt(argv, "root", ".")
    cfg = _detect.resolve(root)
    plan = _plan.read_plan(root, source=cfg["plan"]["source"], change=_opt(argv, "change"),
                           configured=cfg["plan"].get("path", ""))
    if not plan["exists"]:
        print(plan.get("error") or f"no plan at {plan['path']}")
        return 1
    if _plan.close_task(plan["path"], task_id):
        print(f"closed {task_id} in {plan['path']}")
        return 0
    print(f"{task_id}: not found or already closed in {plan['path']}")
    return 1


def cmd_workspace(argv: list[str]) -> int:
    """Discover `.rein/workspace.json` above `start` and report each member.

    Never fails the caller (D4-style): no descriptor is not an error, it is
    the common case for a single-repo project -- one explanatory line, exit
    0. A member that could not be resolved is reported on stderr and simply
    does not get a line; the survivors still print.
    """
    start = _positional(argv) or "."
    found = _workspace.discover(start)
    if not found["found"]:
        print(f"no workspace descriptor found (.rein/workspace.json) above {os.path.abspath(start)}")
        return 0
    if found["doc"] is None:
        print(f"rein workspace: could not read {found['path']}: {found['error']}", file=sys.stderr)
        return 0

    ok, problems = _workspace.members(found["doc"], found["root"])
    for name, reason in problems:
        print(f"rein workspace: skipping {name}: {reason}", file=sys.stderr)
    for name, path, branch, head in ok:
        print(f"{name}\t{branch}\t{head}\t{path}")
    return 0


def cmd_sync(argv: list[str]) -> int:
    """T006: project local state onto Plane. Fully additive -- this is the
    only subcommand that imports `plane_sync`/`plane_client`, so deleting
    `plane.json` cannot change what any other subcommand prints (D1).

    Three config problems exit differently on purpose (AC3), because they
    mean different things: not configured is not an error (exit 0); a
    missing API key or a missing workspace both need a human to act, so
    they exit non-zero naming what to do.
    """
    if not argv or argv[0] != "--plane":
        print("usage: rein sync --plane [root]")
        return 2
    root = _positional(argv[1:]) or "."

    try:
        report = _psync.run_sync(root)
    except _psync.NotConfigured:
        print("rein sync --plane: not configured (no plane.json in this repo) -- nothing to do")
        return 0
    except _pclient.PlaneAuthError as exc:
        print(f"rein sync --plane: {exc}", file=sys.stderr)
        return 1
    except _psync.WorkspaceMissing as exc:
        print(f"rein sync --plane: {exc}", file=sys.stderr)
        return 1
    except _pclient.PlaneConfigError as exc:
        # `plane.json` names where the API key gets sent. A bad value there is
        # a config problem like the three above, not a crash.
        print(f"rein sync --plane: {exc}", file=sys.stderr)
        return 1
    except _pclient.PlaneUnreachable as exc:
        # The fourth and commonest failure: Plane down, wrong host, TLS. AC3
        # distinguished three CONFIG problems and let this one out as a raw
        # urllib traceback.
        print(f"rein sync --plane: {exc}", file=sys.stderr)
        print("  Plane is optional -- delete plane.json and nothing else changes (D1).",
              file=sys.stderr)
        return 1

    for key in report["applied"]:
        print(f"  ok    {key}")
    for item in report["failed"]:
        print(f"  FAIL  {item['key']}: {item['error']}", file=sys.stderr)
    total = len(report["applied"]) + len(report["failed"])
    if total == 0:
        print("rein sync --plane: nothing changed since the last sync")
    return 1 if report["failed"] else 0


def cmd_next(argv: list[str]) -> int:
    """The signal a bounded loop stops on. JSON by default: it is read by scripts."""
    root = _positional(argv) or "."
    cfg = _detect.resolve(root)
    result = _gate.next_task(
        root,
        source=_opt(argv, "source") or cfg["plan"]["source"],
        change=_opt(argv, "change"),
        configured=_opt(argv, "path") or cfg["plan"].get("path", ""),
    )
    print(json.dumps(result, indent=2))
    # Exit code carries the same signal, so `rein next && ...` works in a shell
    # without parsing anything.
    return 0 if result["ready"] else 1


def cmd_review(argv: list[str]) -> int:
    if not argv:
        print("usage: rein review <record|check> [...]")
        return 2
    sub, rest = argv[0], argv[1:]
    root = _opt(rest, "root", ".")
    change = _opt(rest, "change")

    if sub == "record":
        files = [f for f in _opt(rest, "files").split(",") if f.strip()]
        findings = [f for f in _opt(rest, "findings").split("|") if f.strip()]
        try:
            episode = _gate.record_review(
                root, change, _opt(rest, "verdict"), files, findings,
                _opt(rest, "reviewer"), _opt(rest, "agent"),
            )
        except (ValueError, OSError) as exc:
            print(f"refused to record: {exc}")
            return 1
        print(f"recorded {episode['verdict']} for {episode['change'] or '(change)'} -> {episode['path']}")
        print(f"  reviewed {len(episode['reviewed_files'])} file(s), state hash {episode['reviewed_state_hash'][:16]}...")
        return 0

    if sub == "check":
        result = _gate.check_review(root, change)
        if result["ok"]:
            print(f"APPROVED and current -- reviewed by {result['reviewer']} ({result['episode']})")
            for f in result.get("findings", [])[:10]:
                print(f"  finding [{f['severity']}]: {f['text']}")
            return 0
        print(f"gate NOT satisfied: {result['reason']}")
        if result["changed"]:
            print("  changed since approval: " + ", ".join(result["changed"][:10]))
        for f in result.get("findings", [])[:10]:
            print(f"  finding [{f['severity']}]: {f['text']}")
        return 1

    print(f"unknown review subcommand: {sub}\n\nusage: rein review <record|check> [...]")
    return 2


def cmd_setup(argv: list[str]) -> int:
    """Probe by default, install only when asked. A bare run changes nothing."""
    root = _positional(argv) or "."
    if "--json" in argv:
        print(json.dumps(_setup.probe(root), indent=2))
        return 0
    col = _ansi.enabled(argv)

    def print_summary(state: dict) -> None:
        print(_setup.render(state, color=col))
        ignore = _setup.gitignore_lines(root)
        if ignore:
            print(f"\n  these tools write local state into the repo — add to .gitignore: {', '.join(ignore)}")

    if "--install" not in argv:
        # --activate: the repo-scoped half ONLY -- no installs, and never a
        # write to a TRACKED file. It exists for the loop's worktree, which
        # is cut from a committed HEAD and therefore never carries
        # `.serena/` (gitignored, so it cannot travel). Without this, a
        # worktree is unactivated while the base repo's capability says
        # otherwise, and agents are handed serena tools for a directory
        # serena does not know as a project.
        #
        # D5 applies here too: activation IS work. The summary must be the
        # probe taken AFTER activating, not before -- otherwise this run
        # prints "present but inert: serena" and then, in the same run,
        # creates the marker that makes that statement false.
        if "--activate" in argv:
            res = _setup.activate_serena(root)
            print_summary(_setup.probe(root))
            print(f"\n  serena-activate: {'ok' if res['ok'] else 'FAILED'} — {res.get('reason', '')}")
            return 0  # never blocks a caller (D4): activation is a capability, not a gate

        state = _setup.probe(root)
        print_summary(state)
        return 0 if not state["missing"] else 1

    # `--activate` combined with `--install`: activation still runs before
    # install, and the summary is STILL a fresh probe taken after activating
    # -- the same ordering as the `--activate`-only branch above, so the two
    # cannot disagree about whether D5 applies (activation IS work; this run
    # can write `.serena/project.yml` and must not then report serena as
    # "present but inert").
    if "--activate" in argv:
        res = _setup.activate_serena(root)
        print_summary(_setup.probe(root))
        print(f"\n  serena-activate: {'ok' if res['ok'] else 'FAILED'} — {res.get('reason', '')}")
        return 0

    # D5: a summary describes the state AFTER the work, not before it -- a
    # repo whose index this run just built must not be reported inert, and
    # a .gitignore this run just wrote must not still be suggested. So the
    # work happens first; the summary below is a FRESH probe taken after.
    pre = _setup.probe(root)
    # Even with nothing "missing" there may still be work: a repo can carry
    # every binary and still not be activated for serena (D4/T004) -- so
    # --install always runs, it just has less to do when truly nothing
    # needs it.
    if pre["missing"]:
        print(f"installing: {', '.join(pre['missing'])}\n")
    report = _setup.install(root=root)
    failed = False
    for name, res in report["results"].items():
        print(f"  {name}: {'ok' if res['ok'] else 'FAILED'}")
        for step in res.get("steps", []):
            print(f"    $ {step['cmd']}  ->  {'ok' if step['ok'] else 'failed'}")
            if not step["ok"] and step["output"]:
                for line in step["output"].splitlines():
                    print(f"      {line}")
        if not res["ok"]:
            failed = True
            if res.get("reason"):
                print(f"    {res['reason']}")
        if res.get("caveat"):
            print(f"    note: {res['caveat']}")

    print()
    print_summary(_setup.probe(root))
    return 1 if failed else 0


def cmd_token_report(argv: list[str]) -> int:
    return _tr.main(argv)


def cmd_event(argv: list[str]) -> int:
    """`rein event <name>` records a skill invocation (D3) -- never a run,
    never a failure for the caller (D4): always exits 0, even when the
    events file or its directory does not exist yet, or is unwritable (the
    latter is reported on stderr so it is still visible, just never fatal).

    `rein event task <task-id> <transition>` (T002/AC1) records a task
    TRANSITION instead -- emitted while it happens, not reconstructed later
    from a checkbox. Unlike the skill-invocation form, an unknown transition
    is a caller bug, not a transient environment problem: it is rejected BY
    NAME, nothing is written, and this exits non-zero.
    """
    if argv and argv[0] == "task":
        positionals = _positionals(argv)
        task_id = positionals[1] if len(positionals) > 1 else ""
        transition = positionals[2] if len(positionals) > 2 else ""
        if not task_id or not transition:
            print(
                "usage: rein event task <task-id> <started|verified|blocked|merged> "
                "[--change <name>] [--root <path>]",
                file=sys.stderr,
            )
            return 2
        if transition not in _events.TASK_TRANSITIONS:
            print(
                f"rein event: unknown transition {transition!r} -- must be one of "
                f"{', '.join(_events.TASK_TRANSITIONS)}",
                file=sys.stderr,
            )
            return 1
        change = _opt(argv, "change", "")
        root = _opt(argv, "root", ".")
        ok, error = _events.record_task_event(task_id, transition, change=change, root=root)
        if not ok:
            print(f"rein event: could not record {task_id} {transition!r}: {error}", file=sys.stderr)
            return 0  # a write failure is an environment problem, not a caller bug (D4)
        return 0

    name = _positional(argv)
    if not name:
        print("usage: rein event <name> [--root <path>]", file=sys.stderr)
        return 0
    root = _opt(argv, "root", ".")
    ok, error = _events.record_event(name, root)
    if not ok:
        print(f"rein event: could not record {name!r}: {error}", file=sys.stderr)
    return 0


def _print_task_state(rec: dict) -> None:
    label = rec["change"] or "(no change)"
    touched = rec["lastTouchedDays"]
    touched_str = f"{touched:.1f}d ago" if touched is not None else "unknown"
    print(f"  change: {label}   last-touched: {touched_str}   plan: {rec['planPath'] or '(none)'}")
    if not rec["tasks"]:
        print("    (no tasks in the plan)")
        return
    for t in rec["tasks"]:
        when = t["when"] or "-"
        commit = (t["commit"] or "-")[:12]
        print(f"    {t['taskId']:<6} {t['transition']:<10} {when:<22} {commit:<12} {t['title']}")


def cmd_state(argv: list[str]) -> int:
    """AC6: the per-task record `product_state.state()` folds, printed --
    and, when `root` sits inside a `.rein/workspace.json` workspace, folded
    per member so ONE call reports the whole product, not one repo at a time.
    """
    root = _positional(argv) or "."
    as_json = "--json" in argv
    change = _opt(argv, "change", "")

    found = _workspace.discover(root)
    if found["found"] and found.get("doc") is not None:
        ok, problems = _workspace.members(found["doc"], found["root"])
        for name, reason in problems:
            print(f"rein state: skipping {name}: {reason}", file=sys.stderr)
        # Every change per member, not one. An explicit --change still
        # narrows; without it a member with 29 openspec changes used to
        # report `(no change)`.
        by_member = {
            name: ([_pstate.state(path, change=change)] if change
                   else _pstate.state_all(path))
            for name, path, _branch, _head in ok
        }
        if as_json:
            print(json.dumps(by_member, indent=2))
            return 0
        total = sum(len(v) for v in by_member.values())
        print(f"workspace: {found['root']} ({len(by_member)} member(s), {total} change(s))")
        for name, recs in by_member.items():
            print(f"\n== {name} — {recs[0]['root'] if recs else path} ==")
            for rec in recs:
                _print_task_state(rec)
        return 0

    recs = [_pstate.state(root, change=change)] if change else _pstate.state_all(root)
    if as_json:
        print(json.dumps(recs if len(recs) != 1 else recs[0], indent=2))
        return 0
    print(f"state: {recs[0]['root']}")
    for rec in recs:
        _print_task_state(rec)
    return 0


def cmd_ledger(argv: list[str]) -> int:
    rows = _tr.read_ledger()
    # Events are counted separately (D3) and must never take the runs report
    # down with them: read_events() already degrades OSError/bad-JSON/bad-UTF8
    # to [], but guard the call itself too so ANY unexpected events-side
    # failure still leaves `rein ledger` reporting runs.
    try:
        events = _events.read_events()
        event_counts = _events.count_by_project(events)
    except Exception:
        event_counts = {}

    if not rows and not event_counts:
        print(f"ledger is empty ({_tr.LEDGER_PATH})")
        print("run `rein token-report` after a workflow run to record one")
        return 0

    as_json = "--json" in argv
    if as_json:
        print(json.dumps({"runs": rows, "events_by_project": event_counts}, indent=2, ensure_ascii=False))
        return 0

    if rows:
        try:
            baseline = _tr.read_baseline()
        except _tr.BaselineCorruptError as exc:
            print(str(exc))
            return 1
        print(_tr.render_ledger(rows, _tr.LEDGER_PATH, baseline))
    else:
        print(f"no runs recorded yet ({_tr.LEDGER_PATH})")

    if event_counts:
        # D3: skill invocations are counted here, separately -- never folded
        # into any run total above.
        print(f"\nskill invocations (events, not counted in runs) -- {_events.EVENTS_PATH}")
        for project, count in sorted(event_counts.items()):
            print(f"    {project}   {count} invocation(s)")

    return 0


def cmd_baseline(argv: list[str]) -> int:
    if not argv:
        print("usage: rein baseline <mark|show|clear> [wf_id]")
        return 2
    sub, rest = argv[0], argv[1:]

    if sub == "mark":
        try:
            record = _tr.mark_baseline(_positional(rest) or None)
        except (ValueError, OSError) as exc:
            print(str(exc))
            return 1
        project = record.get("project") or "unknown project"
        print(f"baseline marked: {record['wf_id']} ({project})")
        return 0

    if sub == "show":
        try:
            baseline = _tr.read_baseline()
        except _tr.BaselineCorruptError as exc:
            print(str(exc))
            return 1
        print(_tr.render_baseline(baseline))
        return 0

    if sub == "clear":
        print("baseline cleared" if _tr.clear_baseline() else "no baseline marked")
        return 0

    print(f"unknown baseline subcommand: {sub}\n")
    print("usage: rein baseline <mark|show|clear> [wf_id]")
    return 2


def cmd_serve_probe(argv: list[str]) -> int:
    """Start `--command`, poll `--url` until it accepts a TCP connection, tear down.

    Default (single-shot) form: start, poll, ALWAYS tear down, report. Prints
    exactly one JSON object and exits 0 only when ready is true, so an agent
    gets the signal from the exit code without parsing (D3-adjacent: a boolean
    the loop can check, not a sentence). Good for a throwaway boot check.

    `--start --pidfile <path>` starts the same way but leaves the process
    group running past this call (its pgid recorded in `pidfile`) so a render
    can happen against it. `--stop --pidfile <path>` is the matching teardown
    -- the SAME CLI, invoked a second time, still the one thing that owns the
    server's lifecycle (D2).
    """
    if "--stop" in argv:
        pidfile = _opt(argv, "pidfile")
        if not pidfile:
            print("usage: rein serve-probe --stop --pidfile <path>")
            return 2
        stopped, error = _serve.stop(pidfile)
        print(json.dumps({"stopped": stopped, "error": error}))
        return 0 if stopped else 1

    command = _opt(argv, "command")
    url = _opt(argv, "url")
    if not command or not url:
        print(
            "usage: rein serve-probe --command <c> --url <u> [--timeout N] [--cwd <dir>] "
            "[--start --pidfile <path>] | rein serve-probe --stop --pidfile <path>"
        )
        return 2
    timeout_raw = _opt(argv, "timeout", str(_serve.DEFAULT_TIMEOUT))
    try:
        timeout = float(timeout_raw)
    except ValueError:
        print(f"invalid --timeout: {timeout_raw!r}")
        return 2
    cwd = _opt(argv, "cwd", ".")

    if "--start" in argv:
        pidfile = _opt(argv, "pidfile")
        if not pidfile:
            print("usage: rein serve-probe --command <c> --url <u> --start --pidfile <path>")
            return 2
        result = _serve.start(command, cwd, url, timeout, pidfile)
        print(json.dumps(result.to_dict()))
        return 0 if result.ready else 1

    result = _serve.probe(command, cwd, url, timeout)
    print(json.dumps(result.to_dict()))
    return 0 if result.ready else 1


def cmd_dashboard(argv: list[str]) -> int:
    return _dash.main(argv)


def _annotated_verify_state(verify_state, resolved):
    """`verify_state` with each slot's freshness resolved, so `--json` says
    what the text output says.

    A persisted outcome is only about the command it was recorded against.
    flow.config.json, a lockfile-driven autodetect change or a newly-set
    `subproject` key can all rewrite the configured command out from under a
    report that already ran, and the text path has annotated that since it
    shipped. Emitting the raw report under --json made the two surfaces of
    one report disagree.
    """
    if not verify_state:
        return verify_state
    out = dict(verify_state)
    results = {}
    for slot, vr in (verify_state.get("results") or {}).items():
        cmd = (resolved.get("commands") or {}).get(slot)
        fresh = bool(vr) and vr.get("command") == cmd
        results[slot] = {
            **vr,
            "fresh": fresh,
            "freshness": "fresh" if fresh else "stale -- the command changed since `rein verify` last ran",
        }
    out["results"] = results
    return out


def cmd_doctor(argv: list[str]) -> int:
    root = argv[0] if argv and not argv[0].startswith("-") else "."
    resolved = _detect.resolve(root)
    ok = "ok"
    miss = "MISSING"

    plan = _plan.read_plan(
        root,
        source=resolved["plan"]["source"],
        change=_opt(argv, "change"),
        configured=resolved["plan"].get("path", ""),
    )
    if plan["exists"]:
        plan_state = f"{len(plan['pending'])} pending / {len(plan['tasks'])} tasks"
    elif plan["path"]:
        plan_state = f"NOT FOUND at {plan['path']}"
    else:
        # D3: no location to report a NOT FOUND at (an openspec source with
        # no change named) -- `error` names the changes that exist instead.
        plan_state = plan.get("error", "no plan")
    # Last-known verification state, read-only -- doctor never runs a command
    # itself (that is `rein verify`'s job). "known" means a prior `rein
    # verify` ran and persisted a report; nothing here re-derives it.
    verify_state = _verify.read_state(resolved["root"])
    verify_results = (verify_state or {}).get("results", {})
    runs = _tr.read_ledger()
    latest = _tr.latest_workflow_dir()

    # Is `rein` itself out of date? Local files only (D1): compares the
    # installed plugin's version against what its marketplace clone offers.
    # Never touches or even reads VERSION above -- the repo's own version is
    # a third fact that proves nothing about the clone (D3). Report, never
    # act (D2): doctor's exit code never reflects this verdict (D4).
    try:
        staleness, staleness_loaded = _staleness.resolve_verdict(PLUGIN_ROOT)
    except Exception as exc:  # noqa: BLE001 -- a staleness bug must never sink the whole report (D4)
        staleness = _staleness.StalenessResult(_staleness.UNKNOWN, f"staleness check failed: {exc}")
        staleness_loaded = _staleness.LoaderResult(
            installed_doc=None,
            marketplace_doc=None,
            plugin_key=None,
            plugin_name=None,
            marketplace_name=None,
            load_reason=str(exc),
        )

    if "--json" in argv:
        report = {
            "version": VERSION,
            "pluginRoot": PLUGIN_ROOT,
            "project": resolved,
            "plan": {**plan, "state": plan_state},
            # Annotated, not raw: the text path below compares each persisted
            # command against the currently resolved one and says "stale" when
            # they differ. Emitting the raw report here made the two surfaces
            # of the same data disagree -- a machine consumer read `ok` for a
            # command that had changed under it, the exact defect
            # TestChangedCommandIsStale exists to prevent for the text output.
            "verifyState": _annotated_verify_state(verify_state, resolved),
            "ledger": {
                "path": _tr.LEDGER_PATH,
                "runCount": len(runs),
                "latestWorkflowRun": latest,
            },
            "staleness": {
                **staleness.to_dict(),
                # The ORDER is the whole point of the change; a consumer that
                # has to re-derive it can get it wrong. Omitted entirely when
                # the names are unknown -- emitting `... update None` would be
                # a copy-pasteable command that cannot work, which is worse
                # than saying nothing (D3).
                **(
                    {"fixCommands": _staleness.fix_commands(
                        staleness_loaded.marketplace_name, staleness_loaded.plugin_name)}
                    if staleness_loaded.marketplace_name and staleness_loaded.plugin_name
                    else {}
                ),
            },
        }
        print(json.dumps(report, indent=2))
        return 0

    col = _ansi.enabled(argv)
    print(_ansi.paint(f"rein {VERSION}", "bold", on=col))
    print(f"  plugin root : {PLUGIN_ROOT}")
    print(f"  CLAUDE_PLUGIN_ROOT env : {os.environ.get('CLAUDE_PLUGIN_ROOT') or '(not set)'}")
    print(f"  `rein` on PATH : {shutil.which('rein') or '(not on PATH -- call it via $CLAUDE_PLUGIN_ROOT/bin/rein)'}")
    print(f"  version : {staleness.verdict} -- {staleness.reason}")
    if staleness.verdict == _staleness.STALE:
        for cmd in _staleness.fix_commands(staleness_loaded.marketplace_name, staleness_loaded.plugin_name):
            print(f"    $ {cmd}")
    elif staleness.verdict == _staleness.UP_TO_DATE:
        # D3: the clone offering the same version proves nothing about the
        # clone itself, so the refresh line is printed anyway.
        # fix_commands() is the ONE source of this string; spelling it out
        # again here put the same user-facing command in two files, each
        # pinned by a different test.
        refresh = _staleness.fix_commands(
            staleness_loaded.marketplace_name, staleness_loaded.plugin_name)[0]
        print(f"    $ {refresh}")
    print()
    print(f"  project root : {resolved['root']}")
    print(f"  flow.config.json : {'found' if resolved['configFound'] else 'not found (using autodetect)'}")
    print(f"  stack : {resolved['stack']}" + (f"  subtypes: {', '.join(resolved['subtypes'])}" if resolved["subtypes"] else ""))
    print(f"  package manager : {resolved['packageManager'] or '-'}")
    print(f"  task runner : {resolved['taskRunner'] or '-'}")
    print(f"  plan source : {resolved['plan']['source']}   tracker: {resolved['tracker']['kind']}")
    print(f"  plan        : {plan_state}")
    print()
    print(_ansi.paint("  resolved commands:", "bold", on=col))
    if resolved["commands"]:
        # Aligns on the LONGEST slot name actually present, not a fixed
        # width -- a long slot like `harnessTest` must not push the cmd/source
        # columns out of alignment for every other row (finding 4). Never
        # narrower than the historical width of 10, so a fixture with only
        # short names prints byte-identically to before this change.
        slot_width = max([10] + [len(s) for s in resolved["commands"]])
        # The command column was hardcoded at 40 while the single-subproject path
        # now emits `cd <dir> && <cmd>`, which is systematically longer -- so the
        # [source] column broke on exactly the repos this change added support for.
        cmd_width = max([40] + [len(c) for c in (resolved["commands"] or {}).values()])
        for slot, cmd in sorted(resolved["commands"].items()):
            vr = verify_results.get(slot)
            if vr is None:
                status = "  verified: unknown -- run `rein verify`"
            elif vr.get("command") == cmd:
                # Only trust the persisted outcome when it was recorded
                # against the SAME configured command we are printing --
                # flow.config.json, a lockfile-driven autodetect change, or a
                # newly-set `subproject` key can all rewrite `cmd` out from
                # under a report that already ran (finding 1).
                status = f"  verified: {vr['outcome']} (exit={vr['exitCode']})"
            else:
                status = "  verified: stale -- the command changed since `rein verify` last ran"
            source = _ansi.paint(f"[{resolved['commandSources'][slot]}]", "cyan", on=col)
            print(f"    {slot:<{slot_width}} {cmd:<{cmd_width}} {source}{status}")
    else:
        print("    (none)")
    if resolved["missingCommands"]:
        print(f"    {_ansi.paint(miss, 'red', on=col)}: {', '.join(resolved['missingCommands'])} -- set them in flow.config.json")
    if verify_state:
        import datetime as _dt

        checked = _dt.datetime.fromtimestamp(verify_state["checkedAt"]).isoformat(timespec="seconds")
        print(f"    last verified: {checked}")
    print()
    vp = resolved["verifyPolicy"]
    tools_str = ", ".join(vp["tools"]) if vp["tools"] else "(none reachable)"
    print(f"  verify : mode={vp['mode']}  tools={tools_str}")
    if resolved.get("verifyWarnings"):
        for w in resolved["verifyWarnings"]:
            print(f"    WARNING: {w}")
    serve = resolved.get("serve")
    if serve is not None:
        serve_cmd = serve["command"] or miss
        print(f"  serve  : {serve_cmd} @ {serve['url']}")
    print()
    print(f"  models : aux={resolved['models']['aux']}  impl={resolved['models']['impl']}  review={resolved['models']['review']}")
    print(f"  limits : maxTaskSteps={resolved['limits']['maxTaskSteps']}  maxReviewRounds={resolved['limits']['maxReviewRounds']}")
    print()
    print(f"  capabilities : {', '.join(resolved['capabilities']) or '(none)'}")
    for optional in ("graphify", "openspec", "serena", "codegraph"):
        if optional in resolved["capabilities"]:
            state = _ansi.paint(ok, "green", on=col)
        else:
            state = _ansi.paint("absent (optional -- flow degrades, never breaks)", "yellow", on=col)
        print(f"    {optional:<10} {state}")
    print()
    print(f"  ledger : {_tr.LEDGER_PATH} ({len(runs)} run(s) recorded)")
    print(f"  latest workflow run : {latest or '(none found)'}")
    return 0


COMMANDS = {
    "doctor": cmd_doctor,
    "setup": cmd_setup,
    "detect": cmd_detect,
    "verify": cmd_verify,
    "tasks": cmd_tasks,
    "plan-check": cmd_plan_check,
    "context": cmd_context,
    "close": cmd_close,
    "next": cmd_next,
    "review": cmd_review,
    "token-report": cmd_token_report,
    "event": cmd_event,
    "state": cmd_state,
    "ledger": cmd_ledger,
    "baseline": cmd_baseline,
    "dashboard": cmd_dashboard,
    "serve-probe": cmd_serve_probe,
    "workspace": cmd_workspace,
    "sync": cmd_sync,
}


def main(argv: list[str]) -> int:
    if not argv or argv[0] in ("-h", "--help", "help"):
        print(USAGE)
        return 0
    if argv[0] in ("-V", "--version"):
        print(f"rein {VERSION}")
        return 0
    handler = COMMANDS.get(argv[0])
    if not handler:
        print(f"unknown subcommand: {argv[0]}\n")
        print(USAGE)
        return 2
    return handler(argv[1:])


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
