"""``agent-config doctor`` — install + manifest health report.

Phase 4 of road-to-multi-package-coexistence (drift detection) and
Phase 2 of road-to-surface-discipline (diagnostic hub). Read-only
sibling to ``prune``/``validate``: walks the project manifest and the
on-disk deploy roots, runs a battery of health checks, and produces:

Drift categories (manifest ↔ filesystem):

* ``missing``   — manifest entry has a ``path`` that is **not** on disk.
* ``modified``  — manifest entry records a ``sha256`` that does not
  match the current bytes on disk.
* ``foreign``   — file present under one of the ``deploy_roots`` that
  no manifest entry claims (potential neighbour-tool drift).
* ``tag-drift`` — manifest-claimed ``.md`` file carries a frontmatter
  ``package:`` value that disagrees with this package's identifier
  (P5.2). Hand-edited tags or accidental cross-package writes show up
  here; files without frontmatter are skipped (P5.1 contract).

Health checks (see :data:`CHECK_IDS`):
scope · manifest-integrity · lockfile-freshness · bridge-drift ·
mcp-mode · mcp-beta-readiness · offline-readiness · python-runtime ·
tier-usage-readiness · council-cli · unsupported-combos ·
wizard-state.
Each emits a structured ``{id, status, message, remedy}`` record with
``status`` ∈ ``ok`` / ``warn`` / ``fail`` / ``skipped`` (rendered
``✅`` / ``⚠️`` / ``❌`` / ``⏭️``). ``--check <id>`` runs a single check.

Checks are split by scope (:data:`GLOBAL_CHECK_IDS` vs
:data:`MANIFEST_REQUIRED_CHECK_IDS`) so an ADR-020 global-only consumer
(bridge marker present, no ``agents/installed-tools.lock``) gets a
green-capable report instead of a hard bail: the global checks run, the
manifest-required checks report ``skipped``, and ``bridge-drift`` returns
a scope-aware "drift not applicable" verdict. See :func:`main` for the
no-manifest branch and exit-code contract.

Repair affordances: ``--repair wizard-state`` resets a malformed or
orphaned ``state/wizard-state.json`` under the user-global root (the
file the unified Setup-Wizard uses for resume continuity). Used as the
recovery path when ``1.9``'s npm downgrade is not viable
(road-to-global-only-install § 1.10 / A6).

Exit codes: ``0`` (clean) · ``1`` (drift or any ``fail`` check) · ``2``
(error such as "manifest missing"). Both human and ``--json`` output
emit the drift category lists and the structured checks array. Every
drift entry carries a one-line ``fix`` hint (P4.3).
"""
from __future__ import annotations

import argparse
import hashlib
import json
import os
import re
import shutil
import sys
from pathlib import Path
from typing import Any

from scripts._lib import installed_lock, installed_tools
from scripts._lib.agent_settings import (
    PROJECT_ROOT_ENV,
    ROOT_OVERRIDE_ENV,
    ProjectRootError,
    find_project_root_with_trace,
    project_settings_path,
    resolve_project_root,
)


class _Sentinel:
    """Tiny stand-in for a private sentinel value type."""

    __slots__ = ()


#: Returned by :func:`_read_inline_package_tag` when the file is out
#: of scope for tag-drift detection (no ``.md`` suffix, unreadable, or
#: no leading frontmatter block).
NO_FRONTMATTER = _Sentinel()


def _resolve_project_root(arg: str | None) -> tuple[Path, str]:
    """Resolve the doctor's project root via the shared Phase-3 helper.

    Returns ``(root, origin)`` where ``origin`` is one of the anchor
    names from :mod:`scripts._lib.agent_settings` (``agents-dir``,
    ``git``, ``agent-settings``) or one of the origin tags
    ``root-flag`` / ``explicit`` / ``env`` / ``cwd-fallback``. The tag
    is surfaced in both human and JSON output so subdir invocations
    are auditable.
    """
    return resolve_project_root(arg)


#: Path of the install-mode marker file (Step 8 A5). One-line file:
#: ``minimal\n`` or ``full\n``. Written by ``install.py``; consumed by
#: ``doctor --context`` to surface install state without re-deriving it
#: from filesystem heuristics.
_INSTALL_MODE_MARKER_REL = "agents/.agent-state/install-mode.txt"


def _detect_install_mode(project_root: Path) -> tuple[str, str]:
    """Return ``(mode, source)`` for the project install state.

    Hybrid detection (Step 8 council decision Q5):

    1. ``agents/.agent-state/install-mode.txt`` if present (authoritative
       for installs since Step 8) → ``source="marker-file"``.
    2. Filesystem heuristic for back-compat: ``AGENTS.md`` + copilot
       bridges → ``full``; otherwise ``minimal`` → ``source="heuristic"``.

    Heuristic is fragile by design — users who delete ``AGENTS.md`` but
    keep copilot bridges get misreported. The marker file is the
    intended source of truth for installs from Step 8 onward.
    """
    marker = project_root / _INSTALL_MODE_MARKER_REL
    if marker.is_file():
        try:
            value = marker.read_text(encoding="utf-8").strip()
        except OSError:
            value = ""
        if value in ("minimal", "full"):
            return value, "marker-file"
    has_agents_md = (project_root / "AGENTS.md").exists()
    has_copilot = (project_root / ".github" / "copilot-instructions.md").exists()
    if has_agents_md and has_copilot:
        return "full", "heuristic"
    return "minimal", "heuristic"


def _settings_layer_chain(project_root: Path) -> list[str]:
    """Return the ordered ``.agent-settings.yml`` layer files that exist.

    Shallowest first (user-global → project root). Used by ``--context``
    to surface which layers will participate in the cascade merge.
    """
    layers: list[str] = []
    from scripts._lib import user_global_paths
    user_global = user_global_paths.resolve_with_fallback("agent-settings.yml")
    if user_global is not None and user_global.is_file():
        layers.append(str(user_global))
    project_settings = project_settings_path(project_root)
    if project_settings.is_file():
        layers.append(str(project_settings))
    return layers


def _detect_wrapper(project_root: Path) -> dict[str, Any]:
    """Return ``{path, exists, embedded_root}`` for the project wrapper.

    The ``./agent-config`` wrapper at the project root pins
    ``AGENT_CONFIG_PROJECT_ROOT`` to its own ``SELF_DIR``, so the
    "embedded root" is just the wrapper's directory. Surfaced for
    debugging wrapper-coupling issues (Step 8 A3-coupling).
    """
    wrapper = project_root / "agent-config"
    if not wrapper.exists():
        return {"path": str(wrapper), "exists": False, "embedded_root": None}
    return {
        "path": str(wrapper),
        "exists": True,
        "embedded_root": str(project_root),
    }


def _emit_trace_text(
    root: Path | None,
    anchor: str | None,
    trace: list[dict[str, Any]],
    origin: str,
) -> None:
    """Render the discovery trace as human-readable text."""
    print(f"  📍  start: {Path.cwd()}")
    print(f"  📍  origin: {origin}")
    if trace:
        print("  trace:")
        for record in trace:
            hit = record["hit"]
            symbol = "✅" if hit else "·"
            tag = f"[{record['pass']}]"
            anchor_str = f" → {hit}" if hit else ""
            print(
                f"    {symbol} {tag} {record['ancestor']}"
                f"{anchor_str}  ({record['reason']})"
            )
    if root is not None:
        print(f"  📍  resolved root: {root} (anchor: {anchor or 'n/a'})")
    else:
        print("  ⚠️   no anchor found in chain")


def _emit_trace_json(
    root: Path | None,
    anchor: str | None,
    trace: list[dict[str, Any]],
    origin: str,
) -> None:
    """Render the discovery trace as a JSON payload."""
    payload: dict[str, Any] = {
        "start": str(Path.cwd()),
        "origin": origin,
        "resolved_root": str(root) if root is not None else None,
        "anchor": anchor,
        "trace": trace,
    }
    print(json.dumps(payload, indent=2))


def _emit_context_text(ctx: dict[str, Any]) -> None:
    """Render the install / discovery context as human-readable text."""
    print(f"  📍  project_root: {ctx['project_root']}  (origin: {ctx['origin']})")
    print(f"  📦  install_mode: {ctx['install_mode']}  (source: {ctx['install_mode_source']})")
    env_pin = ctx.get("env_pin")
    if env_pin:
        marker = " (--root override)" if ctx.get("root_override") else ""
        print(f"  🔒  env_pin: {env_pin}{marker}")
    else:
        print("  🔒  env_pin: (unset)")
    layers = ctx.get("settings_layers") or []
    if layers:
        print(f"  📑  settings layers ({len(layers)}):")
        for layer in layers:
            print(f"        - {layer}")
    else:
        print("  📑  settings layers: (none)")
    wrapper = ctx.get("wrapper") or {}
    if wrapper.get("exists"):
        print(f"  🧩  wrapper: {wrapper['path']}  (embedded root: {wrapper.get('embedded_root')})")
    else:
        print(f"  🧩  wrapper: (not present at {wrapper.get('path')})")


def _resolve_path(project_root: Path, raw: str) -> Path:
    p = Path(raw).expanduser()
    if not p.is_absolute():
        p = project_root / p
    return p


def _sha256(path: Path) -> str | None:
    try:
        return hashlib.sha256(path.read_bytes()).hexdigest()
    except OSError:
        return None


#: Inline-tag identifier this package writes into deployed Markdown
#: frontmatter (P5.1). Kept in sync with ``install.PACKAGE_TAG_ID``;
#: duplicated here to keep ``cmd_doctor`` import-light (no pull on the
#: installer module from the CLI).
PACKAGE_TAG_ID = "event4u/agent-config"

_FRONTMATTER_PACKAGE_RE = re.compile(
    r"^package:\s*(.+?)\s*$", re.MULTILINE,
)


def _read_inline_package_tag(path: Path) -> str | None | _Sentinel:
    """Extract the inline ``package:`` value from a Markdown frontmatter.

    Returns ``NO_FRONTMATTER`` when ``path`` is not a Markdown file or
    has no leading ``---`` block (P5.1: those files are out of scope).
    Returns ``None`` when frontmatter is present but lacks a
    ``package:`` key. Returns the string value otherwise.
    """
    if path.suffix != ".md":
        return NO_FRONTMATTER
    try:
        text = path.read_text(encoding="utf-8")
    except OSError:
        return NO_FRONTMATTER
    if not (text.startswith("---\n") or text.startswith("---\r\n")):
        return NO_FRONTMATTER
    lines = text.splitlines()
    close_idx: int | None = None
    for i in range(1, len(lines)):
        if lines[i].rstrip() == "---":
            close_idx = i
            break
    if close_idx is None:
        return NO_FRONTMATTER
    block = "\n".join(lines[1:close_idx])
    m = _FRONTMATTER_PACKAGE_RE.search(block)
    if not m:
        return None
    return m.group(1).strip().strip("'\"")


def _fix_hint(category: str, kind: str | None) -> str:
    """Return a one-line remediation hint for the surfaced item."""
    if category == "missing":
        return "run `./agent-config sync` to re-install"
    if category == "modified":
        return "commit the local change, or re-install with --force"
    if category == "foreign":
        return (
            "identify owning tool, or run `./agent-config prune` "
            "if confirmed orphan"
        )
    if category == "tag-drift":
        return (
            "re-install with --force to restore the inline tag, "
            "or remove the file if it is no longer ours"
        )
    return ""


def _collect_manifest_entries(
    project_root: Path, manifest: dict[str, Any],
) -> tuple[
    list[tuple[str, Path, str, str | None]],   # (tool, abs_path, kind, sha)
    set[Path],                                  # resolved-known set
]:
    """Flatten v2 ``tools[].files[]`` into per-file records.

    Returns the records list and a set of resolved absolute paths so
    the foreign-file scan can skip anything the manifest claims.
    """
    records: list[tuple[str, Path, str, str | None]] = []
    known: set[Path] = set()
    for tool in manifest.get("tools") or []:
        if tool.get("scope") != "project":
            continue
        tool_id = str(tool.get("name", ""))
        for entry in tool.get("files") or []:
            raw = entry.get("path") or ""
            if not raw:
                continue
            kind = entry.get("kind") or ""
            target = _resolve_path(project_root, raw)
            try:
                resolved = target.resolve()
            except OSError:
                resolved = target
            records.append((tool_id, target, kind, entry.get("sha256")))
            known.add(resolved)
    return records, known


def _scan_foreign(
    project_root: Path,
    manifest: dict[str, Any],
    known: set[Path],
) -> list[Path]:
    """Walk every declared deploy root and surface unclaimed files.

    Only ``regular files`` under ``deploy_roots`` count; directories and
    symlinks are followed but the bookkeeping is on the resolved final
    path so a manifest claim via either path silences both surfaces.
    Falls back to :data:`installed_tools.DEFAULT_DEPLOY_ROOTS` when the
    manifest lacks an explicit ``deploy_roots`` list.
    """
    roots = manifest.get("deploy_roots") or list(
        installed_tools.DEFAULT_DEPLOY_ROOTS,
    )
    foreign: list[Path] = []
    seen: set[Path] = set()
    for root_rel in roots:
        root = _resolve_path(project_root, str(root_rel))
        if not root.exists() or not root.is_dir():
            continue
        for child in root.rglob("*"):
            if not child.is_file():
                continue
            try:
                resolved = child.resolve()
            except OSError:
                resolved = child
            if resolved in known or resolved in seen:
                continue
            seen.add(resolved)
            foreign.append(child)
    foreign.sort()
    return foreign


def _classify(
    records: list[tuple[str, Path, str, str | None]],
) -> tuple[
    list[dict[str, Any]],
    list[dict[str, Any]],
    list[dict[str, Any]],
]:
    """Split manifest records into missing / modified / tag-drift lists.

    Tag-drift inspection (P5.2) is restricted to manifest entries that
    point at a present ``.md`` file with a frontmatter block. A file
    that has frontmatter but whose ``package:`` value disagrees with
    :data:`PACKAGE_TAG_ID` — or that has dropped the key entirely —
    surfaces here. Files without frontmatter are silently ignored per
    the P5.1 contract (we never synthesise frontmatter).
    """
    missing: list[dict[str, Any]] = []
    modified: list[dict[str, Any]] = []
    tag_drift: list[dict[str, Any]] = []
    for tool_id, target, kind, expected in records:
        if not target.exists():
            missing.append({
                "tool": tool_id, "path": str(target), "kind": kind,
                "fix": _fix_hint("missing", kind),
            })
            continue
        tag = _read_inline_package_tag(target)
        if not isinstance(tag, _Sentinel) and tag != PACKAGE_TAG_ID:
            tag_drift.append({
                "tool": tool_id, "path": str(target), "kind": kind,
                "expected": PACKAGE_TAG_ID,
                "found": "" if tag is None else tag,
                "fix": _fix_hint("tag-drift", kind),
            })
        if expected is None:
            continue
        actual = _sha256(target)
        if actual is None or actual == expected:
            continue
        modified.append({
            "tool": tool_id, "path": str(target), "kind": kind,
            "fix": _fix_hint("modified", kind),
        })
    return missing, modified, tag_drift


def _foreign_records(
    project_root: Path, foreign: list[Path],
) -> list[dict[str, Any]]:
    out: list[dict[str, Any]] = []
    for p in foreign:
        try:
            rel = p.relative_to(project_root)
            path_str = str(rel)
        except ValueError:
            path_str = str(p)
        out.append({
            "tool": "",
            "path": path_str,
            "kind": "deployed",
            "fix": _fix_hint("foreign", "deployed"),
        })
    return out


# ---------------------------------------------------------------------------
# Health checks (Phase 2 of road-to-surface-discipline).
# ---------------------------------------------------------------------------

#: Ordered registry of structured health-check identifiers. The order
#: is preserved in text and JSON output. Adding a check requires both
#: a runner below and an entry in :func:`_run_checks`.
CHECK_IDS = (
    "scope",
    "global-binary",
    "manifest-integrity",
    "lockfile-freshness",
    "bridge-drift",
    "mcp-mode",
    "mcp-beta-readiness",
    "offline-readiness",
    "python-runtime",
    "tier-usage-readiness",
    "council-cli",
    "unsupported-combos",
    "wizard-state",
)

#: Checks that need only the project root (or no input at all) and run
#: regardless of whether a project lockfile exists. Under ADR-020
#: global-only (bridge marker present, no ``installed-tools.lock``) these
#: still produce a real verdict. ``scope`` lives here because
#: :func:`_check_scope` reads only ``project_root`` — the roadmap prose
#: that grouped it with the manifest checks predates this code reality
#: (AI council, claude-sonnet-4-5 + gpt-4o, design lens, 2026-06-02).
GLOBAL_CHECK_IDS: frozenset[str] = frozenset({
    "scope",
    "global-binary",
    "mcp-mode",
    "mcp-beta-readiness",
    "offline-readiness",
    "python-runtime",
    "tier-usage-readiness",
    "council-cli",
    "wizard-state",
})

#: Checks that genuinely cannot run without the project manifest. Without
#: a lockfile they report ``skipped`` rather than a misleading verdict.
#: ``bridge-drift`` is deliberately **not** here: it is scope-aware —
#: a manifest-derived drift roll-up when the lockfile exists, and a
#: "drift not applicable (global-only consumer)" verdict when it does
#: not (computed in :func:`_check_bridge_drift_no_manifest`, keeping
#: :func:`_check_bridge_drift` a pure roll-up per the council's SRP point).
MANIFEST_REQUIRED_CHECK_IDS: frozenset[str] = frozenset({
    "manifest-integrity",
    "lockfile-freshness",
    "unsupported-combos",
})

#: Project-root-relative path of the ADR-020 global-only consumer marker.
BRIDGE_MARKER_RELATIVE = "agents/.event4u-bridge.yml"

#: Repair targets that ``--repair <id>`` accepts. Each id maps to a
#: function in :func:`_run_repair` that resets the named artefact and
#: returns an exit code. Additive set: introduce by adding a new id
#: here, a runner inside :func:`_run_repair`, and (optionally) a
#: matching health check above.
REPAIR_IDS = ("wizard-state",)

#: Six gates that govern the MCP `experimental → beta` promotion. The
#: artefact path under each gate id mirrors `tests/test_mcp_beta_gates.py`
#: and the contract in `docs/contracts/mcp-beta-criteria.md`.
MCP_BETA_GATES: tuple[tuple[str, str], ...] = (
    ("gate-1-external-client", "tests/mcp/external-clients"),
    ("gate-2-bearer-auth", "tests/mcp/auth"),
    ("gate-3-parity-smoke", "tests/mcp/parity"),
    ("gate-4-healthz-load", "tests/mcp/load/healthz.k6.js"),
    ("gate-5-rate-limit", "docs/contracts/mcp-rate-limit.md"),
    ("gate-6-no-drift", ".github/workflows/mcp-no-drift.yml"),
)

#: Visible status → glyph map. ``warn`` keeps a trailing space so the
#: rendered output stays in a single visual column with the other glyphs.
STATUS_SYMBOLS = {"ok": "✅", "warn": "⚠️ ", "fail": "❌", "skipped": "⏭️ "}

#: Minimum Python interpreter the CLI targets. Bumped in lockstep with
#: ``from __future__ import annotations`` + PEP-604 syntax usage.
MIN_PYTHON = (3, 10)


def _check_scope(project_root: Path) -> dict[str, Any]:
    """Distinguish a standalone project root from a monorepo workspace.

    Surfaces ``warn`` when ``project_root`` looks like a monorepo root
    so the operator knows to run ``doctor`` per-package; otherwise
    reports the project as a standalone target.
    """
    for marker in ("pnpm-workspace.yaml", "lerna.json"):
        if (project_root / marker).exists():
            return {
                "id": "scope", "status": "warn",
                "message": f"monorepo root detected ({marker})",
                "remedy": "run `agent-config doctor` from each workspace package",
            }
    pkg = project_root / "package.json"
    if pkg.exists():
        try:
            data = json.loads(pkg.read_text(encoding="utf-8"))
            if data.get("workspaces"):
                return {
                    "id": "scope", "status": "warn",
                    "message": "package.json declares workspaces (monorepo root)",
                    "remedy": "run `agent-config doctor` from each workspace package",
                }
        except (OSError, ValueError):
            pass
    return {
        "id": "scope", "status": "ok",
        "message": "standalone project root",
        "remedy": "",
    }


def _check_manifest_integrity(manifest: dict[str, Any]) -> dict[str, Any]:
    """Verify the manifest carries a writer version and a known schema."""
    schema = manifest.get("schema_version")
    version = manifest.get("agent_config_version")
    if not version:
        return {
            "id": "manifest-integrity", "status": "warn",
            "message": "manifest lacks `agent_config_version`",
            "remedy": "re-run `./agent-config init` to record the writer version",
        }
    if schema not in installed_tools.SCHEMA_VERSIONS_SUPPORTED:
        return {
            "id": "manifest-integrity", "status": "warn",
            "message": f"unknown schema_version: {schema!r}",
            "remedy": "upgrade @event4u/agent-config to a writer that "
                      "recognises this schema",
        }
    return {
        "id": "manifest-integrity", "status": "ok",
        "message": f"schema v{schema}, written by agent-config {version}",
        "remedy": "",
    }


def _package_root() -> Path:
    """Resolve the @event4u/agent-config package root (this repo)."""
    return Path(__file__).resolve().parents[3]


def _current_package_version() -> str | None:
    """Read ``version`` from this package's ``package.json``; ``None`` on error."""
    try:
        data = json.loads(
            (_package_root() / "package.json").read_text(encoding="utf-8"),
        )
        v = data.get("version")
        if isinstance(v, str) and v.strip():
            return v.strip()
    except (OSError, ValueError):
        pass
    return None


def _check_lockfile_freshness(manifest: dict[str, Any]) -> dict[str, Any]:
    """Compare the manifest writer version against the current package."""
    recorded = manifest.get("agent_config_version")
    current = _current_package_version()
    if not recorded:
        return {
            "id": "lockfile-freshness", "status": "warn",
            "message": "manifest has no writer version recorded",
            "remedy": "re-run `./agent-config init` to refresh the manifest",
        }
    if current is None:
        return {
            "id": "lockfile-freshness", "status": "warn",
            "message": f"manifest written by {recorded}; current package version unknown",
            "remedy": "verify the package install (package.json missing or unreadable)",
        }
    if recorded != current:
        return {
            "id": "lockfile-freshness", "status": "warn",
            "message": f"manifest writer {recorded} != current package {current}",
            "remedy": "re-run `./agent-config sync` to refresh the manifest "
                     "against the current package",
        }
    return {
        "id": "lockfile-freshness", "status": "ok",
        "message": f"manifest and package both at {current}",
        "remedy": "",
    }


def _check_global_binary(project_root: Path) -> dict[str, Any]:
    """`agent-config` PATH resolution + global-install version parity.

    The Claude plugin hook resolves `agent-config` from PATH (ADR-020
    global-only); if it is missing, every PostToolUse hook fails silently —
    this is the root cause of the "dashboard never updates" report. Version
    drift between the binary on PATH and the global install root means the
    plugin may emit a hook spec the installed binary predates.
    """
    binary = shutil.which("agent-config")
    if not binary:
        return {
            "id": "global-binary", "status": "fail",
            "message": "`agent-config` is not on PATH — Claude plugin hooks "
                       "cannot resolve it (hooks silently no-op)",
            "remedy": "npm install -g @event4u/agent-config, then re-run doctor",
        }
    lock = installed_lock.read_lockfile()
    global_v = ""
    if isinstance(lock, dict):
        raw = lock.get("agent_config_version")
        global_v = raw.strip().lstrip("v") if isinstance(raw, str) else ""
    binary_v = (_current_package_version() or "").lstrip("v")
    if global_v and binary_v and global_v != binary_v:
        return {
            "id": "global-binary", "status": "warn",
            "message": f"version drift — binary {binary_v}, global install "
                       f"{global_v}",
            "remedy": "agent-config upgrade (refresh the global install + plugin)",
        }
    bridge = project_root / "agents" / ".event4u-bridge.yml"
    bridge_note = "" if bridge.is_file() else " · no project bridge marker"
    return {
        "id": "global-binary", "status": "ok",
        "message": f"on PATH ({binary}); version {binary_v or 'unknown'}"
                   f"{bridge_note}",
        "remedy": "" if not bridge_note else
                  "agent-config refresh --project (scaffold the bridge marker)",
    }


def _check_bridge_drift(
    missing: list[dict[str, Any]],
    modified: list[dict[str, Any]],
    foreign: list[dict[str, Any]],
    tag_drift: list[dict[str, Any]],
) -> dict[str, Any]:
    """Roll the four drift category counts into a single health verdict."""
    total = len(missing) + len(modified) + len(foreign) + len(tag_drift)
    if total == 0:
        return {
            "id": "bridge-drift", "status": "ok",
            "message": "manifest matches filesystem (no drift)",
            "remedy": "",
        }
    parts = []
    if missing:
        parts.append(f"{len(missing)} missing")
    if modified:
        parts.append(f"{len(modified)} modified")
    if foreign:
        parts.append(f"{len(foreign)} foreign")
    if tag_drift:
        parts.append(f"{len(tag_drift)} tag-drift")
    return {
        "id": "bridge-drift", "status": "fail",
        "message": f"{total} drift item(s): {', '.join(parts)}",
        "remedy": "see the drift section below or run `./agent-config sync`",
    }


def _check_mcp_mode(project_root: Path) -> dict[str, Any]:
    """Detect which MCP config file the project advertises, if any."""
    candidates = (
        (".cursor/mcp.json", "cursor"),
        (".ai/mcp/mcp.json", "ai-mcp"),
        ("mcp.json", "root"),
    )
    found: list[str] = []
    for rel, label in candidates:
        path = project_root / rel
        if not path.exists():
            continue
        try:
            json.loads(path.read_text(encoding="utf-8"))
            found.append(f"{label} ({rel})")
        except (OSError, ValueError):
            return {
                "id": "mcp-mode", "status": "warn",
                "message": f"MCP config at {rel} is not valid JSON",
                "remedy": f"fix or remove `{rel}` (see docs/architecture.md § MCP)",
            }
    if not found:
        return {
            "id": "mcp-mode", "status": "ok",
            "message": "no MCP config present (MCP Beta off)",
            "remedy": "",
        }
    return {
        "id": "mcp-mode", "status": "ok",
        "message": f"MCP config detected: {', '.join(found)}",
        "remedy": "",
    }


def _check_offline_readiness() -> dict[str, Any]:
    """Verify the verified-offline install entrypoint ships with the package."""
    script = _package_root() / "src" / "scripts" / "hermetic-install.sh"
    if not script.exists():
        return {
            "id": "offline-readiness", "status": "warn",
            "message": "src/scripts/hermetic-install.sh not found in package",
            "remedy": "reinstall @event4u/agent-config or pull missing files",
        }
    return {
        "id": "offline-readiness", "status": "ok",
        "message": "verified-offline install entrypoint present",
        "remedy": "",
    }


def _check_python_runtime() -> dict[str, Any]:
    """Confirm the interpreter is at least :data:`MIN_PYTHON`."""
    cur = sys.version_info[:2]
    need = MIN_PYTHON
    if cur < need:
        return {
            "id": "python-runtime", "status": "fail",
            "message": f"python {cur[0]}.{cur[1]} below required {need[0]}.{need[1]}",
            "remedy": f"install python >= {need[0]}.{need[1]} and re-run",
        }
    return {
        "id": "python-runtime", "status": "ok",
        "message": f"python {cur[0]}.{cur[1]} meets {need[0]}.{need[1]}+ requirement",
        "remedy": "",
    }


def _check_mcp_beta_readiness(project_root: Path) -> dict[str, Any]:
    """Report the MCP `experimental → beta` promotion gate sheet.

    Walks the six gates in :data:`MCP_BETA_GATES` and asks whether the
    artefact named by each gate (test directory, k6 script, contract
    doc, or workflow file) is present. Green when all six pass, warn
    when any are missing — the gate stays red until evidence lands.
    Mirrors `tests/test_mcp_beta_gates.py` so the doctor verdict and
    the pytest sheet cannot drift.
    """
    pending: list[str] = []
    for gate_id, rel in MCP_BETA_GATES:
        if not (project_root / rel).exists():
            pending.append(f"{gate_id} ({rel})")
    if not pending:
        return {
            "id": "mcp-beta-readiness", "status": "ok",
            "message": "all 6 MCP beta gates green — promotion authorized",
            "remedy": "",
        }
    return {
        "id": "mcp-beta-readiness", "status": "warn",
        "message": (
            f"{len(pending)}/6 MCP beta gate(s) pending: "
            f"{', '.join(pending)}"
        ),
        "remedy": (
            "produce the artefacts listed in "
            "docs/contracts/mcp-beta-criteria.md (one per gate); "
            "do not flip `experimental` wording until all 6 are green"
        ),
    }


def _check_tier_usage_readiness(project_root: Path) -> dict[str, Any]:
    """Report whether tier-usage telemetry can drive empirical retiering.

    Phase 5 of road-to-surface-discipline. Three terminal states:

    * **ok** — telemetry on, log present, at least one record survived
      the privacy floor.
    * **warn (disabled)** — telemetry off; no signal collected.
      Default-off doctrine; user opt-in is the unlock.
    * **warn (no data)** — telemetry on but the log is absent / empty;
      retiering decisions still rely on operator judgement until
      enough records accumulate.
    * **fail (poisoned)** — every record was rejected by the privacy
      floor; the report would refuse to render. The log needs a manual
      inspection.

    Contract: ``docs/contracts/command-clusters.md`` § tier-usage signal.
    """
    settings_file = project_settings_path(project_root)
    log_path = project_root / ".agent-tier-usage.jsonl"
    enabled = False
    if settings_file.is_file():
        try:
            import yaml  # type: ignore[import-not-found]
        except ImportError:
            yaml = None  # type: ignore[assignment]
        if yaml is not None:
            try:
                raw = yaml.safe_load(settings_file.read_text(encoding="utf-8")) or {}
            except Exception:
                raw = {}
            tele = (raw.get("telemetry") if isinstance(raw, dict) else None) or {}
            tu = tele.get("tier_usage") if isinstance(tele, dict) else None
            if isinstance(tu, dict):
                output = tu.get("output") if isinstance(tu.get("output"), dict) else {}
                if isinstance(output.get("path"), str) and output["path"].strip():
                    log_path = project_root / output["path"].strip()
                val = tu.get("enabled")
                enabled = bool(val) if isinstance(val, bool) else (
                    isinstance(val, str) and val.strip().lower() in ("true", "yes", "on", "1")
                )

    if not enabled:
        return {
            "id": "tier-usage-readiness", "status": "warn",
            "message": (
                "tier-usage telemetry disabled — empirical retiering "
                "decisions fall back to operator judgement"
            ),
            "remedy": (
                "set `telemetry.tier_usage.enabled: true` in "
                ".agent-settings.yml (default-off; opt-in)"
            ),
        }
    if not log_path.exists():
        return {
            "id": "tier-usage-readiness", "status": "warn",
            "message": (
                f"tier-usage telemetry on but {log_path.name} not yet "
                "written — no signal accumulated"
            ),
            "remedy": (
                "run any tracked command to seed the log; the dispatcher "
                "writes one record per invocation"
            ),
        }
    total = 0
    valid = 0
    allowed_fields = {"ts_bucket", "command", "tier", "outcome", "user_hash"}
    allowed_outcomes = {"success", "error", "blocked"}
    try:
        with log_path.open("r", encoding="utf-8") as fh:
            for line in fh:
                line = line.strip()
                if not line:
                    continue
                total += 1
                try:
                    obj = json.loads(line)
                except json.JSONDecodeError:
                    continue
                if not isinstance(obj, dict):
                    continue
                if not set(obj.keys()).issubset(allowed_fields):
                    continue
                if (
                    isinstance(obj.get("command"), str) and obj["command"]
                    and isinstance(obj.get("tier"), int) and obj["tier"] in (0, 1, 2, 3)
                    and obj.get("outcome") in allowed_outcomes
                    and isinstance(obj.get("user_hash"), str) and len(obj["user_hash"]) == 16
                    and isinstance(obj.get("ts_bucket"), str)
                ):
                    valid += 1
    except OSError as exc:
        return {
            "id": "tier-usage-readiness", "status": "fail",
            "message": f"cannot read {log_path.name}: {exc}",
            "remedy": "fix permissions on the tier-usage log",
        }
    if total > 0 and valid == 0:
        return {
            "id": "tier-usage-readiness", "status": "fail",
            "message": (
                f"{total} record(s) in {log_path.name} but 0 passed the "
                "privacy floor — report would refuse to render"
            ),
            "remedy": (
                "inspect the log; the dispatcher is writing records the "
                "contract forbids (paths, argv, message bodies)"
            ),
        }
    if valid == 0:
        return {
            "id": "tier-usage-readiness", "status": "warn",
            "message": f"{log_path.name} present but empty — no signal yet",
            "remedy": "run any tracked command to seed the log",
        }
    return {
        "id": "tier-usage-readiness", "status": "ok",
        "message": (
            f"{valid} record(s) past the privacy floor in {log_path.name} "
            "— retiering signal available"
        ),
        "remedy": "",
    }


#: Provider → (default binary, billable flag). Mirrors the
#: ``CliClient`` subclass attributes in ``scripts/ai_council/clients.py``
#: without importing them at module load time (keeps doctor lightweight
#: and robust if council deps fail to load).
_CLI_PROVIDER_META: dict[str, tuple[str, bool]] = {
    "anthropic": ("claude", False),
    "openai": ("codex", False),
    "gemini": ("gemini", False),
    "xai": ("grok", True),
    "perplexity": ("perplexity", True),
}


def _check_council_cli(project_root: Path) -> dict[str, Any]:
    """Health-probe each enabled ``mode: cli`` council member.

    For every CLI member in ``agents/settings/.ai-council.yml`` reports binary
    presence (via ``shutil.which``), billable flag, daily-quota state
    (used/cap or used/—), and rolls up to a single status icon.

    No subprocess is spawned: ``--version`` probes would defeat the
    cheap-by-default contract and surface flaky network paths (Codex
    talks home on first run). Binary presence + the cached
    ``cli-calls.json`` counter cover the actionable failure modes.

    Status rules:

    - ``ok`` — every enabled CLI member has its binary on PATH AND
      (when capped) usage is below ``warn_at``.
    - ``warn`` — at least one binary is missing OR usage crosses
      ``warn_at`` for at least one capped member.
    - returns ``ok`` with "no council config" if
      ``agents/settings/.ai-council.yml`` is absent (consumer project that
      hasn't enabled the council yet).
    """
    council_path = project_root / "agents" / "settings" / ".ai-council.yml"
    if not council_path.exists():
        return {
            "id": "council-cli", "status": "ok",
            "message": "no council config (agents/settings/.ai-council.yml not present)",
            "remedy": "",
        }
    try:
        from scripts.ai_council.clients import load_cli_call_counts
        from scripts.ai_council.config import load_council_config
    except Exception as exc:  # noqa: BLE001 — defensive: doctor must not crash
        return {
            "id": "council-cli", "status": "warn",
            "message": f"council deps unavailable ({type(exc).__name__})",
            "remedy": "install PyYAML and ensure scripts/ai_council is importable",
        }
    try:
        cfg = load_council_config(council_path)
    except Exception as exc:  # noqa: BLE001
        return {
            "id": "council-cli", "status": "warn",
            "message": f"council config invalid: {exc}",
            "remedy": "fix agents/settings/.ai-council.yml and re-run doctor",
        }
    cli_members: list[tuple[str, Any]] = [
        (name, m) for name, m in cfg.members.items()
        if m.enabled and m.mode == "cli" and name in _CLI_PROVIDER_META
    ]
    if not cli_members:
        return {
            "id": "council-cli", "status": "ok",
            "message": "no enabled CLI-mode members",
            "remedy": "",
        }
    counts = load_cli_call_counts()
    caps = cfg.cli_call_budget.max_calls_per_day
    warn_at = float(cfg.cli_call_budget.warn_at)
    missing: list[str] = []
    over_warn: list[str] = []
    lines: list[str] = []
    for name, member in cli_members:
        default_bin, billable = _CLI_PROVIDER_META[name]
        binary_name = member.binary or default_bin
        resolved = shutil.which(binary_name)
        binary_glyph = "✅" if resolved else "❌"
        if resolved is None:
            missing.append(name)
        used = int(counts.get(name, 0))
        cap = caps.get(name)
        if cap is not None:
            ratio = used / cap if cap > 0 else 0.0
            quota_glyph = "⚠️" if ratio >= warn_at else "✅"
            if ratio >= warn_at:
                over_warn.append(name)
            quota_str = f"{used}/{cap}"
        else:
            quota_glyph = "—"
            quota_str = f"{used}/—"
        billable_str = "billable" if billable else "subscription"
        lines.append(
            f"{name}: binary {binary_glyph} ({binary_name}) · "
            f"quota {quota_glyph} {quota_str} · {billable_str}"
        )
    detail = " | ".join(lines)
    if missing:
        return {
            "id": "council-cli", "status": "warn",
            "message": (
                f"{len(missing)}/{len(cli_members)} CLI member(s) missing binary "
                f"({', '.join(missing)}) · {detail}"
            ),
            "remedy": (
                "install the missing CLI(s) — see `council:estimate` pre-flight "
                "for per-provider install hints, or flip "
                "ai_council.members.<name>.mode to 'api'"
            ),
        }
    if over_warn:
        return {
            "id": "council-cli", "status": "warn",
            "message": (
                f"{len(over_warn)}/{len(cli_members)} CLI member(s) at/over "
                f"quota warn_at={warn_at} ({', '.join(over_warn)}) · {detail}"
            ),
            "remedy": (
                "wait for UTC rollover or run "
                "`python3 scripts/council_cli.py quota --reset` to clear the counter"
            ),
        }
    return {
        "id": "council-cli", "status": "ok",
        "message": f"{len(cli_members)} CLI member(s) healthy · {detail}",
        "remedy": "",
    }


def _check_unsupported_combos(manifest: dict[str, Any]) -> dict[str, Any]:
    """Flag tools whose ``scope`` violates the global-only or project-only rules."""
    global_only = {"droid", "qoder"}
    bad: list[str] = []
    for tool in manifest.get("tools") or []:
        name = str(tool.get("name", ""))
        scope = tool.get("scope")
        if name in global_only and scope != "global":
            bad.append(f"{name} (scope={scope}, requires global)")
    if bad:
        return {
            "id": "unsupported-combos", "status": "fail",
            "message": f"{len(bad)} tool(s) with unsupported scope: {', '.join(bad)}",
            "remedy": "re-install the listed tools with `--global --force`",
        }
    return {
        "id": "unsupported-combos", "status": "ok",
        "message": "all installed tools use supported scopes",
        "remedy": "",
    }


def _wizard_state_path() -> Path:
    """Return the on-disk location of the unified Setup-Wizard state file.

    Mirrors ``STATE_REL`` in :mod:`src/server/routes/wizard.ts` — the
    server writes ``<writeRoot>/state/wizard-state.json``. Under the
    global-only install scope (ADR-020) ``writeRoot`` resolves to
    :func:`user_global_paths.event4u_root` (typically
    ``~/.event4u/agent-config/``); the ``EVENT4U_CONFIG_HOME`` override
    is honoured implicitly so tests stay hermetic.
    """
    from scripts._lib.user_global_paths import event4u_root
    return event4u_root() / "state" / "wizard-state.json"


def _check_wizard_state() -> dict[str, Any]:
    """Health-check the resumable Setup-Wizard state file.

    Returns ``ok`` when the file is absent (no active session) or when
    its JSON shape matches the contract from
    :mod:`src/server/routes/wizard.ts` (``step: int``, ``partial: dict``,
    optional ``totalSteps: int``, ``startedAt: str | null``).
    Returns ``fail`` when the file exists but is unreadable, not valid
    JSON, or violates the shape — ``--repair wizard-state`` resets it.
    """
    state_pth = _wizard_state_path()
    if not state_pth.exists():
        return {
            "id": "wizard-state", "status": "ok",
            "message": "no active wizard session",
            "remedy": "",
        }
    try:
        raw = state_pth.read_text(encoding="utf-8")
    except OSError as exc:
        return {
            "id": "wizard-state", "status": "fail",
            "message": f"unreadable wizard-state at {state_pth}: {exc}",
            "remedy": "agent-config doctor --repair wizard-state",
        }
    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        return {
            "id": "wizard-state", "status": "fail",
            "message": f"malformed JSON in wizard-state ({exc.msg} at line {exc.lineno})",
            "remedy": "agent-config doctor --repair wizard-state",
        }
    if not isinstance(data, dict):
        return {
            "id": "wizard-state", "status": "fail",
            "message": f"wizard-state root is {type(data).__name__}, expected object",
            "remedy": "agent-config doctor --repair wizard-state",
        }
    step = data.get("step")
    partial = data.get("partial", {})
    if not isinstance(step, int) or step < 0:
        return {
            "id": "wizard-state", "status": "fail",
            "message": f"wizard-state.step is {step!r}, expected non-negative integer",
            "remedy": "agent-config doctor --repair wizard-state",
        }
    if not isinstance(partial, dict):
        return {
            "id": "wizard-state", "status": "fail",
            "message": f"wizard-state.partial is {type(partial).__name__}, expected object",
            "remedy": "agent-config doctor --repair wizard-state",
        }
    total = data.get("totalSteps")
    if total is not None and (not isinstance(total, int) or total < 1):
        return {
            "id": "wizard-state", "status": "fail",
            "message": f"wizard-state.totalSteps is {total!r}, expected positive integer or omitted",
            "remedy": "agent-config doctor --repair wizard-state",
        }
    suffix = f" of {total}" if isinstance(total, int) else ""
    return {
        "id": "wizard-state", "status": "ok",
        "message": f"resumable wizard session at step {step + 1}{suffix}",
        "remedy": "",
    }


def _run_checks(
    project_root: Path,
    manifest: dict[str, Any],
    drift: dict[str, list[dict[str, Any]]],
    only: str | None = None,
) -> list[dict[str, Any]]:
    """Run the health-check registry and return ordered structured results.

    ``only`` filters to a single check id; ``None`` runs the full set.
    """
    runners: dict[str, Any] = {
        "scope": lambda: _check_scope(project_root),
        "global-binary": lambda: _check_global_binary(project_root),
        "manifest-integrity": lambda: _check_manifest_integrity(manifest),
        "lockfile-freshness": lambda: _check_lockfile_freshness(manifest),
        "bridge-drift": lambda: _check_bridge_drift(
            drift["missing"], drift["modified"],
            drift["foreign"], drift["tag_drift"],
        ),
        "mcp-mode": lambda: _check_mcp_mode(project_root),
        "mcp-beta-readiness": lambda: _check_mcp_beta_readiness(project_root),
        "offline-readiness": lambda: _check_offline_readiness(),
        "python-runtime": lambda: _check_python_runtime(),
        "tier-usage-readiness": lambda: _check_tier_usage_readiness(project_root),
        "council-cli": lambda: _check_council_cli(project_root),
        "unsupported-combos": lambda: _check_unsupported_combos(manifest),
        "wizard-state": _check_wizard_state,
    }
    out: list[dict[str, Any]] = []
    for cid in CHECK_IDS:
        if only is not None and cid != only:
            continue
        out.append(runners[cid]())
    return out


def _skipped_manifest_check(check_id: str) -> dict[str, Any]:
    """A ``skipped`` verdict for a manifest-required check with no lockfile.

    Explicit machine-readable record (not omitted, not ``null``) so the
    ``--json`` ``checks`` array keeps a stable shape for a global-only
    consumer — a council convergence point (2026-06-02).
    """
    return {
        "id": check_id, "status": "skipped",
        "message": "requires a project lockfile (agents/installed-tools.lock)",
        "remedy": "run `agent-config init` to create a project lockfile, "
                  "then re-run this check",
    }


def _check_bridge_drift_no_manifest(bridge_present: bool) -> dict[str, Any]:
    """Scope-aware ``bridge-drift`` verdict when no project manifest exists.

    Kept out of :func:`_check_bridge_drift` (which stays a pure
    manifest-derived roll-up) per the council's single-responsibility
    point. A global-only consumer has no distributed tools to drift, so
    a bridge-present repo reports ``ok`` ("not applicable"); a repo with
    neither lockfile nor bridge marker reports ``skipped``.
    """
    if bridge_present:
        return {
            "id": "bridge-drift", "status": "ok",
            "message": "no project lockfile → distributed-tool drift not "
                       "applicable (global-only consumer)",
            "remedy": "",
        }
    return {
        "id": "bridge-drift", "status": "skipped",
        "message": "no project lockfile and no bridge marker → drift check "
                   "not applicable",
        "remedy": "run `agent-config init` (project install) or "
                  "`agent-config refresh --project` (global-only consumer)",
    }


def _run_checks_no_manifest(
    project_root: Path,
    bridge_present: bool,
    only: str | None = None,
) -> list[dict[str, Any]]:
    """Run the registry with no project manifest available.

    Mirrors :func:`_run_checks` and preserves :data:`CHECK_IDS` order.
    Global checks (:data:`GLOBAL_CHECK_IDS`) run unchanged; manifest-required
    checks (:data:`MANIFEST_REQUIRED_CHECK_IDS`) report ``skipped``;
    ``bridge-drift`` gets the scope-aware no-manifest verdict.
    """
    runners: dict[str, Any] = {
        "scope": lambda: _check_scope(project_root),
        "global-binary": lambda: _check_global_binary(project_root),
        "manifest-integrity": lambda: _skipped_manifest_check("manifest-integrity"),
        "lockfile-freshness": lambda: _skipped_manifest_check("lockfile-freshness"),
        "bridge-drift": lambda: _check_bridge_drift_no_manifest(bridge_present),
        "mcp-mode": lambda: _check_mcp_mode(project_root),
        "mcp-beta-readiness": lambda: _check_mcp_beta_readiness(project_root),
        "offline-readiness": lambda: _check_offline_readiness(),
        "python-runtime": lambda: _check_python_runtime(),
        "tier-usage-readiness": lambda: _check_tier_usage_readiness(project_root),
        "council-cli": lambda: _check_council_cli(project_root),
        "unsupported-combos": lambda: _skipped_manifest_check("unsupported-combos"),
        "wizard-state": _check_wizard_state,
    }
    out: list[dict[str, Any]] = []
    for cid in CHECK_IDS:
        if only is not None and cid != only:
            continue
        out.append(runners[cid]())
    return out


def _run_no_manifest(
    opts: argparse.Namespace,
    project_root: Path,
    origin: str,
    bridge_present: bool,
) -> int:
    """Handle the no-project-lockfile path without the old hard bail.

    Exit-code contract (council-defined, 2026-06-02):

    * ``0`` — bare report for a recognised global-only consumer
      (bridge marker present), or a single global ``--check`` that passed.
    * ``1`` — a runnable health check requested via ``--check`` failed.
    * ``2`` — a requested ``--check`` cannot run (manifest-required check
      with no lockfile, or ``bridge-drift`` with neither lockfile nor
      bridge marker), or a bare report in an **uninitialised** repo
      (neither lockfile nor bridge marker) — preserves the spirit of the
      pre-existing "run init" signal while still printing a real report.
    """
    checks = _run_checks_no_manifest(project_root, bridge_present, only=opts.check)
    fail_check = any(c["status"] == "fail" for c in checks)
    skipped_requested = opts.check is not None and any(
        c["id"] == opts.check and c["status"] == "skipped" for c in checks
    )

    if opts.json:
        _emit_json(project_root, [], [], [], [], checks=checks, origin=origin)
    elif opts.check is None:
        print(f"  📍  project_root: {project_root} (origin: {origin})")
        if bridge_present:
            print("  ℹ️   global-only consumer: bridge marker present, no "
                  "project lockfile (expected under ADR-020)")
            print("      project-manifest checks are skipped — they apply only "
                  "to project-local distributed tools")
        else:
            print("  ⚠️   no project lockfile and no bridge marker at "
                  f"{project_root}", file=sys.stderr)
            print("      run `agent-config init` (project install) or "
                  "`agent-config refresh --project` (global-only consumer)",
                  file=sys.stderr)
        _emit_checks_text(checks)
    else:
        _emit_checks_text(checks)

    if opts.check is not None:
        if skipped_requested:
            return 2
        return 1 if fail_check else 0
    return 0 if bridge_present else 2


def _emit_json(
    project_root: Path,
    missing: list[dict[str, Any]],
    modified: list[dict[str, Any]],
    foreign: list[dict[str, Any]],
    tag_drift: list[dict[str, Any]],
    checks: list[dict[str, Any]] | None = None,
    origin: str | None = None,
) -> None:
    payload: dict[str, Any] = {
        "project_root": str(project_root),
        "missing": missing,
        "modified": modified,
        "foreign": foreign,
        "tag_drift": tag_drift,
    }
    if origin is not None:
        payload["project_root_origin"] = origin
    if checks is not None:
        payload["checks"] = checks
    print(json.dumps(payload, indent=2))


def _emit_checks_text(checks: list[dict[str, Any]]) -> None:
    """Render the health-check block above the drift section."""
    if not checks:
        return
    print("checks:")
    for c in checks:
        sym = STATUS_SYMBOLS.get(c["status"], "?")
        print(f"  {sym} {c['id']}: {c['message']}")
        if c["status"] != "ok" and c.get("remedy"):
            print(f"      fix: {c['remedy']}")
    print("")


def _emit_text(
    project_root: Path,
    missing: list[dict[str, Any]],
    modified: list[dict[str, Any]],
    foreign: list[dict[str, Any]],
    tag_drift: list[dict[str, Any]],
) -> None:
    total = len(missing) + len(modified) + len(foreign) + len(tag_drift)
    if total == 0:
        print(f"✅  doctor: manifest matches filesystem under {project_root}")
        return
    print(f"⚠️   doctor: {total} drift item(s) under {project_root}")
    for label, items in (
        ("missing", missing),
        ("modified", modified),
        ("foreign", foreign),
        ("tag-drift", tag_drift),
    ):
        if not items:
            continue
        print(f"\n  {label} ({len(items)}):")
        for it in items:
            tool = it["tool"] or "?"
            print(f"    · [{tool}] {it['path']}")
            if label == "tag-drift":
                found = it.get("found") or "(missing)"
                expected = it.get("expected", PACKAGE_TAG_ID)
                print(f"        expected: {expected}")
                print(f"        found:    {found}")
            print(f"        fix: {it['fix']}")


def _parse(argv: list[str]) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        prog="agent-config doctor",
        description=(
            "Manifest ↔ filesystem drift report plus install health "
            "checks. See --check for the individual check ids."
        ),
    )
    parser.add_argument("--project", default=None,
                        help="project root (default: cwd)")
    parser.add_argument("--json", action="store_true",
                        help="emit a JSON report instead of human text")
    parser.add_argument(
        "--check", default=None, metavar="ID",
        choices=list(CHECK_IDS),
        help=("run a single health check by id "
              f"({' · '.join(CHECK_IDS)})"),
    )
    parser.add_argument(
        "--trace-root", action="store_true",
        help=("print every ancestor checked during project-root discovery "
              "plus the winning anchor; short-circuits the drift report"),
    )
    parser.add_argument(
        "--context", action="store_true",
        help=("print effective project root, anchor, env-pin, settings "
              "layer chain, wrapper, and install-mode; short-circuits "
              "the drift report"),
    )
    parser.add_argument(
        "--repair", default=None, metavar="ID",
        choices=list(REPAIR_IDS),
        help=("reset a recoverable artefact and exit "
              f"({' · '.join(REPAIR_IDS)}); short-circuits the drift "
              "report. Idempotent — absent files report 'nothing to "
              "repair' and exit 0."),
    )
    return parser.parse_args(argv)


def _run_trace_root(opts: argparse.Namespace) -> int:
    """Handle ``--trace-root``: discovery walk + records, no drift report."""
    start = Path.cwd()
    root, anchor, trace = find_project_root_with_trace(start)
    # Determine origin label even when no anchor is found.
    if os.environ.get(ROOT_OVERRIDE_ENV) == "1" and os.environ.get(PROJECT_ROOT_ENV):
        origin = "root-flag"
    elif opts.project:
        origin = "explicit"
    elif os.environ.get(PROJECT_ROOT_ENV):
        origin = "env"
    elif root is not None:
        origin = anchor or "unknown"
    else:
        origin = "cwd-fallback"
    if opts.json:
        _emit_trace_json(root, anchor, trace, origin)
    else:
        _emit_trace_text(root, anchor, trace, origin)
    return 0


def _run_context(opts: argparse.Namespace) -> int:
    """Handle ``--context``: install + discovery snapshot, no drift report."""
    try:
        project_root, origin = _resolve_project_root(opts.project)
    except ProjectRootError as exc:
        print(f"❌  doctor: {exc}", file=sys.stderr)
        return 2
    mode, mode_source = _detect_install_mode(project_root)
    ctx: dict[str, Any] = {
        "project_root": str(project_root),
        "origin": origin,
        "install_mode": mode,
        "install_mode_source": mode_source,
        "env_pin": os.environ.get(PROJECT_ROOT_ENV) or None,
        "root_override": os.environ.get(ROOT_OVERRIDE_ENV) == "1",
        "settings_layers": _settings_layer_chain(project_root),
        "wrapper": _detect_wrapper(project_root),
    }
    if opts.json:
        print(json.dumps(ctx, indent=2))
    else:
        _emit_context_text(ctx)
    return 0


def _run_repair(opts: argparse.Namespace) -> int:
    """Handle ``--repair <id>``: reset the named artefact, no drift report.

    Currently the only target is ``wizard-state`` — unlinks the
    resumable session file so the next ``agent-config setup`` boots
    from step 1. Absent files are a no-op (idempotent re-run).
    """
    target = opts.repair
    if target == "wizard-state":
        state_pth = _wizard_state_path()
        payload = {
            "id": "wizard-state",
            "path": str(state_pth),
            "action": "remove" if state_pth.exists() else "noop",
        }
        if state_pth.exists():
            try:
                state_pth.unlink()
            except OSError as exc:
                payload["action"] = "error"
                payload["error"] = str(exc)
                if opts.json:
                    print(json.dumps(payload, indent=2))
                else:
                    print(f"❌  doctor: could not remove {state_pth}: {exc}",
                          file=sys.stderr)
                return 2
        if opts.json:
            print(json.dumps(payload, indent=2))
        else:
            if payload["action"] == "remove":
                print(f"✅  doctor: reset wizard-state ({state_pth})")
            else:
                print(f"✅  doctor: nothing to repair (no wizard-state at {state_pth})")
        return 0
    # argparse `choices=` already constrains target; defensive default.
    print(f"❌  doctor: unknown repair target {target!r}", file=sys.stderr)
    return 2


def main(argv: list[str] | None = None) -> int:
    opts = _parse(list(argv) if argv is not None else sys.argv[1:])
    if opts.repair is not None:
        return _run_repair(opts)
    if opts.trace_root:
        return _run_trace_root(opts)
    if opts.context:
        return _run_context(opts)
    try:
        project_root, origin = _resolve_project_root(opts.project)
    except ProjectRootError as exc:
        print(f"❌  doctor: {exc}", file=sys.stderr)
        return 2
    manifest_pth = installed_tools.manifest_path(project_root)
    manifest = installed_tools.read_manifest(manifest_pth)
    if manifest is None:
        # No project lockfile. Under ADR-020 global-only this is a
        # legitimate state, not an error — run the lockfile-independent
        # checks and report consumer state instead of a hard bail. The
        # per-scope split + exit-code contract live in _run_no_manifest.
        bridge_present = (project_root / BRIDGE_MARKER_RELATIVE).is_file()
        return _run_no_manifest(opts, project_root, origin, bridge_present)

    records, known = _collect_manifest_entries(project_root, manifest)
    missing, modified, tag_drift = _classify(records)
    foreign = _foreign_records(
        project_root, _scan_foreign(project_root, manifest, known),
    )
    drift_groups = {
        "missing": missing, "modified": modified,
        "foreign": foreign, "tag_drift": tag_drift,
    }
    checks = _run_checks(project_root, manifest, drift_groups, only=opts.check)
    fail_check = any(c["status"] == "fail" for c in checks)

    if opts.json:
        _emit_json(
            project_root, missing, modified, foreign, tag_drift,
            checks=checks, origin=origin,
        )
    else:
        if opts.check is None:
            print(f"  📍  project_root: {project_root} (origin: {origin})")
        _emit_checks_text(checks)
        if opts.check is None:
            _emit_text(project_root, missing, modified, foreign, tag_drift)

    # Full-suite mode: exit code reflects drift only; failing health
    # checks are surfaced visually but do not change the rc, preserving
    # the original drift-detection contract. ``--check <id>`` mode is
    # explicit and propagates the single check's verdict.
    if opts.check is not None:
        return 1 if fail_check else 0
    return 1 if (missing or modified or foreign or tag_drift) else 0


if __name__ == "__main__":  # pragma: no cover
    raise SystemExit(main())
