#!/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 --
                      "could these be INVOKED", exit 0 when they all could, even
                      if a suite ran and failed
  rein gate           the other question: did they PASS. Runs the same commands and
                      exits 0 passed / 1 the code is wrong / 126 the environment
                      could not run the checks. --require-review also demands a
                      current APPROVED review episode
  rein role <name>    one role's operating profile (planner|implementer|reviewer)
                      as plain markdown -- the same section Claude Code reads via
                      /rein:rein-role, for any agent that cannot invoke a skill
  rein tasks          parsed plan as JSON (tasks.md or openspec)
  rein plan-check     mechanical findings on a drafted plan's own text, as JSON.
                      Always exits 0 (D5: never a silent skip, never a hard stop) --
                      it runs inside the planning skill, where the judgement is the
                      planner's. `--gate` is for a RUNNER instead: exits 1 on a
                      BLOCKING finding, 126 when the file cannot be read. `--tasks
                      T001,T002` scopes it -- a finding pinned to a task nobody will
                      execute cannot stop the run, while one with no task id is
                      plan-level and always counts
  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 linear list    issues from Linear, filtered (--repo/--priority/--max-priority/
                      --state/--flow/--label/--parent) and ordered urgent-first;
                      index cards are excluded unless --groupers
  rein linear show <ID>
                      one issue, with its prose. Needs REIN_LINEAR_API_KEY
  rein linear comment <ID>
                      leave a comment: --body TEXT, or --body-file PATH (`-` for
                      stdin, which is what multi-line markdown wants)
  rein intake <ID>    take an issue: Beads issue in the owning repo, branch named
                      the way Linear named it, and Backlog -> In Progress
  rein land <ID>      close it out: Beads closed, Linear -> Done. Refuses when
                      the base branch carries no commit naming the issue

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.15.0"

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 backlog as _backlog
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 roles as _roles  # noqa: E402
import linear_client as _lclient  # noqa: E402
import linear_issue as _lissue  # noqa: E402
import linear_select as _lselect  # noqa: E402
import linear_intake as _lintake  # 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_gate(argv: list[str]) -> int:
    """Did the configured commands PASS -- the question `verify` does not ask.

    `rein verify` reports invocability and exits 0 when everything could be
    invoked, so a suite that ran and failed exits 0 there. That is right for a
    precheck (nobody should be paid to work toward a gate that cannot pass)
    and unusable as a gate. This runs the same commands and applies
    `gate.decide_gate` to the same report.

    Three exit codes, and the third is the point: 0 passed, 1 the code is
    wrong, 126 the environment could not run the checks. A caller that gets
    126 has been told to fix its machine, not its code.
    """
    # Root is POSITIONAL here because that is this CLI's convention -- `rein
    # verify <root>`, `rein next <root>`, `rein doctor <root>`. Accepting
    # `--root` instead would have failed silently: `_positional` skips
    # anything starting with `-`, so `rein gate --root=/elsewhere` would have
    # resolved `.` and reported a confident verdict about the wrong repo.
    root = _positional(argv) or "."
    timeout_raw = _opt(argv, "timeout", str(_verify.DEFAULT_TIMEOUT))
    try:
        timeout = float(timeout_raw)
    except ValueError:
        print(f"gate: --timeout must be a number, got {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
    require_review = "--require-review" in argv

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

    review = _gate.check_review(resolved["root"], _opt(argv, "change")) if require_review else None

    # Render evidence arrives as a FILE, written by whatever actually drove a
    # browser -- never as a flag an agent sets. `--render` is the same rule as
    # everywhere else in this kit: a process writes the facts, and this reads
    # them. A frontend repo with no evidence and no way to get it stays at 126,
    # which is what "nobody has looked at the UI" honestly is.
    render = None
    render_path = _opt(argv, "render")
    if render_path:
        try:
            with open(render_path, encoding="utf-8") as fh:
                render = json.load(fh)
        except (OSError, json.JSONDecodeError) as exc:
            print(f"gate: SETUP -- could not read --render {render_path}: {type(exc).__name__}: {exc}")
            return _gate.EXIT_SETUP

    tools_raw = _opt(argv, "browser-tools")
    policy = dict(resolved.get("verifyPolicy") or {})
    if tools_raw:
        policy["tools"] = [t.strip() for t in tools_raw.split(",") if t.strip()]
    serve = {"command": (resolved.get("commands") or {}).get("serve") or "",
             "url": _opt(argv, "url")}

    decision = _gate.decide_gate(report, review=review, require_review=require_review,
                                 verify_policy=policy, serve=serve, render=render)

    if "--json" in argv:
        print(json.dumps({"decision": decision, "report": report}, indent=2))
        return decision["exit"]

    mark = {_gate.GATE_GREEN: "PASS", _gate.GATE_RED: "FAIL", _gate.GATE_SETUP: "SETUP"}[decision["decision"]]
    print(f"gate: {resolved['root']}")
    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']}")
        for line in res["outputHead"][:5]:
            print(f"    | {line}")
    print(f"\n  {mark}: {decision['reason']}")
    if decision["decision"] == _gate.GATE_SETUP:
        print("  fix the environment and run again -- this is not a verdict on the code")
    return decision["exit"]


def cmd_role(argv: list[str]) -> int:
    """One role's operating profile, for an agent that is not Claude Code.

    The profiles live in the `rein-role` SKILL, which Claude Code invokes as
    `/rein:rein-role`. Nothing else can. This prints the SAME section of the
    SAME file, so any agent -- codex, opencode, a shell -- can be handed the
    rules it is expected to be bound by.

    Deliberately not a second copy: this kit already proves one definition has
    two consumers by matching both shipped files against each other, because
    two copies of one rule drift apart with nothing failing. Reading the one
    file makes drift impossible rather than merely detectable.

    Prints and nothing else -- no event is recorded. A command whose job is to
    emit text should be safe to pipe.
    """
    if "--list" in argv or not _positional(argv):
        found = _roles.available(PLUGIN_ROOT)
        if not found:
            print(f"role: SETUP -- no role profiles found at {_roles.skill_path(PLUGIN_ROOT)}")
            return _gate.EXIT_SETUP
        print("usage: rein role <" + "|".join(found) + ">")
        return 0 if "--list" in argv else 2

    result = _roles.profile(_positional(argv), PLUGIN_ROOT)
    if not result["ok"]:
        print(f"role: {result['error']}")
        # An unknown role is the caller's mistake; an unreadable skill file is
        # this installation's. Different exit codes, because a caller that gets
        # 126 has been told to fix its machine, not its argument.
        return 2 if "unknown role" in result["error"] else _gate.EXIT_SETUP

    if "--json" in argv:
        print(json.dumps(result, indent=2))
        return 0
    print(f"# role: {result['role']}\n")
    print(result["text"])
    return 0


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 '.')
    # Scoped to the tasks this run will actually execute: a BLOCKING finding
    # pinned to a task nobody will touch cannot waste an implementer, and
    # stopping for it is the false stop the scoping exists to prevent. A
    # finding with NO taskId is plan-level and always counts.
    only_raw = _opt(argv, "tasks")
    run_ids = [t.strip() for t in only_raw.split(",") if t.strip()] if only_raw else []
    decision = _plan_check.decide_plan_check(findings, run_ids)

    print(json.dumps({"path": path, "error": error, "findings": findings,
                      "decision": decision["decision"], "reason": decision["reason"]}, indent=2))

    # A file that could not be read is a setup problem, not a clean plan. This
    # returned 0 for a missing path, which is the same silent pass the rest of
    # this CLI has been losing.
    # D5 -- "never a silent skip, never a hard stop". This command runs INSIDE
    # /rein:rein-plan, before the plan is written, where a hard stop would
    # replace the planner's judgement with a regex's. So by default it reports
    # and exits 0, missing file included, exactly as it always has.
    #
    # A RUNNER is a different consumer of the same findings: loop.js called
    # decidePlanCheck and stopped before Isolate, because a BLOCKING finding
    # found there costs nothing and the same one found at Review costs a whole
    # run. `--gate` is that second consumer, opt-in, so D5 keeps its default.
    if "--gate" not in argv:
        return 0
    if error:
        return _gate.EXIT_SETUP
    return 1 if decision["decision"] == _plan_check.DECISION_STOP else 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 _linear_int(argv: list[str], name: str):
    """An int option, or `None` when absent. A non-numeric value is refused
    by name rather than silently treated as absent -- `--priority high`
    would otherwise return every issue and look like it worked."""
    raw = _opt(argv, name)
    if not raw:
        return None
    try:
        return int(raw)
    except ValueError:
        raise ValueError(f"--{name} takes a number 0-4, not {raw!r}")


def _comment_request(rest: list[str]) -> tuple:
    """(identifier, body, problem). Pure apart from reading the named file.

    Resolved BEFORE the API key is looked up, so a usage mistake answers
    with the usage mistake instead of demanding a credential it will not
    use.
    """
    identifier = _positional(rest)
    if not identifier:
        return "", "", ("usage: rein linear comment <ID> "
                        "(--body TEXT | --body-file PATH | --body-file -)")

    body = _opt(rest, "body")
    path = _opt(rest, "body-file")
    if body and path:
        return identifier, "", "rein linear comment: pass --body or --body-file, not both"
    if path:
        # `-` reads stdin, and it is the option that matters: a comment is
        # multi-line markdown, and pushing that through shell quoting is how
        # backticks and quotes get mangled on the way to the board. It cost
        # two broken heredocs the day this was written.
        try:
            body = sys.stdin.read() if path == "-" else open(path, encoding="utf-8").read()
        except OSError as exc:
            return identifier, "", f"rein linear comment: cannot read {path}: {exc}"
    if not (body or "").strip():
        return identifier, "", ("rein linear comment: empty comment -- "
                                "nothing to say is not a comment")
    return identifier, body, ""


def _linear_comment(client, identifier: str, body: str) -> int:
    """Leave a comment. The one write an agent needs that is not a state move.

    Exposed because the alternative is every caller hand-rolling GraphQL with
    the personal API key: the credential spread across ad-hoc scripts, with
    no bounded surface, no tests and nothing to audit. The client already had
    this method and `rein intake` / `rein land` already used it -- only the
    subcommand was missing.

    Deliberately NOT accompanied by a bare `rein linear state`: moving an
    issue to Done belongs to `rein land`, which refuses when the base branch
    carries no commit naming it. A free-form state command would route around
    that guard, and the guard is the point.
    """
    posted = client.comment(identifier, body)
    print(f"{identifier}  comentado")
    if posted.get("url"):
        print(f"  {posted['url']}")
    return 0


def cmd_linear(argv: list[str]) -> int:
    """Read the board. Linear is the source; no other repo is consulted.

    This is a parse, not a judgement, so it is a script and not an agent
    (README's rule). The loop needs a stable, filterable, testable answer;
    an MCP call inside a bounded fresh agent is none of those.
    """
    if not argv or argv[0] not in ("list", "show", "comment"):
        print("usage: rein linear list [--repo R] [--priority N] [--max-priority N]\n"
              "                        [--state S] [--flow F] [--label L] [--parent ID]\n"
              "                        [--groupers] [--json]\n"
              "       rein linear show <ID> [--json]\n"
              "       rein linear comment <ID> (--body TEXT | --body-file PATH | --body-file -)")
        return 2
    action, rest = argv[0], argv[1:]
    as_json = "--json" in rest

    # Resolved ONCE, here, and handed down. A usage error must not require a
    # credential to report itself -- and `--body-file -` consumes stdin, so
    # resolving it a second time inside the action would read an exhausted
    # stream and report an empty comment for a comment that was there.
    comment_request = None
    if action == "comment":
        identifier, body, problem = _comment_request(rest)
        if problem:
            print(problem, file=sys.stderr)
            return 2
        comment_request = (identifier, body)

    try:
        client = _lclient.LinearClient()
    except _lclient.LinearAuthError as exc:
        print(f"rein linear: {exc}", file=sys.stderr)
        return 1

    try:
        if action == "comment":
            return _linear_comment(client, *comment_request)

        if action == "show":
            identifier = _positional(rest)
            if not identifier:
                print("usage: rein linear show <ID>", file=sys.stderr)
                return 2
            parsed = _lissue.parse(client.issue(identifier))
            if as_json:
                print(json.dumps(parsed, indent=2, ensure_ascii=False))
                return 0
            _print_issue(parsed)
            return 0

        try:
            priority = _linear_int(rest, "priority")
            max_priority = _linear_int(rest, "max-priority")
        except ValueError as exc:
            print(f"rein linear: {exc}", file=sys.stderr)
            return 2

        result = _lselect.select(
            client.issues(),
            repo=_opt(rest, "repo"), state=_opt(rest, "state"),
            flow=_opt(rest, "flow"), label=_opt(rest, "label"),
            parent=_opt(rest, "parent"), priority=priority,
            max_priority=max_priority, include_groupers="--groupers" in rest,
        )
    except _lclient.LinearUnreachable as exc:
        print(f"rein linear: {exc}", file=sys.stderr)
        return 1
    except _lclient.IssueNotFound as exc:
        print(f"rein linear: {exc}", file=sys.stderr)
        return 1
    except _lclient.LinearError as exc:
        print(f"rein linear: {exc}", file=sys.stderr)
        return 1

    if as_json:
        print(json.dumps(result, indent=2, ensure_ascii=False))
        return 0

    # A filter value the board has never seen is the difference between
    # "nothing to do" and "you typed a repo that does not exist". Both
    # return zero rows, and only one of them is good news.
    for miss in result["unknown"]:
        print(f"rein linear: no issue has {miss['filter']} {miss['value']!r} -- "
              f"known: {', '.join(miss['known']) or '(none)'}", file=sys.stderr)

    rows = result["issues"]
    if not rows:
        print("rein linear: no issues match" if result["filters"] else "rein linear: no issues")
        return 0
    for r in rows:
        warn = "  !" + "; ".join(r["warnings"]) if r["warnings"] else ""
        print(f'{r["identifier"]:<8} {_lselect.priority_name(r["priority"]):<7} '
              f'{r["repo"]:<15} {r["state"]:<12} {r["title"][:60]}{warn}')
    print(f"\n{len(rows)} issue{'s' if len(rows) != 1 else ''}")
    return 0


def _print_issue(r: dict) -> None:
    print(f'{r["identifier"]}  {r["title"]}')
    print(f'  state    {r["state"]} ({r["stateType"]})')
    print(f'  priority {_lselect.priority_name(r["priority"])}')
    print(f'  repo     {r["repo"] or "(none)"}')
    if r["flows"]:
        print(f'  flows    {", ".join(r["flows"])}')
    if r["foundBy"]:
        print(f'  found by {r["foundBy"]}')
    if r["parent"]:
        print(f'  parent   {r["parent"]}')
    if r["branchName"]:
        print(f'  branch   {r["branchName"]}')
    for w in r["warnings"]:
        print(f'  WARNING  {w}')
    for m in r["missing"]:
        print(f'  MISSING  {m}')
    if r["impact"]:
        print(f'\nImpacto: {r["impact"]}')
    if r["body"]:
        print(f'\n{r["body"]}')


def _workspace_root(argv: list[str]) -> str:
    """Where the sibling repos live.

    `--root` wins. Otherwise: the PARENT of the current repository, because
    the issue names a repo by directory name and that is where its siblings
    are. Outside a repository, the working directory itself.
    """
    explicit = _opt(argv, "root")
    if explicit:
        return explicit
    cwd = os.path.abspath(".")
    return os.path.dirname(cwd) if os.path.isdir(os.path.join(cwd, ".git")) else cwd


def _linear_client_or_exit():
    try:
        return _lclient.LinearClient(), 0
    except _lclient.LinearAuthError as exc:
        print(f"rein: {exc}", file=sys.stderr)
        return None, 1


def _print_intake_report(report: dict, verb: str) -> None:
    head = f"{report['identifier']}  {verb}"
    if report.get("dryRun"):
        head += "  (dry run)"
    print(head)
    print(f"  repo   {report['repo']}")
    if report.get("branch"):
        print(f"  rama   {report['branch']} (base {report['base']})")
    if report.get("bead"):
        print(f"  bead   {report['bead']}")
    if report.get("mergeCommit"):
        print(f"  commit {report['mergeCommit']}")
    for step in report["steps"]:
        print(f"  · {step}")


def cmd_intake(argv: list[str]) -> int:
    """Take one issue off the board. Everything between `intake` and `land`
    is the ordinary flow: rein-plan, rein-apply, rein-audit."""
    identifier = _positional(argv)
    if not identifier:
        print("usage: rein intake <ID> [--root <workspace>] [--dry-run]")
        return 2
    client, code = _linear_client_or_exit()
    if client is None:
        return code
    try:
        report = _lintake.intake(identifier, root=_workspace_root(argv), client=client,
                                 dry_run="--dry-run" in argv)
    except _lintake.IntakeError as exc:
        print(f"rein intake: {exc}", file=sys.stderr)
        return 1
    except _lclient.LinearError as exc:
        print(f"rein intake: {exc}", file=sys.stderr)
        return 1
    _print_intake_report(report, "tomado")
    return 0


def cmd_land(argv: list[str]) -> int:
    """Close it out, once it is actually merged."""
    identifier = _positional(argv)
    if not identifier:
        print("usage: rein land <ID> [--root <workspace>] [--bead <id>] [--force] [--dry-run]")
        return 2
    client, code = _linear_client_or_exit()
    if client is None:
        return code
    try:
        report = _lintake.land(identifier, root=_workspace_root(argv), client=client,
                               bead=_opt(argv, "bead"), force="--force" in argv,
                               dry_run="--dry-run" in argv)
    except _lintake.IntakeError as exc:
        print(f"rein land: {exc}", file=sys.stderr)
        return 1
    except _lclient.LinearError as exc:
        print(f"rein land: {exc}", file=sys.stderr)
        return 1
    _print_intake_report(report, "cerrado")
    return 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", ""),
    )
    # The plan says what may be worked on; it cannot say whether the gate that
    # work will be judged against can even run. That check existed only inside
    # loop.js's Prepare phase, so every other caller of `rein next` was blind
    # to it. Reads the last persisted `rein verify` report -- runs nothing.
    result = _gate.decide_claimable(
        result,
        _verify.read_state(cfg["root"]),
        cfg.get("commands") or {},
        monorepo_unconfigured=_gate.monorepo_unconfigured(cfg),
    )
    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


def cmd_backlog(argv: list[str]) -> int:
    """`rein backlog add "<text>"` / `rein backlog list`.

    The input `/rein:rein-plan` reads from. A backlog item is a pointer to a
    problem, not a unit of work, so it carries no criteria and no
    verification -- and it lives at `.rein/backlog.md`, which `read_plan`
    does not look at, so the loop can never be handed one to implement
    (measured: the same list under `openspec/changes/` IS offered by
    `rein next` as claimable).
    """
    if not argv or argv[0] not in ("add", "list"):
        print('usage: rein backlog add "<text>" [root] | rein backlog list [root]')
        return 2
    action, rest = argv[0], argv[1:]

    if action == "add":
        words = [a for a in rest if not a.startswith("--")]
        # A trailing token that is an existing directory is the root, even
        # when it is the ONLY token -- otherwise `rein backlog add <dir>`
        # silently files the directory path itself as an idea.
        root = "."
        if words and os.path.isdir(words[-1]):
            root, words = words[-1], words[:-1]
        text = " ".join(words)
        if not text.strip():
            print('rein backlog: nothing to add -- rein backlog add "<text>"', file=sys.stderr)
            return 2
        try:
            new_id = _backlog.add(root, text)
        except (OSError, ValueError) as exc:
            print(f"rein backlog: {exc}", file=sys.stderr)
            return 1
        print(f"{new_id}  {text.strip()}")
        return 0

    root = _positional(rest) or "."
    try:
        _backlog.check(root)
    except _backlog.BacklogCorrupt as exc:
        # A named message, not a traceback. Duplicate ids mean two lines
        # answer to the same item, so listing them as if all were fine
        # would be the wrong kind of helpful.
        print(f"rein backlog: {exc}", file=sys.stderr)
        return 1
    entries = _backlog.items(root)
    if not entries:
        print("rein backlog: empty (.rein/backlog.md)")
        return 0
    changes = [r for r in _pstate.state_all(root) if r.get("change") != "backlog"]
    derived = _backlog.derive_states(root, changes)
    if "--json" in rest:
        print(json.dumps([{**e, "state": derived.get(e["id"])} for e in entries], indent=2))
        return 0
    for entry in entries:
        print(f"  {entry['id']}  {derived.get(entry['id'], 'backlog'):10} {entry['text']}")
    return 0


COMMANDS = {
    "doctor": cmd_doctor,
    "setup": cmd_setup,
    "detect": cmd_detect,
    "verify": cmd_verify,
    "gate": cmd_gate,
    "role": cmd_role,
    "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,
    "linear": cmd_linear,
    "intake": cmd_intake,
    "land": cmd_land,
    "backlog": cmd_backlog,
}


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:]))
