#!/usr/bin/env python3
"""Gitea MCP Server — exposes Gitea operations as MCP tools.

Runs over stdio. All tools authenticate via macOS keychain (git credential fill).

Usage (standalone test):
    python3 mcp_server.py

Configuration (mcp_config.json):
    "gitea-tools": {
        "command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
        "args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"],
        "env": {}
    }
"""
import json
import os
import re
import sys
import json
import functools
import contextlib
import subprocess
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any



# Mutation-authority record (#199, refs #194). Deliberately in-process, NOT a
# file: a /tmp lock is host-global, writable (spoofable) by any local process,
# goes silently stale across sessions, and races between concurrent agent
# sessions. This record lives and dies with the MCP server process, so it can
# never be forged from outside or leak between sessions. The CLI side-channel
# (a subprocess overriding GITEA_MCP_PROFILE to escalate roles) is covered by
# SESSION_PROFILE_LOCK_ENV below: the server exports its launch profile into
# the environment, children inherit it, and reviewer CLIs (review_pr.py)
# refuse to run under a different resolved profile.
_MUTATION_AUTHORITY: dict | None = None

SESSION_PROFILE_LOCK_ENV = "GITEA_SESSION_PROFILE_LOCK"


def _export_session_profile_lock():
    """Export this process's launch profile for child CLI processes.

    setdefault: an already-locked environment (outer session) wins, so a
    nested launch cannot relabel the session.
    """
    try:
        name = (get_profile().get("profile_name") or "").strip()
        if name:
            os.environ.setdefault(SESSION_PROFILE_LOCK_ENV, name)
    except Exception:
        # Profile resolution problems surface loudly on the first real call;
        # the lock export must not mask them here at import time.
        pass


def record_mutation_authority(profile_name: str | None, identity: str | None,
                              remote: str | None, task: str | None):
    """Record the resolved capability context for this process (fail-closed
    consumers in verify_mutation_authority)."""
    global _MUTATION_AUTHORITY
    _MUTATION_AUTHORITY = {
        "initial_profile": profile_name,
        "initial_identity": identity,
        "current_profile": profile_name,
        "current_identity": identity,
        "remote": remote,
        "task": task,
        "role_pivot_authorized": False,
        "role_pivot_record": None,
        "pid": os.getpid(),
    }


def verify_mutation_authority(remote: str | None, host: str | None = None,
                              required_role: str = "reviewer",
                              active_identity: str | None = None):
    """Verify the current mutation matches this process's recorded authority.

    Fail-closed rules:
    - No recorded authority (or one from another process after a fork) is
      seeded from the live, config-resolved context — the approved preflight
      path (whoami → eligibility → mutation) therefore works without an
      explicit resolve call — but an unresolvable profile still fails closed.
    - GITEA_SESSION_PROFILE_LOCK (set by the launching session) must match
      the active profile: a mid-session GITEA_MCP_PROFILE override flips the
      active profile away from the lock and is refused.
    - Remote, profile, and identity must match the recorded authority.
    - An author→reviewer pivot requires an authorized pivot record
      (gitea_activate_profile in dynamic mode); it can never be improvised.
    """
    global _MUTATION_AUTHORITY

    profile = get_profile()
    active_profile = profile.get("profile_name")
    if active_identity is None:
        # Callers that already proved the identity (eligibility gate) pass it
        # in; otherwise resolve it here (cached, read-only).
        h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
        active_identity = _authenticated_username(h) if h else None

    if not active_profile:
        raise RuntimeError(
            "Mutation authority unavailable: active profile unresolved (fail closed)"
        )

    session_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
    if session_lock and session_lock != active_profile and not gitea_config.is_runtime_switching_enabled():
        raise RuntimeError(
            f"Active profile '{active_profile}' does not match the session "
            f"profile lock '{session_lock}' — profile side-channel override "
            "rejected (fail closed)"
        )

    data = _MUTATION_AUTHORITY
    if data is None or data.get("pid") != os.getpid():
        # First mutation gate in this process (approved preflight path):
        # seed the authority from the live context, then verify against it.
        record_mutation_authority(
            active_profile, active_identity, remote, "seeded-at-mutation-gate"
        )
        data = _MUTATION_AUTHORITY

    if data.get("remote") != remote:
        raise RuntimeError(
            f"Mutation remote '{remote}' does not match locked remote "
            f"'{data.get('remote')}' (fail closed)"
        )

    locked_profile = data.get("current_profile")
    locked_identity = data.get("current_identity")
    if active_profile != locked_profile or active_identity != locked_identity:
        raise RuntimeError(
            f"Mutation profile '{active_profile}' or identity '{active_identity}' "
            f"does not match locked authority (profile: '{locked_profile}', "
            f"identity: '{locked_identity}') (fail closed)"
        )

    # Reviewer/author role pivot boundary: only an authorized pivot
    # (recorded by gitea_activate_profile) may cross author -> reviewer/merger.
    if (required_role in ("reviewer", "merger")
            and "author" in str(data.get("initial_profile")).lower()
            and ("reviewer" in str(active_profile).lower() or "merger" in str(active_profile).lower())):
        if not data.get("role_pivot_authorized"):
            raise RuntimeError(
                f"Attempted {required_role} mutation from author session without "
                "authorized role pivot (fail closed)"
            )

# Resolve the project root. MCP clients must launch this script directly with
# the venv interpreter (venv/bin/python3) — see the config example above. We do
# NOT os.execv() to re-point the interpreter: replacing the process after the
# client has already wired up the stdio pipes can desync the JSON-RPC transport
# (observed with Antigravity/Cascade hosts).
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))

# Ensure the project root is on the path so gitea_auth.py can be imported.
if PROJECT_ROOT not in sys.path:
    sys.path.insert(0, PROJECT_ROOT)

import json
import subprocess

_process_boot_head_sha = None
try:
    _process_boot_head_sha = subprocess.run(
        ["git", "rev-parse", "HEAD"],
        capture_output=True, text=True, check=True, cwd=PROJECT_ROOT
    ).stdout.strip()
except Exception:
    pass


_preflight_whoami_called = False
_preflight_capability_called = False
_preflight_whoami_violation = False
_preflight_capability_violation = False
_preflight_resolved_role = None
_preflight_resolved_task: str | None = None
_process_start_porcelain: str | None = None
_preflight_whoami_baseline_porcelain: str | None = None
_preflight_capability_baseline_porcelain: str | None = None
_preflight_whoami_violation_files: list[str] = []
_preflight_capability_violation_files: list[str] = []
_preflight_reviewer_violation_files: list[str] = []

ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
REVIEWER_WORKTREE_ENV = "GITEA_REVIEWER_WORKTREE"
MERGER_WORKTREE_ENV = "GITEA_MERGER_WORKTREE"
RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE"

import namespace_workspace_binding as nwb  # noqa: E402
import canonical_repository_root as crr  # noqa: E402  # #706 cross-repo canonical root
import mcp_namespace_health  # noqa: E402
import stale_binding_recovery  # noqa: E402

# Worktree env bindings inherited from the parent environment at daemon boot
# (#702). A value that still equals its boot snapshot was never established by
# a sanctioned tool in this daemon's lifetime.
_BOOT_ENV_WORKTREE_BINDINGS = stale_binding_recovery.snapshot_boot_bindings()


def _preflight_in_test_mode() -> bool:
    return "pytest" in sys.modules or "unittest" in sys.modules


def _reviewer_session_worktree() -> str | None:
    import reviewer_pr_lease as _reviewer_pr_lease

    session = _reviewer_pr_lease.get_session_lease()
    if not session:
        return None
    worktree = (session.get("worktree") or "").strip()
    return worktree or None


def _effective_workspace_role() -> str:
    """Resolve the namespace key used for workspace binding (#510)."""
    profile = get_profile()
    role = _preflight_resolved_role
    if not role:
        role = _role_kind(
            profile.get("allowed_operations") or [],
            profile.get("forbidden_operations") or [],
        )
    return nwb.normalize_role_kind(
        role,
        profile_name=profile.get("profile_name"),
    )


def _profile_role_kind(profile: dict) -> str:
    """Resolve a profile's declared role before inferring from permissions."""
    role = (profile.get("role") or profile.get("role_kind") or "").strip()
    if role:
        return role
    profile_name = (profile.get("profile_name") or "").strip().lower()
    for candidate in ("reconciler", "merger", "reviewer", "author"):
        if candidate in profile_name:
            return candidate
    return _role_kind(
        profile.get("allowed_operations") or [],
        profile.get("forbidden_operations") or [],
    )


def _actual_profile_role() -> str:
    """Resolve the workspace role from the ACTIVE PROFILE alone (#540).

    Unlike :func:`_effective_workspace_role`, this never consults
    ``_preflight_resolved_role``. A task capability whose ``required_role_kind``
    is ``author`` (e.g. ``comment_issue``) stamps ``_preflight_resolved_role =
    "author"``; the #274/#475 role exemptions must key off the real profile
    identity so that stamp cannot poison a genuine reviewer/merger/reconciler
    session into being treated as an author. Author profiles still classify as
    ``author`` here, so author blocking is preserved.
    """
    profile = get_profile()
    role = _profile_role_kind(profile)
    return nwb.normalize_role_kind(
        role,
        profile_name=profile.get("profile_name"),
    )


def _assess_stale_active_binding(auto_recover: bool = False) -> dict:
    """Classify the GITEA_ACTIVE_WORKTREE binding; recover when provably stale (#702).

    Runs during ``gitea_resolve_task_capability`` / runtime context. Clearing
    is the sanctioned in-daemon mechanism and happens only for the two
    provably-stale classes (missing path, superseded by a sanctioned session
    lease); an uncorroborated boot-inherited binding is surfaced with the
    managed-reconnect next action, never cleared silently.
    """
    active_value = (os.environ.get(ACTIVE_WORKTREE_ENV) or "").strip() or None
    try:
        role = _actual_profile_role()
    except Exception:
        role = "author"
    role_env_key = nwb.ROLE_WORKTREE_ENVS.get(role, AUTHOR_WORKTREE_ENV)
    boot_value = (
        (_BOOT_ENV_WORKTREE_BINDINGS.get("active_worktree") or "").strip() or None
    )
    classification = stale_binding_recovery.classify_active_worktree_binding(
        active_value=active_value,
        role_env_value=(os.environ.get(role_env_key) or "").strip() or None,
        session_lease_worktree=_reviewer_session_worktree(),
        boot_inherited=bool(active_value and boot_value == active_value),
        path_exists=os.path.isdir(active_value) if active_value else None,
        role_kind=role,
    )
    plan = stale_binding_recovery.plan_recovery(classification)
    applied: dict = {"performed": False}
    recovery_enabled = (
        not _preflight_in_test_mode()
        or os.environ.get("GITEA_FORCE_STALE_BINDING_RECOVERY") == "1"
    )
    audit_persisted: bool | None = None
    if auto_recover and plan.get("clear_allowed") and recovery_enabled:
        # #702 F6: the durable audit record must exist BEFORE the environment
        # is cleared. If audit persistence fails the recovery does not run —
        # an env clear without durable proof is an unauditable mutation.
        pending_value = (os.environ.get(ACTIVE_WORKTREE_ENV) or "").strip() or None
        audit_error: str | None = None
        try:
            import mcp_session_state as mss

            mss.save_state(
                kind=mss.KIND_STALE_BINDING_RECOVERY,
                payload={
                    "cleared_env": ACTIVE_WORKTREE_ENV,
                    "cleared_value": pending_value,
                    "classification": plan.get("classification"),
                    "reasons": list(plan.get("reasons") or []),
                    "recovery_status": "pending",
                },
            )
            audit_persisted = True
        except Exception as exc:
            audit_persisted = False
            audit_error = f"{type(exc).__name__}: {exc}"
        if audit_persisted:
            applied = stale_binding_recovery.apply_recovery(plan)
            if applied.get("performed"):
                try:
                    import mcp_session_state as mss

                    mss.save_state(
                        kind=mss.KIND_STALE_BINDING_RECOVERY,
                        payload={
                            "cleared_env": applied.get("cleared_env"),
                            "cleared_value": applied.get("cleared_value"),
                            "classification": applied.get("classification"),
                            "reasons": applied.get("reasons"),
                            "recovery_status": "performed",
                        },
                    )
                except Exception:
                    # The pre-clear "pending" record is already durable; the
                    # status refresh is best-effort and never rolls back.
                    pass
        else:
            applied = {
                "performed": False,
                "action": stale_binding_recovery.RECOVERY_ACTION_NONE,
                "audit_write_failed": True,
                "reasons": [
                    "audit persistence failed; stale binding NOT cleared "
                    "(fail closed, #702 F6)"
                ]
                + ([audit_error] if audit_error else []),
            }
    report = {
        "classification": classification.get("classification"),
        "active_worktree": classification.get("active_worktree"),
        "boot_inherited": classification.get("boot_inherited", False),
        "clear_eligible": bool(classification.get("clear_eligible")),
        "recovery_action": plan.get("action"),
        "recovery_performed": bool(applied.get("performed")),
        "reasons": classification.get("reasons") or [],
    }
    if audit_persisted is not None:
        report["audit_persisted"] = audit_persisted
    if applied.get("audit_write_failed"):
        report["audit_write_failed"] = True
        report["reasons"] = list(report["reasons"]) + list(
            applied.get("reasons") or []
        )
    if applied.get("performed"):
        report["cleared_value"] = applied.get("cleared_value")
    if plan.get("exact_next_action"):
        report["exact_next_action"] = plan.get("exact_next_action")
    return report


def _configured_canonical_root() -> tuple[str | None, str | None]:
    """Declared cross-repository canonical root for the active namespace (#706).

    Returns ``(value, source)`` from the profile/env binding, or ``(None, None)``
    for the single-repo default. The raw declared value is threaded into
    workspace resolution so the branches-only / membership guards evaluate
    against the configured target repository; the strict identity/existence
    checks run separately in :func:`_enforce_canonical_repository_root`.
    """
    try:
        profile = get_profile()
    except Exception:
        profile = {}
    return crr.configured_canonical_root(profile, os.environ)


def _canonical_local_git_root() -> str:
    """Local git working root for *target-repository* operations (#741).

    Two distinct roots exist and must never be conflated:

    * ``PROJECT_ROOT`` — the Gitea-Tools **installation** checkout, i.e. where
      this server script lives. Server implementation/version parity, workflow
      and skill file loading, and self-code staleness detection are anchored
      here and must stay that way.
    * the **canonical target repository root** — the working root of the
      repository whose issues/PRs/branches the namespace actually mutates.

    Every local git invocation that reads or proves *target repository* state
    (remote identity, ancestry, worktree inventory, cleanup) must run here, or
    a cross-repository namespace silently operates on Gitea-Tools instead.

    Returns the resolved canonical target root when one is configured, else
    ``PROJECT_ROOT`` — which preserves the single-repository default exactly.
    A configured-but-invalid root is *not* silently replaced by the install
    checkout: it is returned as-is so downstream git calls fail rather than
    succeed against the wrong repository, and
    :func:`_enforce_canonical_repository_root` fails the mutation closed first.
    """
    configured, _source = _configured_canonical_root()
    if not configured:
        return PROJECT_ROOT
    return crr.resolve_repo_toplevel(configured) or os.path.realpath(configured)


def _session_author_lock_worktree() -> str | None:
    """Worktree path recorded on the active author issue lock (#618).

    Used to derive the author mutation workspace when no explicit
    ``worktree_path`` or env binding is provided. Never invents a path.
    """
    try:
        lock = issue_lock_store.read_session_issue_lock() or {}
    except Exception:
        return None
    path = (lock.get("worktree_path") or "").strip()
    return path or None


def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
    """Resolve the namespace-scoped workspace root inspected by pre-flight guards.

    #702 F2: preflight resolves with the same existence verification as the
    canonical mutation context (verify_paths). A dead env binding must demote
    here exactly as it does in :func:`_resolve_namespace_mutation_context`,
    or preflight would inspect a path the mutation guard never uses.

    #618: author role never demotes a missing configured binding to the
    control checkout; resolution may derive from the active issue lock.
    """
    role = _effective_workspace_role()
    workspace, _source = nwb.resolve_namespace_workspace(
        role_kind=role,
        worktree_path=worktree_path,
        process_project_root=PROJECT_ROOT,
        session_lease_worktree=(
            _reviewer_session_worktree() if role in {"reviewer", "merger"} else None
        ),
        session_lock_worktree=(
            _session_author_lock_worktree() if role == "author" else None
        ),
        profile_name=get_profile().get("profile_name"),
        verify_paths=True,
    )
    return workspace


def _resolve_namespace_mutation_context(worktree_path: str | None = None) -> dict:
    """Canonical namespace workspace + repository root for guards (#460/#510/#706/#618)."""
    role = _effective_workspace_role()
    configured_root, _source = _configured_canonical_root()
    return nwb.resolve_namespace_mutation_context(
        role_kind=role,
        worktree_path=worktree_path,
        process_project_root=PROJECT_ROOT,
        session_lease_worktree=(
            _reviewer_session_worktree() if role in {"reviewer", "merger"} else None
        ),
        session_lock_worktree=(
            _session_author_lock_worktree() if role == "author" else None
        ),
        profile_name=get_profile().get("profile_name"),
        configured_canonical_root=configured_root,
    )


def _resolve_author_mutation_context(worktree_path: str | None = None) -> dict:
    """Backward-compatible alias for namespace workspace context."""
    return _resolve_namespace_mutation_context(worktree_path)


def _ensure_process_start_porcelain() -> str:
    """Capture the shared-worktree baseline once per MCP process (#252)."""
    global _process_start_porcelain
    if _process_start_porcelain is None:
        _process_start_porcelain = _get_workspace_porcelain()
    return _process_start_porcelain


def _get_git_root(path: str) -> str | None:
    try:
        res = subprocess.run(
            ["git", "-C", path, "rev-parse", "--show-toplevel"],
            capture_output=True,
            text=True,
            check=False,
        )
    except Exception:
        return None
    if res.returncode != 0:
        return None
    return (res.stdout or "").strip() or None


# #702 F2: synthetic tracked-dirty porcelain returned when the preflight
# workspace cannot be inspected (missing path, git failure). Shaped as a
# modified tracked entry so every consumer treats "workspace unavailable"
# as dirty — an uninspectable workspace must never read as clean.
PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN = (
    " M __gitea_preflight_workspace_unavailable__\n"
)


def _get_workspace_porcelain(worktree_path: str | None = None) -> str:
    """Return tracked-workspace porcelain for pre-flight attribution."""
    if os.environ.get("GITEA_TEST_FORCE_DIRTY"):
        return " M __gitea_test_force_dirty__.py\n"
    override = os.environ.get("GITEA_TEST_PORCELAIN")
    if override is not None:
        return override
    workspace = _resolve_preflight_workspace_path(worktree_path)
    if not os.path.isdir(workspace):
        return PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN
    try:
        res = subprocess.run(
            ["git", "status", "--porcelain"],
            capture_output=True,
            text=True,
            cwd=workspace,
        )
    except Exception:
        return PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN
    if res.returncode != 0:
        return PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN
    return res.stdout or ""


def _parse_porcelain_entries(porcelain: str) -> dict[str, str]:
    """Map tracked path -> full porcelain line (untracked ``??`` ignored)."""
    entries: dict[str, str] = {}
    for line in (porcelain or "").splitlines():
        if not line or len(line) < 4 or line.startswith("??"):
            continue
        path = line[3:].strip()
        if " -> " in path:
            path = path.split(" -> ", 1)[1].strip()
        if path:
            entries[path] = line
    return entries


def _new_tracked_changes_since(baseline: str, current: str) -> list[str]:
    """Tracked paths that are new or changed since *baseline* porcelain."""
    base = _parse_porcelain_entries(baseline)
    cur = _parse_porcelain_entries(current)
    changed = [
        path for path, line in cur.items()
        if path not in base or base[path] != line
    ]
    return sorted(changed)


def _format_preflight_files(files: list[str]) -> str:
    if not files:
        return "(none)"
    return ", ".join(files)


def _preflight_workspace_details(worktree_path: str | None, dirty_files: list[str]) -> dict:
    ctx = _resolve_namespace_mutation_context(worktree_path)
    workspace = ctx["workspace_path"]
    # Prefer durable-resolution evidence when present (#618); fall back to live git.
    inspected_root = ctx.get("inspected_git_root")
    if inspected_root is None and workspace and os.path.isdir(workspace):
        inspected_root = _get_git_root(workspace)
    process_root = ctx["process_project_root"]
    canonical_root = ctx["canonical_repo_root"]
    active_root = os.path.realpath(inspected_root or workspace)
    if active_root == canonical_root:
        dirty_scope = "control checkout"
    else:
        dirty_scope = "active task workspace"
    path_exists = ctx.get("path_exists")
    if path_exists is None:
        path_exists = os.path.isdir(workspace) if workspace else False
    details = {
        "mcp_server_process_root": process_root,
        "canonical_repository_root": canonical_root,
        "active_task_workspace_root": active_root,
        "inspected_git_root": inspected_root,
        "dirty_files": list(dirty_files),
        "dirty_scope": dirty_scope,
        "workspace_roots_aligned": ctx["roots_aligned"],
        "workspace_role_kind": ctx.get("workspace_role_kind"),
        "workspace_binding_source": ctx.get("workspace_binding_source"),
        "ignored_bindings": list(ctx.get("ignored_bindings") or []),
        # #618 runtime health surface for bound worktree disappearance
        "path_exists": path_exists,
        "in_git_worktree_list": ctx.get("in_git_worktree_list"),
        "bound_worktree_missing": bool(ctx.get("bound_worktree_missing")),
        "author_worktree_block": bool(ctx.get("author_worktree_block")),
        "author_worktree_reasons": list(ctx.get("author_worktree_reasons") or []),
        "operator_recovery": ctx.get("operator_recovery"),
        "workspace_healthy": not bool(
            ctx.get("bound_worktree_missing") or ctx.get("author_worktree_block")
        ),
    }
    if not ctx["roots_aligned"]:
        details["workspace_root_mismatch"] = (
            "runtime_context and mutation guard use canonical repository root "
            f"'{canonical_root}' instead of MCP process root '{process_root}'"
        )
    return details


def _format_preflight_workspace_details(details: dict) -> str:
    parts = [
        f"MCP server process root: {details.get('mcp_server_process_root')}",
        f"active task workspace root: {details.get('active_task_workspace_root')}",
        f"workspace role: {details.get('workspace_role_kind')}",
        f"binding source: {details.get('workspace_binding_source')}",
        f"inspected git root: {details.get('inspected_git_root')}",
        f"path_exists: {details.get('path_exists')}",
        f"in_git_worktree_list: {details.get('in_git_worktree_list')}",
        f"bound_worktree_missing: {details.get('bound_worktree_missing')}",
        f"workspace_healthy: {details.get('workspace_healthy')}",
        f"dirty files: {_format_preflight_files(details.get('dirty_files') or [])}",
        f"dirty scope: {details.get('dirty_scope')}",
    ]
    ignored = details.get("ignored_bindings") or []
    if ignored:
        parts.append(f"ignored foreign bindings: {'; '.join(ignored)}")
    return "; ".join(parts)


def assess_preflight_status(worktree_path: str | None = None) -> dict:
    """Non-throwing pre-flight readiness for runtime-context alignment (#252/#618)."""
    reasons: list[str] = []
    if not _preflight_whoami_called:
        reasons.append(
            "Identity (gitea_whoami) has not been verified"
        )
    if not _preflight_capability_called:
        reasons.append(
            "Task capability (gitea_resolve_task_capability) has not been resolved"
        )
    try:
        role = _effective_workspace_role()
    except Exception:
        role = "author"

    # #618: always compute workspace details for author so runtime_context can
    # report unhealthy state when a configured role-bound path is missing.
    dirty_files: list[str] = []
    workspace_details = None
    try:
        dirty_files = sorted(
            _parse_porcelain_entries(_get_workspace_porcelain(worktree_path))
        )
        workspace_details = _preflight_workspace_details(worktree_path, dirty_files)
    except Exception:
        workspace_details = None

    if workspace_details is not None:
        if workspace_details.get("bound_worktree_missing") or (
            workspace_details.get("author_worktree_block")
            and workspace_details.get("path_exists") is False
        ):
            reasons.append(
                author_mutation_worktree.BOUND_WORKTREE_MISSING_MESSAGE
                + f" ({_format_preflight_workspace_details(workspace_details)})"
            )
        elif (
            role == "author"
            and workspace_details.get("workspace_healthy") is False
            and workspace_details.get("author_worktree_reasons")
        ):
            reasons.append(
                "Author worktree binding unhealthy: "
                + "; ".join(workspace_details.get("author_worktree_reasons") or [])
                + f" ({_format_preflight_workspace_details(workspace_details)})"
            )

    if worktree_path:
        if dirty_files and not any("bound worktree missing" in r for r in reasons):
            details = workspace_details or _preflight_workspace_details(
                worktree_path, dirty_files
            )
            reasons.append(
                "Active task workspace has tracked file edits before mutation "
                f"({_format_preflight_workspace_details(details)})"
            )
        if role in nwb.NON_AUTHOR_ROLES:
            binding = nwb.assess_metadata_only_worktree_binding(
                role_kind=role,
                declared_worktree_path=worktree_path,
                mutation_workspace=_resolve_preflight_workspace_path(worktree_path),
                process_project_root=PROJECT_ROOT,
                profile_name=get_profile().get("profile_name"),
            )
            if binding.get("block"):
                reasons.append(binding["reasons"][0])
                reasons.append(
                    nwb.format_namespace_workspace_binding_error(
                        role_kind=role,
                        workspace_path=binding["mutation_workspace"],
                        binding_source="MCP server process root (default)",
                        reasons=binding.get("reasons"),
                    )
                )
        return {
            "preflight_ready": not reasons,
            "preflight_block_reasons": reasons,
            "preflight_whoami_verified": _preflight_whoami_called,
            "preflight_capability_resolved": _preflight_capability_called,
            "preflight_whoami_violation_files": [],
            "preflight_capability_violation_files": [],
            "preflight_reviewer_violation_files": [],
            "preflight_workspace": workspace_details,
        }
    if _preflight_whoami_violation:
        reasons.append(
            "Workspace file edits occurred before gitea_whoami verification "
            f"(offending files: {_format_preflight_files(_preflight_whoami_violation_files)})"
        )
    if _preflight_capability_violation:
        reasons.append(
            "Workspace file edits occurred before gitea_resolve_task_capability verification "
            f"(offending files: {_format_preflight_files(_preflight_capability_violation_files)})"
        )
    if _preflight_reviewer_violation_files:
        reasons.append(
            "Reviewer profile modified tracked workspace files after capability resolution "
            f"(offending files: {_format_preflight_files(_preflight_reviewer_violation_files)})"
        )
    warnings: list[str] = []
    agent_artifacts = agent_temp_artifacts.find_agent_temp_artifacts_from_porcelain(
        _get_workspace_porcelain()
    )
    if agent_artifacts:
        warnings.append(
            "Agent temp artifacts detected at repo root (delete before mutations): "
            f"{_format_preflight_files(agent_artifacts)}"
        )
    return {
        "preflight_ready": not reasons,
        "preflight_block_reasons": reasons,
        "preflight_warnings": warnings,
        "preflight_whoami_verified": _preflight_whoami_called,
        "preflight_capability_resolved": _preflight_capability_called,
        "preflight_whoami_violation_files": list(_preflight_whoami_violation_files),
        "preflight_capability_violation_files": list(_preflight_capability_violation_files),
        "preflight_reviewer_violation_files": list(_preflight_reviewer_violation_files),
        "preflight_workspace": workspace_details
        if workspace_details is not None
        else _preflight_workspace_details(None, []),
    }


def _clear_preflight_capability_state() -> None:
    """Drop resolved capability proof (consumed by a mutation or fresh resolve)."""
    global _preflight_capability_called, _preflight_capability_violation
    global _preflight_resolved_task, _preflight_resolved_role
    global _preflight_capability_baseline_porcelain, _preflight_capability_violation_files
    global _preflight_reviewer_violation_files

    _preflight_capability_called = False
    _preflight_capability_violation = False
    _preflight_capability_violation_files = []
    _preflight_capability_baseline_porcelain = None
    _preflight_resolved_task = None
    _preflight_resolved_role = None
    _preflight_reviewer_violation_files = []


def _clear_resolved_capability_stamp() -> None:
    """Clear the task/role stamp without manufacturing capability proof.

    A fresh resolver attempt invalidates the prior stamp immediately.  The
    new role/task is recorded only after the current attempt is permitted, so
    denial or an unexpected exception cannot leave stale authority behind.
    """
    global _preflight_resolved_role, _preflight_resolved_task

    _preflight_resolved_role = None
    _preflight_resolved_task = None


def _invalidate_preflight_identity_state() -> None:
    """Fail closed when whoami cannot prove the configured identity."""
    global _preflight_whoami_called, _preflight_whoami_violation
    global _preflight_whoami_baseline_porcelain, _preflight_whoami_violation_files

    _preflight_whoami_called = False
    _preflight_whoami_violation = False
    _preflight_whoami_baseline_porcelain = None
    _preflight_whoami_violation_files = []
    _clear_preflight_capability_state()


def record_preflight_check(
    type_name: str,
    resolved_role: str | None = None,
    resolved_task: str | None = None,
):
    """Record a pre-flight check (whoami or capability) with session-scoped deltas."""
    global _preflight_whoami_called, _preflight_capability_called
    global _preflight_whoami_violation, _preflight_capability_violation
    global _preflight_resolved_role, _preflight_resolved_task
    global _preflight_whoami_baseline_porcelain, _preflight_capability_baseline_porcelain
    global _preflight_whoami_violation_files, _preflight_capability_violation_files
    global _preflight_reviewer_violation_files

    current = _get_workspace_porcelain()

    if type_name == "whoami":
        # Re-evaluate whoami violations instead of replaying sticky state (#252).
        # Interleaved read-only whoami must not clear a valid capability proof (#469).
        saved_capability = None
        preserve_capability = (
            _preflight_capability_called
            and _preflight_whoami_called
            and not _preflight_whoami_violation
        )
        if preserve_capability:
            saved_capability = (
                _preflight_capability_violation,
                list(_preflight_capability_violation_files),
                _preflight_capability_baseline_porcelain,
                _preflight_resolved_role,
                _preflight_resolved_task,
            )
        else:
            _clear_preflight_capability_state()

        process_start = _ensure_process_start_porcelain()
        whoami_delta = _new_tracked_changes_since(process_start, current)
        _preflight_whoami_violation = bool(whoami_delta)
        _preflight_whoami_violation_files = whoami_delta
        _preflight_whoami_baseline_porcelain = current
        _preflight_whoami_called = True

        if (
            preserve_capability
            and saved_capability is not None
            and not whoami_delta
        ):
            (
                _preflight_capability_violation,
                _preflight_capability_violation_files,
                _preflight_capability_baseline_porcelain,
                _preflight_resolved_role,
                _preflight_resolved_task,
            ) = saved_capability
            _preflight_capability_called = True
    elif type_name == "capability":
        baseline = _preflight_whoami_baseline_porcelain or ""
        capability_delta = _new_tracked_changes_since(baseline, current)
        _preflight_capability_violation = bool(capability_delta)
        _preflight_capability_violation_files = capability_delta
        _preflight_capability_baseline_porcelain = current
        _preflight_capability_called = True
        if resolved_role:
            _preflight_resolved_role = resolved_role
        if resolved_task:
            _preflight_resolved_task = resolved_task


def _enforce_canonical_repository_root(
    worktree_path: str | None = None,
    *,
    remote: str | None = None,
) -> None:
    """#706: validate the immutable cross-repository canonical root binding.

    A no-op for the single-repo default (no configured binding). When a
    namespace declares a ``canonical_repository_root`` (profile/env), the
    configured target repository must exist, be a git repository, and carry a
    repository identity matching the session's authorized repository. Missing,
    conflicting, or forged bindings fail closed, and the validated root is
    checked against the immutable session pin so it cannot be swapped mid
    session.
    """
    configured_value, source = _configured_canonical_root()
    if not configured_value:
        return

    bound = session_ctx.get_session_context() or {}
    expected_slug = session_ctx.format_repository_slug(
        bound.get("org"), bound.get("repository")
    )
    assessment = crr.assess_canonical_repository_root(
        configured_value=configured_value,
        source=source,
        expected_slug=expected_slug,
        process_project_root=PROJECT_ROOT,
        remote=remote,
        require_binding=True,
    )
    if assessment["block"]:
        raise RuntimeError(
            crr.format_canonical_repository_root_error(assessment)
        )

    drift = session_ctx.assess_session_context(
        profile_name=get_profile().get("profile_name"),
        remote=remote,
        canonical_repository_root=assessment["canonical_repo_root"],
    )
    if drift["block"]:
        raise RuntimeError(
            "Canonical repository root guard (#706): "
            + "; ".join(drift["reasons"])
        )


# #757: distinguishes "caller supplied no bootstrap evidence" (compute it) from
# "caller supplied a not-applicable/refused assessment" (honour it, fail closed).
_BOOTSTRAP_UNSET = object()


def _create_issue_bootstrap_assessment(
    task: str | None,
    worktree_path: str | None = None,
) -> dict | None:
    """#757: the single server-derived create_issue bootstrap assessment.

    Computed once per preflight from inspected repository state, then consumed
    by BOTH the #274 branches-only guard and the #604 anti-stomp preflight so
    the two cannot reach opposite conclusions about identical evidence.

    Returns ``None`` when *task* is not a create_issue mutation, which leaves
    every other author mutation on the ordinary branches-only path. The result
    is derived from inspected repository state only — never from an MCP tool
    argument.
    """
    import create_issue_bootstrap as _cib

    if not _cib.is_create_issue_task(task):
        return None

    ctx = _resolve_namespace_mutation_context(worktree_path)
    workspace = ctx["workspace_path"]
    git_state = issue_lock_worktree.read_worktree_git_state(workspace)
    # #757 AC3/AC4: a resolver failure is not "no constraint" — it is missing
    # evidence, and must reach the assessor as such so the bootstrap fails
    # closed instead of proceeding without base-equivalence proof.
    remote_master_sha_error: str | None = None
    try:
        remote_master_sha = root_checkout_guard.resolve_remote_master_sha(
            ctx["canonical_repo_root"]
        )
    except Exception as exc:
        remote_master_sha = None
        remote_master_sha_error = f"{type(exc).__name__}: {exc}".strip() or "resolver failed"
    return _cib.assess_create_issue_bootstrap(
        workspace_path=workspace,
        canonical_repo_root=ctx["canonical_repo_root"],
        current_branch=git_state.get("current_branch"),
        head_sha=git_state.get("head_sha"),
        porcelain_status=git_state.get("porcelain_status") or "",
        remote_master_sha=remote_master_sha,
        remote_master_sha_error=remote_master_sha_error,
        task=task,
    )


def _enforce_branches_only_author_mutation(
    worktree_path: str | None = None,
    *,
    task: str | None = None,
    bootstrap_assessment: Any = _BOOTSTRAP_UNSET,
) -> None:
    """#274: author file/branch mutations must run from a branches/ worktree.

    Reviewer, merger, and reconciler roles are exempt: reconciler ``close_pr``
    is a Gitea metadata mutation and must not require ``GITEA_AUTHOR_WORKTREE``
    (#468, #483). Non-author namespaces use dedicated workspace env vars (#510).

    The exemption honours BOTH the effective workspace role and the actual
    profile role (#540). ``comment_issue`` preflight stamps
    ``_preflight_resolved_role = "author"`` (its ``required_role_kind``), which
    would otherwise poison :func:`_effective_workspace_role` into classifying a
    genuine reconciler as an author and defeat this exemption. Keying off the
    real profile role as well preserves the exemption without weakening author
    blocking — an actual author profile classifies as ``author`` in both.

    #749: ``create_issue`` alone may proceed from a **clean** canonical control
    checkout (pure remote mutation; no issue number exists yet for an
    issue-backed worktree). Dirty roots, non-base branches, base races, and
    every other author mutation keep the ordinary branches-only fail-closed
    behaviour.
    """
    if (
        _effective_workspace_role() in nwb.NON_AUTHOR_ROLES
        or _actual_profile_role() in nwb.NON_AUTHOR_ROLES
    ):
        return
    ctx = _resolve_namespace_mutation_context(worktree_path)
    workspace = ctx["workspace_path"]

    # #618: durable resolution already failed closed (missing binding, lock
    # mismatch, traversal, membership). Bound-worktree-missing never receives
    # the create_issue control-checkout bootstrap exemption — a configured but
    # missing worktree is never "clean control".
    durable = ctx.get("author_worktree_resolution") or {}
    if ctx.get("bound_worktree_missing") or (
        durable.get("block") and durable.get("bound_worktree_missing")
    ):
        raise RuntimeError(
            author_mutation_worktree.format_durable_author_worktree_error(
                durable or {
                    "reasons": ctx.get("author_worktree_reasons")
                    or [author_mutation_worktree.BOUND_WORKTREE_MISSING_MESSAGE],
                    "workspace_path": workspace,
                    "workspace_binding_source": ctx.get("workspace_binding_source"),
                    "bound_worktree_missing": True,
                    "blocker_kind": author_mutation_worktree.BOUND_WORKTREE_MISSING,
                    "operator_recovery": ctx.get("operator_recovery"),
                    "configured_path": workspace,
                    "binding_source": ctx.get("workspace_binding_source"),
                    "profile_name": get_profile().get("profile_name"),
                }
            )
        )
    if durable.get("block") and not durable.get("bound_worktree_missing"):
        # Other durable failures (lock ownership, traversal, no binding while
        # on control). create_issue bootstrap may still permit clean control.
        import create_issue_bootstrap as _cib

        bootstrap = (
            _create_issue_bootstrap_assessment(task, worktree_path)
            if bootstrap_assessment is _BOOTSTRAP_UNSET
            else bootstrap_assessment
        )
        if _cib.bootstrap_permits_control_checkout(
            bootstrap,
            task=task,
            workspace_path=workspace,
            canonical_repo_root=ctx["canonical_repo_root"],
        ):
            return
        if (
            isinstance(bootstrap, dict)
            and bootstrap.get("block")
            and not bootstrap.get("not_applicable")
        ):
            raise RuntimeError(_cib.format_create_issue_bootstrap_error(bootstrap))
        raise RuntimeError(
            author_mutation_worktree.format_durable_author_worktree_error(durable)
        )

    git_state = issue_lock_worktree.read_worktree_git_state(workspace)
    assessment = author_mutation_worktree.assess_author_mutation_worktree(
        workspace_path=workspace,
        project_root=ctx["canonical_repo_root"],
        current_branch=git_state.get("current_branch"),
    )
    if not assessment["block"]:
        return

    # #749 create_issue bootstrap: narrow phase exemption only.
    # #757: consume the caller-computed assessment when one was threaded in, so
    # this guard and the later #604 anti-stomp guard judge identical evidence.
    # Falling back to computing it here preserves behaviour for callers that
    # supply none.
    import create_issue_bootstrap as _cib

    bootstrap = (
        _create_issue_bootstrap_assessment(task, worktree_path)
        if bootstrap_assessment is _BOOTSTRAP_UNSET
        else bootstrap_assessment
    )
    if _cib.bootstrap_permits_control_checkout(
        bootstrap,
        task=task,
        workspace_path=workspace,
        canonical_repo_root=ctx["canonical_repo_root"],
    ):
        return
    if (
        isinstance(bootstrap, dict)
        and bootstrap.get("block")
        and not bootstrap.get("not_applicable")
    ):
        raise RuntimeError(_cib.format_create_issue_bootstrap_error(bootstrap))
    raise RuntimeError(
        author_mutation_worktree.format_author_mutation_worktree_error(assessment)
    )


def _anti_stomp_in_test_mode() -> bool:
    """Whether the #604 anti-stomp gate is skipped under pytest.

    Mirrors remote-repo / stable-contamination test bypasses so the existing
    suite (bare remotes, mocked APIs) stays green. Set
    ``GITEA_TEST_FORCE_ANTI_STOMP=1`` to exercise the live gate under tests.
    """
    if not _preflight_in_test_mode():
        return False
    return not bool(os.environ.get("GITEA_TEST_FORCE_ANTI_STOMP"))


# Mutation tasks that carry an explicit cross-repository *target* contract and
# must NOT auto-resolve the workspace repository for omitted coordinates. Only
# ``delete_branch`` (#733) qualifies: a destructive raw-branch deletion names
# its org/repo explicitly and fails closed otherwise. Every other mutation is
# session-bound to the single workspace-aligned repository and resolves it from
# the trusted git remote (mirroring :func:`_resolve`).
_EXPLICIT_TARGET_MUTATION_TASKS = frozenset({"delete_branch"})


def _run_anti_stomp_preflight(
    task: str | None,
    *,
    remote: str | None = None,
    worktree_path: str | None = None,
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    expected_head_sha: str | None = None,
    live_head_sha: str | None = None,
    require_head_sha: bool = False,
    lease_required: bool = False,
    foreign_lease: bool | None = None,
    lease_reasons: list[str] | None = None,
    terminal_lock_blocks: bool | None = None,
    terminal_lock_reasons: list[str] | None = None,
    workflow_hash_valid: bool | None = None,
    workflow_hash_reasons: list[str] | None = None,
    raise_on_block: bool = True,
    bootstrap_assessment: Any = _BOOTSTRAP_UNSET,
) -> dict | None:
    """Shared #604 anti-stomp preflight for MCP mutation entrypoints.

    Gathers live workspace/parity/role/repo facts and invokes the pure
    :func:`anti_stomp_preflight.assess_anti_stomp_preflight` assessor. On
    block, raises ``RuntimeError`` (default) or returns a structured
    :func:`anti_stomp_preflight.block_response` when *raise_on_block* is
    False. Returns ``None`` when the mutation may proceed.
    """
    if _anti_stomp_in_test_mode():
        return None

    # Only enforce when the caller names a known mutation task. Read-only
    # paths and legacy verify_preflight_purity calls without a task stay
    # on the pre-#604 gate chain (whoami/capability/root/worktree).
    if not task or not anti_stomp_preflight.is_mutation_task(task):
        return None

    profile = get_profile()
    profile_name = profile.get("profile_name")
    profile_role = _actual_profile_role()
    allowed_operations = list(profile.get("allowed_operations") or [])
    req_role = None
    req_perm = None
    if task:
        try:
            req_role = task_capability_map.required_role(task)
        except KeyError:
            req_role = _preflight_resolved_role
        try:
            req_perm = task_capability_map.required_permission(task)
        except KeyError:
            req_perm = None

    ctx = _resolve_namespace_mutation_context(worktree_path)
    workspace = ctx["workspace_path"]
    canonical_root = ctx["canonical_repo_root"]
    git_state = issue_lock_worktree.read_worktree_git_state(canonical_root)
    remote_master_sha = root_checkout_guard.resolve_remote_master_sha(canonical_root)

    # Repo/org facts (best-effort; explicit org/repo when provided).
    resolved_org = org
    resolved_repo = repo
    local_remote_url = None
    org_explicit = org is not None
    repo_explicit = repo is not None
    filled_org = False
    filled_repo = False
    if remote:
        try:
            local_remote_url = _local_git_remote_url(remote)
        except Exception:
            local_remote_url = None
        if resolved_org is None or resolved_repo is None:
            # Session-bound mutations act on the single workspace-aligned
            # repository, so prefer the trusted git-remote-derived identity for
            # omitted coordinates — exactly as :func:`_resolve` does — instead
            # of the remote-wide ``REMOTES`` default *target*. A bare ``prgs``
            # default resolves to ``Timesheet`` and false-positived the #530
            # repo guard against a ``Gitea-Tools`` checkout, blocking native
            # issue/PR/review/merge/lock/cleanup mutations at preflight while
            # the actual resolve stage (``_resolve``) would have targeted the
            # workspace repo correctly. Workspace-filled sides are intentional
            # alignment and are marked explicit so the guard accepts them.
            # ``delete_branch`` keeps its explicit cross-repo target contract
            # (#733) and is excluded, so it still fails closed on omitted
            # coordinates. ``REMOTES`` stays a best-effort last resort only when
            # no workspace identity can be derived (preserves the dadeschools
            # default-target behavior and the existing unit suite).
            workspace_parts = None
            if task not in _EXPLICIT_TARGET_MUTATION_TASKS:
                try:
                    workspace_parts = (
                        remote_repo_guard.parse_org_repo_from_remote_url(
                            local_remote_url
                        )
                    )
                except Exception:
                    workspace_parts = None
            if workspace_parts:
                if resolved_org is None:
                    resolved_org = workspace_parts[0]
                    filled_org = True
                if resolved_repo is None:
                    resolved_repo = workspace_parts[1]
                    filled_repo = True
            if resolved_org is None or resolved_repo is None:
                try:
                    rem = _effective_remote(remote)
                    if rem in REMOTES:
                        if resolved_org is None:
                            resolved_org = REMOTES[rem]["org"]
                        if resolved_repo is None:
                            resolved_repo = REMOTES[rem]["repo"]
                except Exception:
                    pass

    parity = _current_master_parity()
    startup_head = parity.get("startup_head")
    current_code_head = parity.get("current_head")

    # Source contamination from durable #671 marker (role-aware).
    source_contaminated = None
    contamination_reasons = None
    try:
        marker = _load_stable_contamination_marker(remote)
        contaminated, cont_reasons = anti_stomp_preflight.contamination_from_stable_marker(
            marker,
            task=task,
            actual_role=profile_role,
        )
        if marker is not None:
            source_contaminated = contaminated
            contamination_reasons = cont_reasons
    except Exception:
        # Fail soft on marker I/O: existing #671 enforcer still runs separately.
        source_contaminated = None

    # Workflow-hash facts when the task is review/merge oriented.
    if workflow_hash_valid is None and task in {
        "acquire_reviewer_pr_lease",
        "gitea_acquire_reviewer_pr_lease",
        "review_pr",
        "submit_pr_review",
        "approve_pr",
        "request_changes_pr",
        "merge_pr",
    }:
        try:
            wf_reasons = _review_workflow_load_gate_reasons()
            workflow_hash_valid = not bool(wf_reasons)
            workflow_hash_reasons = list(wf_reasons) if wf_reasons else None
        except Exception:
            pass

    # Mirror remote_repo_guard's pytest bypass: under the unit suite bare
    # remote=prgs still defaults to Timesheet, and re-checking here would
    # break happy-path reconciler/author tests that already rely on the
    # #530 gate being opt-in via GITEA_FORCE_REMOTE_REPO_CHECK.
    check_repo = True
    if "pytest" in sys.modules and not os.environ.get(
        "GITEA_FORCE_REMOTE_REPO_CHECK"
    ):
        check_repo = False

    assessment = anti_stomp_preflight.assess_anti_stomp_preflight(
        task=task,
        remote=remote,
        resolved_org=resolved_org,
        resolved_repo=resolved_repo,
        local_remote_url=local_remote_url,
        # Workspace-filled sides are intentional alignment for #530 (same
        # contract as :func:`_resolve`), so treat them as explicit.
        org_explicit=org_explicit or filled_org,
        repo_explicit=repo_explicit or filled_repo,
        check_repo=check_repo,
        profile_name=profile_name,
        profile_role=profile_role,
        required_role=req_role or _preflight_resolved_role,
        required_permission=req_perm,
        allowed_operations=allowed_operations,
        workspace_path=workspace,
        project_root=canonical_root,
        current_branch=git_state.get("current_branch"),
        root_head_sha=git_state.get("head_sha"),
        root_porcelain=git_state.get("porcelain_status") or "",
        remote_master_sha=remote_master_sha,
        startup_head=startup_head,
        current_code_head=current_code_head,
        lease_required=lease_required,
        foreign_lease=foreign_lease,
        lease_reasons=lease_reasons,
        terminal_lock_blocks=terminal_lock_blocks,
        terminal_lock_reasons=terminal_lock_reasons,
        require_head_sha=require_head_sha,
        expected_head_sha=expected_head_sha,
        live_head_sha=live_head_sha,
        workflow_hash_valid=workflow_hash_valid,
        workflow_hash_reasons=workflow_hash_reasons,
        source_contaminated=source_contaminated,
        contamination_reasons=contamination_reasons,
        manual_bypass_attempted=False,
        # #757: same server-derived bootstrap decision the #274 guard consumed.
        # Computing it here when unsupplied keeps standalone callers consistent
        # rather than silently bootstrap-blind.
        create_issue_bootstrap_assessment=(
            _create_issue_bootstrap_assessment(task, worktree_path)
            if bootstrap_assessment is _BOOTSTRAP_UNSET
            else bootstrap_assessment
        ),
    )
    if not assessment.get("block"):
        return None
    if raise_on_block:
        raise RuntimeError(anti_stomp_preflight.format_anti_stomp_error(assessment))
    return anti_stomp_preflight.block_response(assessment)


def verify_preflight_purity(
    remote: str | None = None,
    worktree_path: str | None = None,
    task: str | None = None,
    *,
    target_issue_number: int | None = None,
    require_author_lock: bool = False,
    expected_head_sha: str | None = None,
    live_head_sha: str | None = None,
    require_head_sha: bool = False,
    lease_required: bool = False,
    foreign_lease: bool | None = None,
    lease_reasons: list[str] | None = None,
    terminal_lock_blocks: bool | None = None,
    terminal_lock_reasons: list[str] | None = None,
    workflow_hash_valid: bool | None = None,
    workflow_hash_reasons: list[str] | None = None,
    org: str | None = None,
    repo: str | None = None,
):
    """Verify identity/capability order, production workspace guards, then anti-stomp.

    #683: pytest/unittest must not skip production root/branches/scope
    enforcement when force-on signals request production behavior. The
    early return below only skips *preflight-order* purity checks under
    pure unit-test isolation — never when production guards are active.

    #604: also runs the shared anti-stomp preflight for mutation tasks (typed
    blocker + exact next action). Extended lease/head/terminal facts may be
    supplied by review/merge callers.
    """
    global _preflight_reviewer_violation_files

    in_test = _preflight_in_test_mode()
    production_active = workflow_scope_guard.production_guards_active(
        in_test_mode=in_test
    )
    # Pure unit-test isolation: skip purity-order unless legacy dirty/porcelain
    # force flags request the dirtiness path. #683 FORCE_PRODUCTION_GUARDS alone
    # runs production root/branches/scope without requiring whoami/capability.
    force_anti_stomp = bool(os.environ.get("GITEA_TEST_FORCE_ANTI_STOMP"))
    skip_purity_order = (
        in_test
        and not workflow_scope_guard.purity_order_forced()
        and not force_anti_stomp
    )

    if not skip_purity_order:
        if not _preflight_whoami_called:
            raise _PreflightOrderError(
                "Pre-flight order violation: Identity (gitea_whoami) has not been verified (fail closed)"
            )
        if not _preflight_capability_called:
            raise _PreflightOrderError(
                "Pre-flight order violation: Task capability (gitea_resolve_task_capability) has not been resolved (fail closed)"
            )
        if (
            task is not None
            and _preflight_resolved_task is not None
            and not task_capability_map.preflight_task_matches(
                _preflight_resolved_task, task
            )
        ):
            raise _PreflightOrderError(
                "Pre-flight task mismatch: "
                f"resolved '{_preflight_resolved_task}' but mutation requires "
                f"'{task}' (fail closed)"
            )

        # #671: block review/merge/close/completion mutations while the session is
        # contaminated by a direct stable-branch push attempt (reconciler-exempt).
        _enforce_stable_branch_contamination_gate(task, remote)

        ctx = _resolve_namespace_mutation_context(worktree_path)
        workspace = ctx["workspace_path"]
        canonical_root = ctx["canonical_repo_root"]
        process_root = ctx["process_project_root"]
        real_workspace = os.path.realpath(workspace)
        role = ctx.get("workspace_role_kind") or _effective_workspace_role()

        if real_workspace != process_root:
            if not _preflight_in_test_mode():
                membership = author_mutation_worktree.assess_workspace_repo_membership(
                    workspace_path=workspace,
                    canonical_repo_root=canonical_root,
                )
                if membership["block"]:
                    raise RuntimeError(
                        author_mutation_worktree.format_workspace_repo_membership_error(
                            membership
                        )
                    )

            dirty_files = sorted(
                _parse_porcelain_entries(_get_workspace_porcelain(workspace))
            )
            if dirty_files:
                raise RuntimeError(
                    nwb.format_namespace_workspace_binding_error(
                        role_kind=role,
                        workspace_path=workspace,
                        binding_source=ctx.get("workspace_binding_source")
                        or "unknown binding source",
                        dirty_files=dirty_files,
                        ignored_bindings=ctx.get("ignored_bindings"),
                    )
                )
        else:
            if _preflight_whoami_violation:
                raise _PreflightOrderError(
                    "Pre-flight order violation: Workspace file edits occurred before "
                    f"gitea_whoami verification (fail closed). Offending files: "
                    f"{_format_preflight_files(_preflight_whoami_violation_files)}"
                )
            if _preflight_capability_violation:
                raise _PreflightOrderError(
                    "Pre-flight order violation: Workspace file edits occurred before "
                    f"gitea_resolve_task_capability verification (fail closed). Offending files: "
                    f"{_format_preflight_files(_preflight_capability_violation_files)}"
                )

            if role in {"reviewer", "merger"}:
                current = _get_workspace_porcelain()
                baseline = _preflight_capability_baseline_porcelain or ""
                reviewer_delta = _new_tracked_changes_since(baseline, current)
                _preflight_reviewer_violation_files = reviewer_delta
                if reviewer_delta:
                    raise RuntimeError(
                        f"{role.title()} role violation: profile is forbidden from modifying "
                        "tracked workspace files (fail closed). Offending files: "
                        f"{_format_preflight_files(reviewer_delta)}"
                    )

        # Historical path: root + branches after purity-order when dirty paths live.
        _enforce_canonical_repository_root(worktree_path, remote=remote)
        _enforce_root_checkout_guard(worktree_path)
        # #757: compute the create_issue bootstrap decision ONCE and hand the
        # same result to both the #274 and #604 guards, so they cannot disagree.
        bootstrap_assessment = _create_issue_bootstrap_assessment(task, worktree_path)
        _enforce_branches_only_author_mutation(
            worktree_path, task=task, bootstrap_assessment=bootstrap_assessment
        )
        _enforce_issue_scope_guard(
            worktree_path,
            task=task,
            target_issue_number=target_issue_number,
            require_author_lock=require_author_lock,
        )
        # #604: common anti-stomp preflight after legacy + #683 enforcers.
        _run_anti_stomp_preflight(
            task,
            bootstrap_assessment=bootstrap_assessment,
            remote=remote,
            worktree_path=worktree_path,
            org=org,
            repo=repo,
            expected_head_sha=expected_head_sha,
            live_head_sha=live_head_sha,
            require_head_sha=require_head_sha,
            lease_required=lease_required,
            foreign_lease=foreign_lease,
            lease_reasons=lease_reasons,
            terminal_lock_blocks=terminal_lock_blocks,
            terminal_lock_reasons=terminal_lock_reasons,
            workflow_hash_valid=workflow_hash_valid,
            workflow_hash_reasons=workflow_hash_reasons,
            raise_on_block=True,
        )
        _clear_preflight_capability_state()
        return

    # #683: under pytest unit isolation, FORCE_PRODUCTION_GUARDS still runs
    # production root + branches + issue scope (no silent no-op of guards).
    if production_active:
        _enforce_canonical_repository_root(worktree_path, remote=remote)
        _enforce_root_checkout_guard(worktree_path)
        # #757: one shared bootstrap decision for both guards (see above).
        bootstrap_assessment = _create_issue_bootstrap_assessment(task, worktree_path)
        _enforce_branches_only_author_mutation(
            worktree_path, task=task, bootstrap_assessment=bootstrap_assessment
        )
        _enforce_issue_scope_guard(
            worktree_path,
            task=task,
            target_issue_number=target_issue_number,
            require_author_lock=require_author_lock,
        )
        if force_anti_stomp:
            _run_anti_stomp_preflight(
                task,
                bootstrap_assessment=bootstrap_assessment,
                remote=remote,
                worktree_path=worktree_path,
                org=org,
                repo=repo,
                expected_head_sha=expected_head_sha,
                live_head_sha=live_head_sha,
                require_head_sha=require_head_sha,
                lease_required=lease_required,
                foreign_lease=foreign_lease,
                lease_reasons=lease_reasons,
                terminal_lock_blocks=terminal_lock_blocks,
                terminal_lock_reasons=terminal_lock_reasons,
                workflow_hash_valid=workflow_hash_valid,
                workflow_hash_reasons=workflow_hash_reasons,
                raise_on_block=True,
            )


def _session_issue_lock_snapshot(
    workspace_path: str | None = None,
) -> dict:
    """Return session lock fields relevant to #683 scope enforcement.

    Branch-vs-lock comparison uses the live workspace branch only when the
    lock's worktree matches the mutation workspace. That prevents a foreign
    or leftover session lock from poisoning unrelated test worktrees while
    still fail-closing when the bound worktree drifts to another issue.
    """
    lock = issue_lock_store.read_session_issue_lock() or {}
    raw = lock.get("issue_number")
    locked: int | None
    try:
        locked = int(raw) if raw is not None else None
    except (TypeError, ValueError):
        locked = None
    lock_wt = (lock.get("worktree_path") or "").strip()
    workspace = (workspace_path or "").strip()
    worktrees_match = False
    if lock_wt and workspace:
        try:
            worktrees_match = os.path.realpath(lock_wt) == os.path.realpath(workspace)
        except OSError:
            worktrees_match = False
    return {
        "locked_issue_number": locked,
        "lock_branch_name": (lock.get("branch_name") or "").strip() or None,
        "lock_worktree_path": lock_wt or None,
        "worktrees_match": worktrees_match,
    }


def _session_locked_issue_number() -> int | None:
    """Return the active session issue lock number when present (#683)."""
    return _session_issue_lock_snapshot().get("locked_issue_number")


def _enforce_issue_scope_guard(
    worktree_path: str | None = None,
    *,
    task: str | None = None,
    target_issue_number: int | None = None,
    require_author_lock: bool = False,
) -> None:
    """#683: fail closed on missing/out-of-scope issue ownership for mutations."""
    ctx = _resolve_namespace_mutation_context(worktree_path)
    workspace = ctx["workspace_path"]
    git_state = issue_lock_worktree.read_worktree_git_state(workspace)
    # Honour actual profile role as well as poisoned task role (#540 / #683):
    # comment_issue preflight stamps required_role_kind=author, which must not
    # strip a genuine reconciler of control-checkout exemptions.
    role = ctx.get("workspace_role_kind") or _effective_workspace_role()
    actual = _actual_profile_role()
    if actual in nwb.NON_AUTHOR_ROLES:
        role = actual
    snap = _session_issue_lock_snapshot(workspace)
    # Scope uses the lock's recorded branch for issue-number matching.
    # Live workspace branch can inherit the parent control checkout's branch
    # name when a temp branches/ dir is not its own worktree tip — that must
    # not invent a false out-of-scope failure. Live branch drift is enforced
    # by issue_lock_store.verify_lock_for_mutation elsewhere.
    branch_for_scope = snap.get("lock_branch_name")
    if (
        snap.get("worktrees_match")
        and workflow_scope_guard.production_guards_forced()
    ):
        live_branch = git_state.get("current_branch")
        live_issue = workflow_scope_guard.extract_issue_number_from_branch(
            live_branch
        )
        locked = snap.get("locked_issue_number")
        if (
            live_issue is not None
            and locked is not None
            and live_issue != locked
        ):
            branch_for_scope = live_branch
    # Author implementation / source-adjacent mutations need ownership when forced.
    authorish = role == "author" or (
        task
        in {
            "create_issue",
            "comment_issue",
            "lock_issue",
            "create_pr",
            "commit_files",
            "gitea_commit_files",
            "mark_issue",
        }
    )
    # #749: create_issue is pre-ownership (no issue number / lock can exist yet).
    import create_issue_bootstrap as _cib

    is_create_issue = _cib.is_create_issue_task(task)
    require_lock = bool(require_author_lock) or (
        authorish
        and workflow_scope_guard.production_guards_forced()
        and role == "author"
        and not is_create_issue
    )
    assessment = workflow_scope_guard.assess_production_mutation_guards(
        workspace_path=workspace,
        canonical_repo_root=ctx["canonical_repo_root"],
        porcelain_status=git_state.get("porcelain_status") or "",
        current_branch=branch_for_scope,
        locked_issue_number=snap.get("locked_issue_number"),
        target_issue_number=target_issue_number,
        role_kind=role,
        require_author_lock=require_lock,
        in_test_mode=_preflight_in_test_mode(),
        mutation_task=task,
    )
    workflow_scope_guard.raise_if_blocked(assessment)


def _production_guard_block_from_exc(exc: BaseException, **extra) -> dict | None:
    """Map production-guard exceptions to typed tool block responses (#683)."""
    if isinstance(exc, workflow_scope_guard.ProductionGuardError):
        return workflow_scope_guard.block_response(exc, **extra)
    text = str(exc)
    if "Workflow scope guard (#683)" in text or "Root checkout guard (#475)" in text:
        kind = workflow_scope_guard.BLOCKER_PRODUCTION_GUARD
        if "root_diagnostic_edit" in text or "tracked source or test edits" in text:
            kind = workflow_scope_guard.BLOCKER_ROOT_DIAGNOSTIC_EDIT
        elif (
            "Branches-only mutation guard" in text
            or "stable control checkout" in text
            or "Create-issue bootstrap guard (#749)" in text
        ):
            kind = workflow_scope_guard.BLOCKER_MISSING_WORKTREE
        elif "out-of-scope" in text or "locked to issue" in text:
            kind = workflow_scope_guard.BLOCKER_OUT_OF_SCOPE_ISSUE
        elif "no owning issue" in text:
            kind = workflow_scope_guard.BLOCKER_MISSING_ISSUE_SCOPE
        return workflow_scope_guard.block_response(
            blocker_kind=kind,
            reasons=[text],
            **extra,
        )
    if "Branches-only mutation guard" in text or "Create-issue bootstrap guard (#749)" in text:
        # Prefer bootstrap next-action text when present (#749 AC4).
        next_action = None
        if "exact_next_action:" in text:
            next_action = text.split("exact_next_action:", 1)[1].strip()
        return workflow_scope_guard.block_response(
            blocker_kind=workflow_scope_guard.BLOCKER_MISSING_WORKTREE,
            reasons=[text],
            exact_next_action=next_action,
            **extra,
        )
    if "Root checkout guard" in text:
        return workflow_scope_guard.block_response(
            blocker_kind=workflow_scope_guard.BLOCKER_ROOT_DIAGNOSTIC_EDIT,
            reasons=[text],
            **extra,
        )
    return None


def _verify_role_mutation_workspace(
    remote: str | None = None,
    *,
    worktree_path: str | None = None,
    worktree: str | None = None,
    task: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> str:
    """Bind reviewer/merger mutations to the active namespace workspace (#510).

    #683: must NOT early-return solely because pytest/unittest is loaded.
    Production workspace binding always runs; test isolation uses explicit
    env fixtures / force-on flags, never a production short-circuit here.

    org/repo are forwarded into anti-stomp (#604) so callers that pass explicit
    repository targets (e.g. prgs + Gitea-Tools when REMOTES defaults to
    Timesheet) do not fail closed with wrong_repo after a successful resolve.
    """

    # Check running runtimes to prevent stale mutations
    try:
        if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ:
            config = gitea_config.load_config()
            required_permission = task_capability_map.required_permission(task) if task else None
            matching_profiles = []
            if required_permission and config and "profiles" in config:
                for p_name, p_data in config["profiles"].items():
                    p_allowed = p_data.get("allowed_operations") or []
                    p_forbidden = p_data.get("forbidden_operations") or []
                    p_allowed_n = []
                    for op in p_allowed:
                        try:
                            p_allowed_n.append(gitea_config.normalize_operation(op))
                        except Exception:
                            pass
                    p_forbidden_n = []
                    for op in p_forbidden:
                        try:
                            p_forbidden_n.append(gitea_config.normalize_operation(op))
                        except Exception:
                            pass
                    ok, _ = gitea_config.check_operation(required_permission, p_allowed_n, p_forbidden_n)
                    if ok:
                        matching_profiles.append(p_name)
            runtime_reasons = _check_mcp_runtimes_diagnostics(task or "unknown", matching_profiles)
            if runtime_reasons:
                raise RuntimeError("; ".join(runtime_reasons))
    except Exception as exc:
        if "stale-runtime:" in str(exc):
            raise RuntimeError(str(exc))
        pass

    role = _effective_workspace_role()
    git_state = issue_lock_worktree.read_worktree_git_state(
        _resolve_preflight_workspace_path(worktree_path)
    )
    # #741: thread the configured canonical root exactly as
    # _resolve_namespace_mutation_context does. Omitting it here made the two
    # paths disagree: the #274 branches-only / worktree-membership guards fell
    # back to the install checkout and validated Gitea-Tools/branches/ instead
    # of the target repository the namespace is actually bound to.
    _configured_root, _configured_source = _configured_canonical_root()
    assessment = nwb.assess_namespace_mutation_workspace(
        role_kind=role,
        worktree_path=worktree_path,
        worktree=worktree,
        process_project_root=PROJECT_ROOT,
        session_lease_worktree=(
            _reviewer_session_worktree() if role in {"reviewer", "merger"} else None
        ),
        session_lock_worktree=(
            _session_author_lock_worktree() if role == "author" else None
        ),
        profile_name=get_profile().get("profile_name"),
        current_branch=git_state.get("current_branch"),
        configured_canonical_root=_configured_root,
    )
    if assessment["block"]:
        raise RuntimeError(
            nwb.format_namespace_workspace_binding_error(
                role_kind=role,
                workspace_path=assessment["mutation_workspace"],
                binding_source=assessment.get("workspace_binding_source")
                or "unknown binding source",
                reasons=assessment.get("reasons"),
                ignored_bindings=assessment.get("ignored_bindings"),
                operator_recovery=assessment.get("operator_recovery"),
            )
        )
    resolved = assessment["mutation_workspace"]
    verify_preflight_purity(
        remote,
        worktree_path=resolved,
        task=task,
        org=org,
        repo=repo,
    )
    return resolved


def _enforce_root_checkout_guard(worktree_path: str | None = None) -> None:
    """#475: fail closed when the stable control checkout is contaminated."""
    ctx = _resolve_author_mutation_context(worktree_path)
    canonical_root = ctx["canonical_repo_root"]
    workspace = ctx["workspace_path"]
    git_state = issue_lock_worktree.read_worktree_git_state(canonical_root)
    remote_master_sha = root_checkout_guard.resolve_remote_master_sha(canonical_root)
    assessment = root_checkout_guard.assess_root_checkout_guard(
        workspace_path=workspace,
        canonical_repo_root=canonical_root,
        current_branch=git_state.get("current_branch"),
        head_sha=git_state.get("head_sha"),
        porcelain_status=git_state.get("porcelain_status") or "",
        remote_master_sha=remote_master_sha,
        resolved_role=_preflight_resolved_role,
        actual_role=_actual_profile_role(),
    )
    if assessment["block"]:
        raise RuntimeError(root_checkout_guard.format_root_checkout_guard_error(assessment))


# ── stable-branch push contamination (#671) ──────────────────────────────────
# A worker session that attempts a direct stable-branch push (or a
# root-checkout local commit) is workflow-contaminated. The marker is durable
# (survives daemon process pools like the other session proofs) and keyed per
# profile identity. It fails closed on gated mutations until a reconciler
# audits and clears it.

def _stable_contamination_profile_identity() -> str:
    return mcp_session_state.current_profile_identity(
        profile_name=get_profile().get("profile_name"),
    )


def _load_stable_contamination_marker(remote: str | None = None) -> dict | None:
    return mcp_session_state.load_state(
        kind=mcp_session_state.KIND_STABLE_BRANCH_CONTAMINATION,
        remote=remote,
        profile_identity=_stable_contamination_profile_identity(),
    )


def _save_stable_contamination_marker(
    record: dict,
    *,
    remote: str | None = None,
) -> dict | None:
    return mcp_session_state.save_state(
        kind=mcp_session_state.KIND_STABLE_BRANCH_CONTAMINATION,
        payload=record,
        remote=remote,
        profile_identity=_stable_contamination_profile_identity(),
    )


def _clear_stable_contamination_marker(
    *,
    remote: str | None = None,
    profile_identity: str | None = None,
) -> None:
    mcp_session_state.clear_state(
        kind=mcp_session_state.KIND_STABLE_BRANCH_CONTAMINATION,
        remote=remote,
        profile_identity=profile_identity or _stable_contamination_profile_identity(),
    )


def _enforce_stable_branch_contamination_gate(
    task: str | None,
    remote: str | None = None,
) -> None:
    """#671 AC4: fail closed on gated mutations while contaminated.

    Reconciler role is exempt (the sanctioned audit/clear path). Non-gated
    tasks (comment_issue, lock_issue) stay allowed so a contaminated worker can
    still post the durable audit comment and hand off.
    """
    if _preflight_in_test_mode() and not os.environ.get(
        "GITEA_TEST_FORCE_STABLE_CONTAMINATION"
    ):
        return
    marker = _load_stable_contamination_marker(remote)
    if not marker:
        return
    gate = stable_branch_push_guard.assess_contamination_gate(
        marker,
        task=task,
        actual_role=_actual_profile_role(),
    )
    if gate["block"]:
        raise RuntimeError(
            stable_branch_push_guard.format_contamination_gate_error(gate)
        )


from mcp.server.fastmcp import FastMCP  # noqa: E402

from gitea_auth import (  # noqa: E402
    REMOTES,
    get_credentials,
    get_auth_header,
    api_request,
    api_get_all,
    api_fetch_page,
    repo_api_url,
    get_profile,
    gitea_url,
    GiteaConfigError,
)
import mcp_tool_error_boundary  # noqa: E402
import gitea_audit  # noqa: E402
import gitea_config  # noqa: E402
import capability_stop_terminal  # noqa: E402
import issue_duplicate_gate  # noqa: E402
import issue_content_gate  # noqa: E402
import role_session_router  # noqa: E402
import role_namespace_gate  # noqa: E402
import task_capability_map  # noqa: E402
import review_proofs  # noqa: E402
import review_workflow_boundary  # noqa: E402
import review_workflow_load  # noqa: E402
import mcp_session_state  # noqa: E402
import stale_review_decision_lock  # noqa: E402
import allocator_service  # noqa: E402
import allocator_dependencies  # noqa: E402
import control_plane_db  # noqa: E402
import lease_lifecycle  # noqa: E402
import workflow_dashboard  # noqa: E402  # #605 live queue/lease dashboard
import incident_bridge  # noqa: E402
import sentry_observability  # noqa: E402  (#606 optional Sentry observability)
import sentry_incident_bridge  # noqa: E402  (#607 Sentry→Gitea incident bridge)
import agent_temp_artifacts
import issue_lock_worktree  # noqa: E402
import issue_lock_provenance  # noqa: E402
import issue_lock_store  # noqa: E402
import issue_lock_adoption  # noqa: E402
import issue_lock_recovery  # noqa: E402
import stacked_pr_support  # noqa: E402
import merge_approval_gate  # noqa: E402
import review_quarantine  # noqa: E402  # #695 contaminated formal-review quarantine
import mcp_daemon_guard  # noqa: E402  # #695 native transport provenance
import already_landed_reconcile  # noqa: E402
import author_mutation_worktree  # noqa: E402
import root_checkout_guard  # noqa: E402
import workflow_scope_guard  # noqa: E402  # #683 production scope / force-on guards
import stable_branch_push_guard  # noqa: E402
import remote_repo_guard  # noqa: E402
import anti_stomp_preflight  # noqa: E402
import issue_claim_heartbeat  # noqa: E402
import session_context_binding as session_ctx  # noqa: E402  # #714 immutable session context


def _workspace_repository_slug(remote: str | None) -> str | None:
    """Trusted ``owner/repository`` derived from the workspace git remote (#714).

    This is the only source the session binding accepts for repository identity:
    it comes from the verified, workspace-aligned checkout rather than from any
    caller-supplied tool argument. ``REMOTES`` is deliberately not consulted —
    its ``org``/``repo`` entries are default *targets*, not an authorization
    scope (#530).
    """
    if not remote:
        return None
    parsed = remote_repo_guard.parse_org_repo_from_remote_url(
        _local_git_remote_url(remote)
    )
    if not parsed:
        return None
    return session_ctx.format_repository_slug(parsed[0], parsed[1])


def _canonical_repository_slug(
    profile: dict,
    remote: str | None,
) -> tuple[str | None, list[str]]:
    """Repository identity of the *configured* canonical root, if any (#739 F2).

    Returns ``(slug, reasons)``. A namespace with no configured
    ``canonical_repository_root`` yields ``(None, [])`` so callers keep the
    install-derived single-repository default. A configured root that cannot be
    resolved to a repository identity yields ``(None, reasons)`` — a fail-closed
    signal, never a silent fallback to the installation checkout, because that
    fallback is exactly what lets a cross-repository namespace validate against
    the wrong repository.

    Env-over-profile precedence and the existence / git-toplevel / resolvable
    remote-identity validation all live in ``crr``.
    """
    configured_value, configured_source = crr.configured_canonical_root(
        profile, os.environ
    )
    if not configured_value:
        return None, []
    assessment = crr.assess_canonical_repository_root(
        configured_value=configured_value,
        source=configured_source,
        expected_slug=None,
        process_project_root=PROJECT_ROOT,
        remote=remote,
        require_binding=True,
    )
    slug = assessment.get("resolved_slug")
    if assessment.get("block") or not slug:
        return None, list(assessment.get("reasons") or [
            "configured canonical repository root has no resolvable "
            "repository identity (fail closed)"
        ])
    return slug, []


def _trusted_session_repository(
    profile: dict,
    remote: str | None,
    *,
    for_mutation: bool = False,
) -> dict:
    """Resolve and authorize the session repository from the verified workspace.

    Returns ``org`` / ``repository`` / ``reasons``. A profile's
    ``allowed_repositories`` is an authorization boundary: the verified
    workspace selects exactly one entry from it, and the session never binds to
    the list itself nor switches between entries.

    *for_mutation*: require a non-empty configured allowlist and a complete
    workspace-derived identity (fail closed; no self-authorization).
    """
    reasons: list[str] = []
    try:
        allowed = session_ctx.declared_allowed_repositories(
            profile, strict=for_mutation
        )
    except ValueError as exc:
        return {
            "org": None,
            "repository": None,
            "reasons": [str(exc)],
        }
    # #706 F1: when a cross-repository canonical root is configured, the session
    # repository identity must be derived from that validated *target* repository,
    # not from the install-checkout git remote. ``_workspace_repository_slug``
    # reads ``_local_git_remote_url`` in ``PROJECT_ROOT`` (always Gitea-Tools), so
    # without this a cross-repo namespace pinned Gitea-Tools while #274 filesystem
    # membership bound the external root, and ``_enforce_canonical_repository_root``
    # then failed closed on a self-inflicted identity mismatch. The derived
    # identity is still authorized by the profile allowlist below (never
    # self-authorizing). Env-over-profile precedence and fail-closed validation
    # (existence, git toplevel, resolvable remote identity) are handled by
    # ``crr``; unconfigured single-repo namespaces keep the install-derived slug.
    canonical_slug, canonical_reasons = _canonical_repository_slug(profile, remote)
    if canonical_reasons:
        return {"org": None, "repository": None, "reasons": canonical_reasons}
    slug = canonical_slug or _workspace_repository_slug(remote)
    scope = session_ctx.assess_repository_scope(
        workspace_slug=slug,
        allowed=allowed,
        profile_name=profile.get("profile_name"),
        require_scope=for_mutation,
    )
    if scope.get("block"):
        return {"org": None, "repository": None, "reasons": list(scope["reasons"])}
    parts = session_ctx.parse_repository_slug(slug) if slug else None
    if not parts:
        if for_mutation:
            reasons.append(
                "mutation denied: no verified workspace repository identity "
                "could be established (fail closed)"
            )
            return {"org": None, "repository": None, "reasons": reasons}
        return {"org": None, "repository": None, "reasons": []}
    return {"org": parts[0], "repository": parts[1], "reasons": []}


def _seed_session_context(
    *,
    profile: dict,
    remote: str | None,
    host: str | None,
    identity: str | None,
    source: str = "seed",
) -> dict:
    """Seed immutable session context once for the current process (#714).

    Repository and organization are always derived from the verified workspace,
    never from caller arguments, so every first-bind entry point (whoami,
    runtime context, capability preflight, activation, mutation gate) pins the
    same complete context.
    """
    expected = (profile.get("username") or "").strip() or None
    trusted = _trusted_session_repository(profile, remote)
    # #706: pin the configured cross-repository canonical root immutably so a
    # later call cannot forge/swap it. Store the resolved toplevel so drift
    # checks compare against the same value the mutation gate validates.
    configured_value, _crr_source = crr.configured_canonical_root(
        profile, os.environ
    )
    canonical_root_pin = None
    if configured_value:
        canonical_root_pin = (
            crr.resolve_repo_toplevel(configured_value)
            or os.path.realpath(configured_value)
        )
    return session_ctx.seed_session_context_if_unbound(
        profile_name=profile.get("profile_name") or "",
        remote=remote,
        host=host,
        identity=identity,
        repository=trusted["repository"],
        org=trusted["org"],
        role_kind=_profile_role_kind(profile),
        expected_username=expected,
        source=source,
        canonical_repository_root=canonical_root_pin,
    )
import issue_work_duplicate_gate  # noqa: E402
import issue_workflow_labels  # noqa: E402
import reviewer_pr_lease  # noqa: E402
import post_merge_moot_lease_gate  # noqa: E402  # #745 reconciler cleanup gate
import merger_lease_adoption  # noqa: E402
import merged_cleanup_reconcile  # noqa: E402
import worktree_cleanup_audit  # noqa: E402
import reconciler_profile  # noqa: E402
import reconciliation_workflow  # noqa: E402
import audit_reconciliation_mode  # noqa: E402
import review_merge_state_machine  # noqa: E402
import pr_work_lease  # noqa: E402
import workflow_skill  # noqa: E402
import conflict_fix_classification  # noqa: E402
import pr_sync_status  # noqa: E402  # PR sync / update-by-merge lifecycle
import native_mcp_preference  # noqa: E402
import branch_cleanup_guard  # noqa: E402
import thread_state_ledger_validator  # noqa: E402
import master_parity_gate  # noqa: E402
import stable_control_runtime  # noqa: E402

# Master-parity baseline (#420): the commit this server process started at.
# Captured once so that, at mutation time, we can detect when the on-disk
# master has advanced past the running code and fail closed until restart.
# Read-only operations are never blocked by staleness.
_STARTUP_PARITY = master_parity_gate.capture_startup_parity(PROJECT_ROOT)

# Stable-control runtime facts (#615): which runtime this process serves from.
# These are the *immutable* facts -- process root, branch, head, checkout-ness --
# and they are captured at import exactly like the #420 parity baseline, because
# the runtime a process serves from is fixed when it loads its code.
#
# Dirty state and the session's workspace binding are deliberately NOT captured
# here. Both change during a process lifetime, so freezing them would retire the
# acceptance-criterion-7 dirty-runtime blocker after the first snapshot and let
# whichever session happened to call first decide alignment for every session
# after it. _current_runtime_mode_report() re-evaluates them per call.
#
# Whether the gate enforces at all is decided from how the process was loaded --
# a suite running from a branches/ worktree is dev-test by design, and per-test
# production simulation must not switch this gate on.
_STARTUP_RUNTIME_FACTS = stable_control_runtime.observe_runtime(PROJECT_ROOT)
_RUNTIME_MODE_GATE_UNDER_TEST = "pytest" in sys.modules or "unittest" in sys.modules
import worktree_cleanup_audit  # noqa: E402
import canonical_comment_validator as ccv  # noqa: E402


# Keyed issue-lock storage (#443): per remote/org/repo/issue files under
# GITEA_ISSUE_LOCK_DIR, bound to the current MCP session via a per-PID pointer.
# Legacy global path retained only for test/doc references — do not seed manually.
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
WORK_LEASE_TTL_HOURS = 4
AUTHOR_ISSUE_WORK_LEASE = "author_issue_work"
VALID_WORK_LEASE_OPERATIONS = frozenset({
    AUTHOR_ISSUE_WORK_LEASE,
    "review_pr_work",
    "fix_pr_changes",
    "cleanup_branch_work",
    "issue_filing_work",
    "recovery_work",
})


def _work_lease_now() -> datetime:
    return datetime.now(timezone.utc)


def _work_lease_timestamp(value: datetime) -> str:
    return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")


def _parse_work_lease_timestamp(value: str | None) -> datetime | None:
    text = (value or "").strip()
    if not text:
        return None
    try:
        return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc)
    except ValueError:
        return None


def _load_existing_issue_lock(
    *,
    remote: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    issue_number: int | None = None,
) -> dict | None:
    if remote and org and repo and issue_number is not None:
        return issue_lock_store.load_issue_lock(
            remote=remote,
            org=org,
            repo=repo,
            issue_number=issue_number,
        )
    return issue_lock_store.read_session_issue_lock()


def _resolve_issue_lock_for_pr(
    *,
    remote: str,
    org: str,
    repo: str,
    head: str,
) -> dict:
    lock_data = issue_lock_store.read_session_issue_lock()
    if not lock_data:
        lock_data = issue_lock_store.find_lock_for_branch(
            remote=remote,
            org=org,
            repo=repo,
            branch_name=head,
        )
    if not lock_data:
        raise RuntimeError(
            "Issue lock is missing (fail closed). Call gitea_lock_issue first."
        )
    return lock_data


def _save_issue_lock(data: dict, *, expected_generation: int | None = None) -> str:
    existing = issue_lock_store.load_issue_lock(
        remote=str(data.get("remote") or ""),
        org=str(data.get("org") or ""),
        repo=str(data.get("repo") or ""),
        issue_number=int(data.get("issue_number") or 0),
    )
    overwrite_block = issue_lock_store.assess_foreign_lock_overwrite(existing, data)
    if overwrite_block:
        raise RuntimeError(overwrite_block)
    try:
        return issue_lock_store.bind_session_lock(
            data, expected_generation=expected_generation
        )
    except Exception as e:
        raise RuntimeError(f"Could not write issue lock file: {e}") from e


def _evaluate_issue_lock_recovery(
    existing_lock: dict,
    *,
    issue_number: int,
    branch_name: str,
    worktree_path: str,
    remote: str,
    h: str | None,
    o: str,
    r: str,
    git_state: dict,
) -> dict:
    """Gather recovery evidence and decide the disposition (#753/#768/#772).

    The single decision point for dead-session issue-lock recovery. The mutating
    ``gitea_lock_issue`` path and the read-only diagnostic assessor both call
    this, so the two cannot report different verdicts or different supporting
    evidence for the same durable state (#772 AC9) — a divergence between them
    would itself be the defect.

    Every input is either durable lock state or a live server-side observation
    (Gitea branch/PR inventory, git in the declared worktree). Nothing is
    reachable from an MCP caller's parameters (#772 AC1).
    """
    recovery_auth = _auth(h)
    try:
        recovery_branches = api_get_all(
            f"{repo_api_url(h, o, r)}/branches", recovery_auth
        )
    except Exception as exc:
        raise RuntimeError(
            f"Could not list branches to verify issue-lock recovery: {exc}"
        )
    remote_head: str | None = None
    remote_branch_exists = False
    candidates: list[str] = []
    for entry in recovery_branches:
        entry_name = _branch_entry_name(entry)
        if entry_name == branch_name:
            remote_branch_exists = True
            remote_head = _branch_entry_commit_sha(entry)
        if issue_lock_adoption.branch_carries_issue_marker(entry_name, issue_number):
            candidates.append(entry_name)

    pr_head: str | None = None
    pr_number: int | None = None
    for pull in _list_open_pulls(h, o, r, recovery_auth):
        pull_head = pull.get("head") or {}
        if str(pull_head.get("ref") or "") == branch_name:
            pr_head = pull_head.get("sha")
            pr_number = pull.get("number")
            break

    local_head = git_state.get("head_sha")

    # #768: the recovering author's clean worktree is, by construction, one
    # commit ahead of the head recorded at lock time — committing is the only
    # sanctioned way to clean it without discarding the work. Observe the
    # ancestry here, server-side, so the assessor can tell an honest
    # fast-forward from a rewritten head. Probed only when the heads actually
    # differ, and never from any caller-supplied value.
    head_ancestry: dict | None = None
    if remote_head and local_head and remote_head != local_head:
        head_ancestry = issue_lock_worktree.read_head_ancestry(
            worktree_path,
            ancestor_sha=remote_head,
            descendant_sha=local_head,
        )

    # #772: with no remote branch there is no head to measure against, so the
    # base the branch was cut from is observed instead. Probed only in that
    # case, so the published path's evidence is untouched (#772 AC8).
    recorded_base: str | None = None
    base_ancestry: dict | None = None
    if not remote_branch_exists and local_head:
        base_observation = issue_lock_worktree.read_recorded_base(
            worktree_path,
            head_sha=local_head,
        )
        recorded_base = base_observation.get("base_sha")
        if recorded_base:
            base_ancestry = issue_lock_worktree.read_head_ancestry(
                worktree_path,
                ancestor_sha=recorded_base,
                descendant_sha=local_head,
            )

    claimant = _work_lease_claimant(h)
    return issue_lock_recovery.assess_dead_session_lock_recovery(
        existing_lock,
        issue_number=issue_number,
        branch_name=branch_name,
        worktree_path=worktree_path,
        remote=remote,
        org=o,
        repo=r,
        identity=claimant.get("username"),
        profile=claimant.get("profile"),
        current_branch=git_state.get("current_branch"),
        porcelain_status=git_state.get("porcelain_status") or "",
        head_sha=local_head,
        remote_head_sha=remote_head,
        pr_head_sha=pr_head,
        pr_number=pr_number,
        competing_live_locks=issue_lock_store.list_live_locks(),
        candidate_branches=candidates,
        current_pid=os.getpid(),
        head_ancestry=head_ancestry,
        remote_branch_exists=remote_branch_exists,
        recorded_base_sha=recorded_base,
        base_ancestry=base_ancestry,
    )


def _work_lease_claimant(host: str | None) -> dict:
    profile = get_profile()
    username = _IDENTITY_CACHE.get(host) if host else None
    if username is None and host and not _preflight_in_test_mode():
        username = _authenticated_username(host)
    return {
        "username": username,
        "profile": profile.get("profile_name"),
    }


def _build_author_issue_work_lease(
    *,
    issue_number: int,
    branch_name: str,
    worktree_path: str,
    host: str | None,
) -> dict:
    created = _work_lease_now()
    expires = created + timedelta(hours=WORK_LEASE_TTL_HOURS)
    return {
        "operation_type": AUTHOR_ISSUE_WORK_LEASE,
        "issue_number": issue_number,
        "pr_number": None,
        "branch": branch_name,
        "worktree_path": worktree_path,
        "claimant": _work_lease_claimant(host),
        "created_at": _work_lease_timestamp(created),
        "expires_at": _work_lease_timestamp(expires),
        "last_heartbeat_at": _work_lease_timestamp(created),
    }


def _active_work_lease_block(
    existing_lock: dict | None,
    *,
    issue_number: int,
    branch_name: str,
    worktree_path: str,
    operation_type: str,
) -> str | None:
    if not existing_lock:
        return None
    existing_issue = existing_lock.get("issue_number")
    existing_branch = existing_lock.get("branch_name")
    existing_worktree = existing_lock.get("worktree_path")
    lease = existing_lock.get("work_lease")
    existing_operation = (
        lease.get("operation_type")
        if isinstance(lease, dict)
        else AUTHOR_ISSUE_WORK_LEASE
    )
    if existing_issue != issue_number or existing_operation != operation_type:
        return None

    same_owner = (
        existing_branch == branch_name
        and os.path.realpath(str(existing_worktree or "")) == os.path.realpath(worktree_path)
    )
    expires_at = (
        _parse_work_lease_timestamp(lease.get("expires_at"))
        if isinstance(lease, dict)
        else None
    )
    if expires_at and expires_at <= _work_lease_now():
        return (
            f"Issue #{issue_number} has an expired {operation_type} lease on "
            f"branch '{existing_branch}' from worktree '{existing_worktree}'. "
            "Recovery review is required before takeover (fail closed)"
        )
    if same_owner:
        return None
    return (
        f"Issue #{issue_number} already has an active {operation_type} lease on "
        f"branch '{existing_branch}' from worktree '{existing_worktree}' "
        "(fail closed)"
    )


def _branch_entry_name(branch: dict | str) -> str:
    if isinstance(branch, str):
        return branch
    if not isinstance(branch, dict):
        return ""
    return str(branch.get("name") or branch.get("ref") or "")


def _live_fetch_issue_duplicate_context(
    h: str,
    o: str,
    r: str,
    auth: str,
    issue_number: int,
) -> tuple[list[dict], list[str], dict]:
    """Live open PRs, remote branch names, and claim state for one issue."""
    base = repo_api_url(h, o, r)
    open_prs = api_get_all(f"{base}/pulls?state=open", auth)
    branches = api_get_all(f"{base}/branches", auth)
    branch_names = [_branch_entry_name(b) for b in branches]
    issue = api_request("GET", f"{base}/issues/{issue_number}", auth) or {}
    comments = api_request(
        "GET", f"{base}/issues/{issue_number}/comments", auth
    ) or []
    claim_entry = issue_claim_heartbeat.classify_issue_claim(
        issue=issue,
        comments=comments,
        open_prs=open_prs,
        branch_names=branch_names,
    )
    return open_prs, branch_names, claim_entry


# Injectable duplicate-work context fetcher (#400). Production uses the live
# Gitea API path above; unit tests patch this symbol instead of hitting the
# network.
issue_duplicate_context_fetcher = _live_fetch_issue_duplicate_context


def _collect_issue_duplicate_context(
    h: str,
    o: str,
    r: str,
    auth: str,
    issue_number: int,
) -> tuple[list[dict], list[str], dict]:
    return issue_duplicate_context_fetcher(h, o, r, auth, issue_number)


def _assess_issue_duplicate_gate(
    issue_number: int,
    *,
    h: str,
    o: str,
    r: str,
    auth: str,
    locked_branch: str | None = None,
    phase: str,
    recovered_owning_pr: dict | None = None,
) -> dict:
    open_prs, branch_names, claim_entry = _collect_issue_duplicate_context(
        h, o, r, auth, issue_number
    )
    return issue_work_duplicate_gate.assess_work_issue_duplicate_gate(
        issue_number,
        open_prs=open_prs,
        branch_names=branch_names,
        claim_entry=claim_entry,
        locked_branch=locked_branch,
        phase=phase,
        recovered_owning_pr=recovered_owning_pr,
    )


def _duplicate_gate_block_response(gate: dict, **extra) -> dict:
    out = {
        "success": False,
        "performed": False,
        "reasons": list(gate.get("reasons") or []),
        "duplicate_gate": gate,
        "safe_next_action": gate.get("safe_next_action"),
    }
    out.update(extra)
    return out


def _enforce_locked_issue_duplicate_recheck(
    remote: str,
    phase: str,
    *,
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict | None:
    """Re-check duplicate-work gates for the locked issue (#400).

    #768 AC2: when the lock being re-checked carries a sanctioned dead-session
    recovery, carry that server-derived owning-PR evidence into the gate. The
    commit and create-PR phases run in their own calls, long after the recovery
    assessment ended, so without this they re-block the very PR the recovery
    already proved belongs to this author.
    """
    lock_data = _load_existing_issue_lock()
    if not lock_data:
        return None
    issue_number = int(lock_data.get("issue_number") or 0)
    locked_branch = lock_data.get("branch_name")
    if not issue_number:
        return None
    h, o, r = _resolve(
        remote or lock_data.get("remote") or "dadeschools",
        host or lock_data.get("host"),
        org or lock_data.get("org"),
        repo or lock_data.get("repo"),
    )
    auth = _auth(h)
    gate = _assess_issue_duplicate_gate(
        issue_number,
        h=h,
        o=o,
        r=r,
        auth=auth,
        locked_branch=locked_branch,
        phase=phase,
        recovered_owning_pr=issue_lock_recovery.recovered_owning_pr_from_lock(
            lock_data
        ),
    )
    if gate.get("block"):
        return gate
    return None


def _branch_entry_commit_sha(branch: dict | str) -> str | None:
    """Best-effort head SHA for a Gitea branch entry (None when absent)."""
    if not isinstance(branch, dict):
        return None
    commit = branch.get("commit")
    if isinstance(commit, dict):
        sha = commit.get("id") or commit.get("sha")
        if sha:
            return str(sha)
    sha = branch.get("commit_sha")
    return str(sha) if sha else None


def _reveal_endpoints() -> bool:
    """Admin/debug opt-in (#120): include endpoint URLs and token source
    names in tool output. Off by default so normal LLM-facing responses
    expose only logical names and status. Never affects token values, which
    are excluded on every path."""
    return (os.environ.get("GITEA_MCP_REVEAL_ENDPOINTS") or "").strip().lower() \
        in ("1", "true", "yes")


def _with_optional_url(result: dict, url: str | None) -> dict:
    """Attach web links only under the explicit endpoint reveal opt-in.

    Mutates *result* in place and returns it; pass a freshly-built dict,
    never a shared/aliased one.
    """
    if _reveal_endpoints() and url:
        result["url"] = url
    return result

# #699: known auth/authz/network/config failures → structured CallToolResult
# isError; stdio transport must survive (no unhandled raise / process exit).
from mcp.server.fastmcp.tools.base import Tool as _FastMCPTool  # noqa: E402

mcp_tool_error_boundary.install_tool_run_boundary(_FastMCPTool)

mcp = FastMCP("gitea-tools", instructions=(
    "Gitea issue tracker and PR management for dadeschools and prgs instances. "
    "Use the gitea_ prefixed tools to create issues, PRs, list issues, etc."
))


def extract_linked_issue_numbers(text: str | None, branch_name: str | None = None) -> list[int]:
    issues = set()
    if text:
        pattern = re.compile(r'(?i)(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|implement[s]?|implemented)\s+#(\d+)')
        issues.update(int(m) for m in pattern.findall(text))
    if branch_name:
        pattern = re.compile(r'(?i)issue-(\d+)')
        issues.update(int(m) for m in pattern.findall(branch_name))
    return sorted(list(issues))

def _repo_label_id_map(base: str, auth: str) -> dict[str, int]:
    """Map repository label names to IDs across **all** label pages (#627).

    Uses :func:`api_get_all` so inventories larger than Gitea's per-page cap
    (50) are complete. Duplicate names keep the **first-seen** id for
    deterministic resolution (fail-open for attach; names still resolve).
    """
    labels = api_get_all(f"{base}/labels", auth)
    if labels is None:
        labels = []
    if not isinstance(labels, list):
        raise RuntimeError(
            "failed to list repository labels: expected a list page sequence, "
            f"got {type(labels).__name__}"
        )
    name_to_id: dict[str, int] = {}
    for lb in labels:
        if not isinstance(lb, dict):
            continue
        name = lb.get("name")
        lid = lb.get("id")
        if not name or lid is None:
            continue
        key = str(name)
        if key not in name_to_id:
            name_to_id[key] = int(lid)
    return name_to_id

def _issue_label_names(base: str, auth: str, issue_number: int) -> list[str]:
    issue = api_request("GET", f"{base}/issues/{issue_number}", auth) or {}
    return issue_workflow_labels.label_names(issue)

def _put_issue_label_names(
    *,
    base: str,
    auth: str,
    issue_number: int,
    names: list[str],
    label_ids_by_name: dict[str, int] | None = None,
) -> list[dict]:
    """Full-set label replacement with complete inventory + post-mutation check.

    Missing requested names fail closed before PUT. After PUT, the returned
    label set must match the requested names (order-independent) so callers
    never silently drop labels (#627).
    """
    by_name = label_ids_by_name or _repo_label_id_map(base, auth)
    missing = [name for name in names if name not in by_name]
    if missing:
        raise RuntimeError(
            f"The following labels do not exist on the repository: {missing}. "
            "Please create them first using gitea_create_label."
        )
    ids = [by_name[name] for name in names]
    res = api_request(
        "PUT",
        f"{base}/issues/{issue_number}/labels",
        auth,
        {"labels": ids},
    )
    if not isinstance(res, list):
        raise RuntimeError(
            "Post-mutation label verification failed: expected a list of labels "
            f"from Gitea, got {type(res).__name__}."
        )
    final_names = {
        str(lb.get("name"))
        for lb in res
        if isinstance(lb, dict) and lb.get("name")
    }
    expected = {str(n) for n in names}
    if final_names != expected:
        missing_after = sorted(expected - final_names)
        extra_after = sorted(final_names - expected)
        raise RuntimeError(
            "Post-mutation label verification failed: "
            f"missing={missing_after} unexpected={extra_after}. "
            "Full-set replacement did not match the requested label set."
        )
    return res

def _transition_issue_status(
    *,
    base: str,
    auth: str,
    issue_number: int,
    status: str,
    label_ids_by_name: dict[str, int] | None = None,
) -> dict:
    by_name = label_ids_by_name or _repo_label_id_map(base, auth)
    target_status = issue_workflow_labels.canonical_status_label(status)
    if target_status not in by_name:
        return {
            "success": False,
            "performed": False,
            "issue_number": issue_number,
            "target_status": target_status,
            "reasons": [
                f"required workflow status label '{target_status}' does not exist"
            ],
        }
    current = _issue_label_names(base, auth, issue_number)
    updated = issue_workflow_labels.transition_status_labels(current, target_status)
    _put_issue_label_names(
        base=base,
        auth=auth,
        issue_number=issue_number,
        names=updated,
        label_ids_by_name=by_name,
    )
    return {
        "success": True,
        "performed": True,
        "issue_number": issue_number,
        "target_status": target_status,
        "labels": updated,
    }

def _prepare_issue_status_transition(
    *,
    base: str,
    auth: str,
    issue_number: int,
    status: str,
) -> dict:
    by_name = _repo_label_id_map(base, auth)
    target_status = issue_workflow_labels.canonical_status_label(status)
    if target_status not in by_name:
        return {
            "success": False,
            "performed": False,
            "issue_number": issue_number,
            "target_status": target_status,
            "reasons": [
                f"required workflow status label '{target_status}' does not exist"
            ],
        }
    current = _issue_label_names(base, auth, issue_number)
    updated = issue_workflow_labels.transition_status_labels(current, target_status)
    return {
        "success": True,
        "performed": False,
        "issue_number": issue_number,
        "target_status": target_status,
        "labels": updated,
        "label_ids_by_name": by_name,
    }

def release_in_progress_label(issue_numbers: list[int], remote: str, host: str | None, org: str | None, repo: str | None) -> dict:
    if not issue_numbers:
        return {}

    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    base = repo_api_url(h, o, r)

    try:
        # Paginated inventory (#627): status labels must resolve even when
        # the repo has more labels than one Gitea page.
        label_id = _repo_label_id_map(base, auth).get("status:in-progress")
    except Exception as exc:
        return {num: f"error fetching repo labels: {_redact(str(exc))}" for num in issue_numbers}

    results = {}
    if label_id is None:
        for num in issue_numbers:
            results[num] = "not present"
        return results

    for num in issue_numbers:
        try:
            url = f"{base}/issues/{num}"
            issue_data = api_request("GET", url, auth)
            issue_labels = [lb["name"] for lb in issue_data.get("labels", [])]

            if "status:in-progress" in issue_labels:
                with _audited("release_in_progress_label", host=h, remote=remote, org=o, repo=r, issue_number=num, request_metadata={"action": "remove status:in-progress"}):
                    api_request("DELETE", f"{url}/labels/{label_id}", auth)
                results[num] = "released"
            else:
                results[num] = "not present"
        except Exception as exc:
            results[num] = f"error: {_redact(str(exc))}"

    return results

def cleanup_in_progress_for_pr(pr_payload: dict, remote: str, host: str | None, org: str | None, repo: str | None) -> dict:
    body = pr_payload.get("body") or ""
    title = pr_payload.get("title") or ""
    branch = pr_payload.get("head", {}).get("ref") or ""

    text = f"{title}\n{body}"
    issues = extract_linked_issue_numbers(text, branch)

    if not issues:
        return {"cleanup_status": "no linked issue found"}

    results = release_in_progress_label(issues, remote, host, org, repo)
    return {"cleanup_status": results}

# ── Helpers ───────────────────────────────────────────────────────────────────

def _effective_remote(remote: str) -> str:
    """If remote is the default ('dadeschools') but the active profile base_url maps to a known remote, use that remote instead."""
    try:
        profile = get_profile()
        base_url = profile.get("base_url")
        if remote == "dadeschools" and base_url:
            import urllib.parse
            url = urllib.parse.urlparse(base_url)
            host = (url.netloc or url.path or "").strip().lower()
            for k, v in REMOTES.items():
                if v.get("host") == host:
                    return k
    except Exception:
        pass
    return remote


def _resolve(remote: str, host: str | None, org: str | None, repo: str | None):
    """Resolve remote + overrides to (host, org, repo).

    #714 / #530: when the caller omits org and/or repo, prefer the
    workspace-aligned git remote over ``REMOTES`` defaults (e.g. bare
    ``remote=prgs`` must not force ``Timesheet`` when the checkout is
    ``Gitea-Tools``). Explicit caller org/repo always win and are validated
    by the remote/repo guard. Provenance of omitted-vs-explicit is preserved
    at mutation gates by passing the original caller values (not these
    resolved defaults) into override checks.
    """
    remote = _effective_remote(remote)
    if remote not in REMOTES:
        raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")
    profile = REMOTES[remote]
    org_explicit = org is not None
    repo_explicit = repo is not None
    resolved_host = host or profile["host"]
    resolved_org = org if org_explicit else profile["org"]
    resolved_repo = repo if repo_explicit else profile["repo"]
    filled_org = False
    filled_repo = False
    if not org_explicit or not repo_explicit:
        parsed = None
        # #741: for a cross-repository namespace, prefer the canonical root's
        # own repository identity. `_local_git_remote_url` looks a remote up by
        # *name*, but a target checkout commonly names its remote `origin`
        # rather than `prgs`; that lookup then returns None and the omitted
        # coordinates silently fell through to the REMOTES default target —
        # i.e. a completely unrelated repository. `_canonical_repository_slug`
        # probes the candidate remote names against the canonical root.
        try:
            canonical_slug, _reasons = _canonical_repository_slug(get_profile(), remote)
        except Exception:
            canonical_slug = None
        if canonical_slug:
            parsed = session_ctx.parse_repository_slug(canonical_slug)
        if not parsed:
            parsed = remote_repo_guard.parse_org_repo_from_remote_url(
                _local_git_remote_url(remote)
            )
        if parsed:
            if not org_explicit:
                resolved_org = parsed[0]
                filled_org = True
            if not repo_explicit:
                resolved_repo = parsed[1]
                filled_repo = True
    # #741: explicit caller coordinates may *confirm* a cross-repository
    # canonical binding but must never override it. Both-explicit coordinates
    # short-circuit the #530 guard (remote_repo_guard.assess_remote_repo_match),
    # so without this check a caller could name any repository and skip
    # validation entirely. Unconfigured (single-repository) namespaces yield no
    # canonical slug and are unaffected. An unresolvable configured root is not
    # blocked here — reads must keep working; the fail-closed decision belongs
    # to _enforce_canonical_repository_root at the mutation gate.
    if org_explicit or repo_explicit:
        try:
            canonical_slug, _canonical_reasons = _canonical_repository_slug(
                get_profile(), remote
            )
        except Exception:
            canonical_slug = None
        bound = (
            session_ctx.parse_repository_slug(canonical_slug)
            if canonical_slug
            else None
        )
        if bound:
            override = session_ctx.assess_repository_override(
                requested_org=org,
                requested_repo=repo,
                bound_org=bound[0],
                bound_repo=bound[1],
            )
            if override.get("block"):
                raise RuntimeError(
                    "Canonical repository binding guard (#741): "
                    + "; ".join(override.get("reasons") or [])
                    + ". Explicit org/repo may confirm the configured canonical "
                    "repository binding but can never override it."
                )
    _enforce_remote_repo_guard(
        remote,
        resolved_org,
        resolved_repo,
        # Workspace-filled sides are intentional alignment for #530.
        org_explicit=org_explicit or filled_org,
        repo_explicit=repo_explicit or filled_repo,
    )
    return (resolved_host, resolved_org, resolved_repo)


def _resolve_control_plane_guide_target(
    remote: str,
    host: str | None,
    org: str | None,
    repo: str | None,
) -> tuple[str, str, str]:
    """Resolve guide target with schema-supported org/repo + local context (#530 follow-up).

    ``mcp_get_control_plane_guide`` is a session bootstrap tool. When called with a
    bare ``remote`` whose REMOTES default repo mismatches the local git remote
    (classic prgs→Timesheet vs Gitea-Tools), prefer the local remote's org/repo so
    the guide remains usable. Explicit org/repo always win and are validated by
    :func:`_resolve`.
    """
    if org is not None and repo is not None:
        return _resolve(remote, host, org, repo)

    remote = _effective_remote(remote)
    if remote not in REMOTES:
        raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")

    profile = REMOTES[remote]
    candidate_org = org if org is not None else profile["org"]
    candidate_repo = repo if repo is not None else profile["repo"]
    local_url = _local_git_remote_url(remote)
    assessment = remote_repo_guard.assess_remote_repo_match(
        remote=remote,
        resolved_org=candidate_org,
        resolved_repo=candidate_repo,
        local_remote_url=local_url,
        org_explicit=org is not None,
        repo_explicit=repo is not None,
    )
    if assessment.get("block"):
        parsed = remote_repo_guard.parse_org_repo_from_remote_url(local_url)
        if parsed:
            local_org, local_repo = parsed
            # Fill only unspecified sides from local git context, then resolve
            # as explicit so the guard accepts intentional local alignment.
            use_org = org if org is not None else local_org
            use_repo = repo if repo is not None else local_repo
            return _resolve(remote, host, use_org, use_repo)
        # No local context to recover — surface the original guard error.
        raise RuntimeError(
            remote_repo_guard.format_remote_repo_guard_error(assessment)
        )

    return _resolve(remote, host, org, repo)


def _enforce_remote_repo_guard(
    remote: str,
    resolved_org: str,
    resolved_repo: str,
    *,
    org_explicit: bool,
    repo_explicit: bool,
) -> None:
    """Fail closed on a remote/repo mismatch vs. the local git remote (#530).

    Best-effort: bypassed under pytest unless ``GITEA_FORCE_REMOTE_REPO_CHECK`` is
    set, so the unit suite (which calls tools with bare remotes against mocked APIs)
    is unaffected. In production it protects every read/lookup/mutation tool because
    they all resolve targets through :func:`_resolve`.
    """
    if "pytest" in sys.modules and not os.environ.get(
        "GITEA_FORCE_REMOTE_REPO_CHECK"
    ):
        return
    local_remote_url = _local_git_remote_url(remote)
    assessment = remote_repo_guard.assess_remote_repo_match(
        remote=remote,
        resolved_org=resolved_org,
        resolved_repo=resolved_repo,
        local_remote_url=local_remote_url,
        org_explicit=org_explicit,
        repo_explicit=repo_explicit,
    )
    if assessment["block"]:
        raise RuntimeError(remote_repo_guard.format_remote_repo_guard_error(assessment))


def _auth(host: str) -> str:
    """Get auth header, raise if unavailable.

    Missing credentials are a configuration failure, not a silent internal
    crash. Typed as :class:`gitea_auth.GiteaConfigError` so the tool-error
    boundary (#699) maps them to a structured isError result without EOF.
    The exception message is a fixed constant (no host/token material).
    """
    header = get_auth_header(host)
    if header is None:
        raise GiteaConfigError(reason_code="config_error")
    return header


# ── Audit logging (#18) ─────────────────────────────────────────────────────────
# Mutating actions emit a structured audit record (profile + authenticated user
# + outcome) when GITEA_AUDIT_LOG is configured. When it is not, every helper
# below short-circuits and performs NO work — no extra API calls, no I/O — so
# existing tool behaviour and API call sequences are unchanged.

_UNSET = object()

# Best-effort identity cache keyed by host, so an enabled audit trail resolves
# the authenticated username at most once per host per process.
_IDENTITY_CACHE: dict = {}
# Stable actor identity (id + login) for recovery evidence binding (#709 F7).
_ACTOR_IDENTITY_CACHE: dict = {}


def _authenticated_username(host: str):
    """Resolve the authenticated Gitea username for *host* (cached, fail-soft).

    Read-only. Returns None if the identity cannot be determined; never raises
    and never surfaces credential material.
    """
    if host in _IDENTITY_CACHE:
        return _IDENTITY_CACHE[host]
    user = None
    try:
        header = get_auth_header(host)
        if header:
            who = api_request("GET", gitea_url(host, "/api/v1/user"), header)
            user = (who or {}).get("login")
    except Exception:
        user = None
    _IDENTITY_CACHE[host] = user
    return user


def _authenticated_actor(host: str) -> dict:
    """Resolve the authenticated actor's stable identity (#709 F7 review 438).

    Display names are mutable, so recovery evidence is bound to the immutable
    numeric user id with the login carried alongside for consistency checks.
    Read-only and fail-soft; never surfaces credential material.
    """
    cached = _ACTOR_IDENTITY_CACHE.get(host)
    if cached is not None:
        return dict(cached)
    actor: dict = {"user_id": None, "login": None}
    try:
        header = get_auth_header(host)
        if header:
            who = api_request("GET", gitea_url(host, "/api/v1/user"), header)
            if isinstance(who, dict):
                raw_id = who.get("id")
                actor = {
                    "user_id": int(raw_id) if isinstance(raw_id, int) else None,
                    "login": (who.get("login") or None),
                }
    except Exception:
        actor = {"user_id": None, "login": None}
    _ACTOR_IDENTITY_CACHE[host] = dict(actor)
    return dict(actor)


def _ensure_matching_profile(
    required_permission: str,
    required_role: str,
    remote: str | None,
    host: str | None = None,
) -> str | None:
    """Return the active profile name only when it is allowed for *permission*.

    #714: never silently switch profiles (including cross-host substitution).
    Capability resolution and mutation gates evaluate only the active profile
    for the requested remote. Explicit ``gitea_activate_profile`` is the sole
    in-process switch path.
    """
    del required_role, host  # kept for call-site compatibility
    try:
        profile = get_profile()
    except Exception:
        return None
    active_profile = profile.get("profile_name")
    # Cross-host denial: mdcps profile cannot serve a prgs remote (and vice versa).
    config = None
    try:
        config = gitea_config.load_config()
    except Exception:
        config = None
    contexts = (config or {}).get("contexts") if config else None
    remote_ok = session_ctx.profile_allowed_for_remote(
        profile, remote, REMOTES, contexts=contexts
    )
    if remote_ok.get("block"):
        return None
    active_allowed = profile.get("allowed_operations") or []
    active_forbidden = profile.get("forbidden_operations") or []
    allowed, _ = gitea_config.check_operation(
        required_permission, active_allowed, active_forbidden
    )
    if allowed:
        return active_profile
    # #714: no automatic profile substitution — fail closed.
    return None


def _audit(action: str, *, host, remote, result, org=None, repo=None,
           reason=None, request_metadata=None, issue_number=None,
           pr_number=None, target_branch=None, head_sha=None, username=_UNSET,
           mutation_task: str | None = None):
    """Emit one audit record for a mutating action. No-op unless auditing is on.

    Never raises — auditing must not break the action it records.
    """
    if not gitea_audit.audit_enabled():
        return
    try:
        profile = get_profile()
        if username is _UNSET:
            username = _authenticated_username(host) if host else None
        ns_ctx = {}
        if mutation_task:
            ns_ctx = role_namespace_gate.mutation_audit_context(
                mutation_task, profile, remote=remote, repository=repo)
        # #714: always record the bound session context at mutation time.
        session_audit = session_ctx.mutation_context_audit_fields()
        if isinstance(request_metadata, dict):
            request_metadata = {**request_metadata, **session_audit}
        elif request_metadata is None:
            request_metadata = dict(session_audit)
        event = gitea_audit.build_event(
            action=action,
            result=result,
            remote=remote,
            server=(gitea_url(host, "").rstrip("/") if host else None),
            repository=repo,
            issue_number=issue_number,
            pr_number=pr_number,
            profile_name=profile["profile_name"],
            audit_label=profile["audit_label"],
            authenticated_username=username,
            target_branch=target_branch,
            head_sha=head_sha,
            reason=reason,
            request_metadata=request_metadata,
            mcp_namespace=ns_ctx.get("mcp_namespace"),
            task_role=ns_ctx.get("task_role"),
            operation=ns_ctx.get("operation"),
        )
        gitea_audit.write_event(event)
    except Exception:
        pass  # best-effort; never break the action


class _PreflightOrderError(RuntimeError):
    """Pre-flight order/precondition violation for a mutation (fail closed).

    Carries a safe ``gitea_reason_code`` so the tool-error boundary converts it
    into a typed structured error (``preflight_order_violation``) instead of an
    opaque ``internal_error``. It is still a ``RuntimeError`` subclass so every
    existing ``except RuntimeError`` / message assertion keeps working.
    """

    gitea_reason_code = "preflight_order_violation"


@contextlib.contextmanager
def _mutation_stage(stage: str):
    """Tag any exception escaping *stage* with a safe stage name (#diagnostics).

    On exception, records ``stage`` on the exception (innermost wins) so the
    tool-error boundary can report ``mutation_stage=<stage>`` alongside the
    redacted class/detail. Never swallows, never alters control flow, and never
    overwrites a more specific (inner) stage already set.
    """
    try:
        yield
    except BaseException as exc:  # noqa: BLE001 - tag-and-reraise only
        try:
            if isinstance(exc, Exception) and not getattr(
                exc, "_gitea_mutation_stage", None
            ):
                setattr(exc, "_gitea_mutation_stage", stage)
        except Exception:
            pass
        raise


@contextlib.contextmanager
def _audited(action: str, *, host, remote, org=None, repo=None,
             request_metadata=None, issue_number=None, pr_number=None,
             target_branch=None):
    """Wrap a mutating API call: audit SUCCEEDED on return, FAILED on exception.

    When auditing is off this yields immediately with no bookkeeping.
    """
    if not gitea_audit.audit_enabled():
        yield
        return
    try:
        yield
    except Exception as exc:
        _audit(action, host=host, remote=remote, org=org, repo=repo,
               result=gitea_audit.FAILED, reason=_redact(str(exc)),
               request_metadata=request_metadata, issue_number=issue_number,
               pr_number=pr_number, target_branch=target_branch)
        # #606: best-effort Sentry capture of the failing mutation (fail open).
        sentry_observability.capture_exception(
            exc,
            tags={
                "mutation_tool": action,
                "remote": remote,
                "repo": repo,
                "org": org,
                "issue_number": issue_number,
                "pr_number": pr_number,
            },
        )
        raise
    _audit(action, host=host, remote=remote, org=org, repo=repo,
           result=gitea_audit.SUCCEEDED, request_metadata=request_metadata,
           issue_number=issue_number, pr_number=pr_number,
           target_branch=target_branch)


def _audit_pr_result(action: str):
    """Decorator for gated PR tools that return a result dict.

    Reads the tool's own result dict (authenticated_user, profile, reasons,
    performed) to emit an audit record classifying the outcome as SUCCEEDED,
    BLOCKED, or FAILED. No extra API calls: identity comes from the result.
    """
    def decorate(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            result = fn(*args, **kwargs)
            try:
                if isinstance(result, dict) and gitea_audit.audit_enabled():
                    reasons = [str(x) for x in (result.get("reasons") or [])]
                    if result.get("performed"):
                        status = gitea_audit.SUCCEEDED
                    elif any("failed:" in x.lower() for x in reasons):
                        # "failed:" marks a surfaced exception (e.g. "merge
                        # failed: <err>"); a bare gate message like "… failed
                        # (fail closed)" is a policy block, not an execution error.
                        status = gitea_audit.FAILED
                    else:
                        status = gitea_audit.BLOCKED
                    remote = result.get("remote")
                    host = REMOTES[remote]["host"] if remote in REMOTES else None
                    _audit(
                        action,
                        host=host,
                        remote=remote,
                        result=status,
                        reason="; ".join(reasons) or None,
                        pr_number=result.get("pr_number"),
                        head_sha=result.get("head_sha"),
                        username=result.get("authenticated_user"),
                        request_metadata={
                            "requested_action": result.get("requested_action"),
                            "merge_method": result.get("merge_method"),
                        },
                    )
                    # #606: surface fail-closed blockers / failed mutations to
                    # Sentry as structured events (best-effort, fail open).
                    if status in (gitea_audit.BLOCKED, gitea_audit.FAILED):
                        sentry_observability.capture_workflow_blocker(
                            action,
                            message="; ".join(reasons) or action,
                            next_action=result.get("safe_next_action"),
                            level="error" if status == gitea_audit.FAILED else "warning",
                            tags={
                                "mutation_tool": action,
                                "pr_number": result.get("pr_number"),
                                "current_head_sha": result.get("head_sha"),
                            },
                        )
            except Exception:
                pass  # best-effort; never break the tool
            return result
        return wrapper
    return decorate


# ── Tools ─────────────────────────────────────────────────────────────────────

@mcp.tool()
def gitea_create_issue(
    title: str,
    body: str = "",
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    labels: list[str] | None = None,
    issue_type: str | None = None,
    initial_status: str | None = None,
    discussion: bool = False,
    require_workflow_labels: bool = False,
    allow_duplicate_override: bool = False,
    split_from_issue: int | None = None,
    allow_incomplete_content: bool = False,
    worktree_path: str | None = None,
) -> dict:
    """Create a new issue on a Gitea repository.

    Args:
        title: Issue title (required).
        body: Issue body text.
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.
        labels: Optional labels to apply by name after creating the issue.
        issue_type: Optional canonical type, e.g. 'feature' or 'type:feature'.
        initial_status: Optional initial workflow status, e.g. 'ready'.
        discussion: If True, require/apply type:discussion.
        require_workflow_labels: If True, fail closed unless labels include one
            type:* and one status:* label.
        allow_duplicate_override: Operator-approved split after duplicate found.
        split_from_issue: Existing duplicate issue number when overriding.
        allow_incomplete_content: Operator override to bypass the #582 content
            gate (create despite vague/missing title/body). Off by default.
        worktree_path: Optional path to verify branches-only guard.

    Returns:
        dict with 'number' of the created issue ('url' only with the reveal opt-in).
    """
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop(
        "create_issue"
    )
    if not ok:
        return {
            "success": False,
            "performed": False,
            "number": None,
            "reasons": block_reasons,
        }
    blocked = _namespace_mutation_block(
        "create_issue", number=None, remote=remote)
    if blocked:
        return blocked
    blocked = _profile_permission_block(
        task_capability_map.required_permission("create_issue"),
        number=None,
        remote=remote,
        host=h,
        org=o,
        repo=r,
        org_explicit=org is not None,
        repo_explicit=repo is not None,
    )
    if blocked:
        return blocked
    try:
        with _mutation_stage("preflight_purity"):
            # #735: forward explicit org/repo into shared anti-stomp preflight.
            verify_preflight_purity(
                remote,
                worktree_path=worktree_path,
                task="create_issue",
                org=org,
                repo=repo,
            )
    except Exception as exc:
        typed = _production_guard_block_from_exc(exc, number=None)
        if typed is not None:
            return typed
        raise
    content_gate = issue_content_gate.pre_create_issue_content_gate(
        title,
        body,
        allow_incomplete=allow_incomplete_content,
    )
    if not content_gate.get("performed"):
        return {
            "success": False,
            "performed": False,
            "number": None,
            "content_gate": content_gate,
            "reasons": [
                "BLOCKED + DIAGNOSE: issue content is a vague reference or "
                "missing required fields; provide full title/body or a durable "
                "source pointer (existing issue/PR/comment, checked-in file, "
                "URL, or scratchpad path). "
                + content_gate.get("diagnose", ""),
            ],
        }
    base = repo_api_url(h, o, r)
    requested_labels = issue_workflow_labels.labels_for_new_issue(
        issue_type=issue_type,
        initial_status=initial_status,
        extra_labels=labels,
        discussion=discussion,
    )
    label_assessment = issue_workflow_labels.assess_issue_labels(
        requested_labels,
        discussion=discussion,
        require_type=True,
        require_status=True,
    )
    if require_workflow_labels and not label_assessment["valid"]:
        return {
            "success": False,
            "performed": False,
            "number": None,
            "workflow_label_validation": label_assessment,
            "reasons": label_assessment["errors"],
        }
    label_ids_by_name: dict[str, int] | None = None
    if requested_labels:
        label_ids_by_name = _repo_label_id_map(base, auth)
        missing = [name for name in requested_labels if name not in label_ids_by_name]
        if missing:
            return {
                "success": False,
                "performed": False,
                "number": None,
                "workflow_label_validation": label_assessment,
                "reasons": [
                    f"requested labels do not exist on the repository: {missing}"
                ],
            }
    open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth)
    closed_issues = api_get_all(
        f"{base}/issues?state=closed&type=issues", auth, limit=100
    )
    gate = issue_duplicate_gate.pre_create_issue_duplicate_gate(
        title,
        list(open_issues) + list(closed_issues),
        allow_override=allow_duplicate_override,
        split_from_issue=split_from_issue,
    )
    if not gate.get("performed"):
        return {
            "success": False,
            "performed": False,
            "number": None,
            "duplicate_gate": gate["verdict"],
            "matches": gate.get("matches", []),
            "reasons": gate.get("reasons", []),
        }
    issue_body = body
    if gate.get("override_applied") and split_from_issue is not None:
        rel = f"Operator-approved split from #{split_from_issue}."
        issue_body = f"{rel}\n\n{body}" if body else rel
    url = f"{base}/issues"
    try:
        data = api_request("POST", url, auth, {"title": title, "body": issue_body})
    except Exception as exc:
        _audit("create_issue", host=h, remote=remote, org=o, repo=r,
               result=gitea_audit.FAILED, reason=_redact(str(exc)),
               request_metadata={"title": title})
        raise
    _audit("create_issue", host=h, remote=remote, org=o, repo=r,
           result=gitea_audit.SUCCEEDED, issue_number=data["number"],
           request_metadata={"title": title}, mutation_task="create_issue")
    result = {
        "number": data["number"],
        "workflow_label_validation": label_assessment,
    }
    if requested_labels:
        with _audited(
            "set_issue_labels",
            host=h,
            remote=remote,
            org=o,
            repo=r,
            issue_number=data["number"],
            request_metadata={"labels": requested_labels, "source": "create_issue"},
        ):
            applied = _put_issue_label_names(
                base=base,
                auth=auth,
                issue_number=data["number"],
                names=requested_labels,
                label_ids_by_name=label_ids_by_name,
            )
        result["labels"] = issue_workflow_labels.label_names(applied)
    return _with_optional_url(result, data.get("html_url"))


def _list_open_pulls(h: str, o: str, r: str, auth: str) -> list[dict]:
    """Fetch all OPEN pull requests for a repo (used for stacked-base proof, #484)."""
    try:
        return api_get_all(f"{repo_api_url(h, o, r)}/pulls?state=open", auth) or []
    except Exception as exc:  # fail closed: no proof of an open dependency PR
        raise RuntimeError(
            f"Could not list open pull requests to verify stacked base: {exc}"
        )


@mcp.tool()
def gitea_lock_issue(
    issue_number: int,
    branch_name: str,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    worktree_path: str | None = None,
    stacked_base_branch: str | None = None,
    stacked_base_pr: int | None = None,
) -> dict:
    """Lock exactly one Gitea issue and its branch name to ensure durable tracking.

    Args:
        issue_number: The tracking issue number.
        branch_name: The branch name (must match (fix|feat|docs|chore)/issue-<issue_number>-<desc>).
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override Gitea host.
        org: Override Org.
        repo: Override Repo.
        worktree_path: Author scratch-clone path to validate (defaults to
            GITEA_AUTHOR_WORKTREE or the MCP server project root).
        stacked_base_branch: Opt-in. Declare a non-master base branch for a
            *stacked* PR (a PR based on another unmerged PR's branch). Normal work
            leaves this ``None`` and stays master-equivalent. When set, the
            worktree may be base-equivalent to this branch instead of
            master/main/dev, and the approved base is recorded on the lock (#484).
        stacked_base_pr: Required when ``stacked_base_branch`` is set. The number
            of the OPEN pull request that owns the stacked base branch. The lock
            fails closed unless this open PR exists and owns that branch, so
            arbitrary or stale branches cannot be used as stacked bases.
    """
    # 1. Enforce branch name includes issue number
    expected_pattern = f"issue-{issue_number}"
    if expected_pattern not in branch_name:
        raise ValueError(
            f"Branch name '{branch_name}' must contain locked issue pattern '{expected_pattern}' (fail closed)"
        )

    blocked = _profile_permission_block(
        task_capability_map.required_permission("lock_issue"),
        issue_number=issue_number,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
        org_explicit=org is not None,
        repo_explicit=repo is not None,
    )
    if blocked:
        return blocked

    resolved_worktree = issue_lock_worktree.resolve_author_worktree_path(
        worktree_path, _canonical_local_git_root()
    )
    h, o, r = _resolve(remote, host, org, repo)
    existing_issue_lock = _load_existing_issue_lock(
        remote=remote, org=o, repo=r, issue_number=issue_number
    )
    active_lease_block = issue_lock_store.assess_same_issue_lease_conflict(
        existing_issue_lock,
        issue_number=issue_number,
        branch_name=branch_name,
        worktree_path=resolved_worktree,
        operation_type=AUTHOR_ISSUE_WORK_LEASE,
    )
    if active_lease_block:
        raise RuntimeError(active_lease_block)

    # ── Stacked-PR base declaration (opt-in, #484) ──
    # Normal work leaves stacked_base_branch None → master-equivalent path.
    # A declared stacked base must be proven to own an OPEN PR before it can
    # anchor base-equivalence; this never bypasses the lock.
    stacked_extra_bases: tuple[str, ...] = ()
    stacked_approved: dict | None = None
    if stacked_base_branch:
        stacked_assessment = stacked_pr_support.assess_stacked_base_declaration(
            stacked_base_branch=stacked_base_branch,
            stacked_base_pr=stacked_base_pr,
            open_prs=_list_open_pulls(h, o, r, _auth(h)),
        )
        if stacked_assessment["block"]:
            raise RuntimeError(
                "; ".join(stacked_assessment["reasons"]) + " (fail closed)"
            )
        stacked_approved = stacked_assessment["approved"]
        stacked_extra_bases = (stacked_approved["branch"],)

    if stacked_extra_bases:
        git_state = issue_lock_worktree.read_worktree_git_state(
            resolved_worktree, extra_bases=stacked_extra_bases
        )
    else:
        git_state = issue_lock_worktree.read_worktree_git_state(resolved_worktree)
    # #735: forward explicit org/repo into shared anti-stomp preflight.
    verify_preflight_purity(
        remote,
        worktree_path=resolved_worktree,
        task="lock_issue",
        org=org,
        repo=repo,
    )
    # ── Dead-session lock recovery assessment (#753) ──
    # When the MCP session that took a lock exits, the lock goes non-live
    # (stale by dead PID) even inside its lease TTL, and the branch it owns is
    # ahead of its base by construction — so the base-equivalence gate below
    # makes normal re-lock unreachable for every PR that already exists.
    #
    # This grants a waiver ONLY for that case, proven against the durable lock
    # record plus live git/Gitea observation. A refused assessment never raises:
    # it simply withholds the waiver, leaving the pre-existing guard to fail
    # closed exactly as before. Recovery can only ever add permission.
    recovery_assessment: dict | None = None
    if (
        existing_issue_lock
        and existing_issue_lock.get("issue_number") == issue_number
        and not issue_lock_store.is_lease_live(existing_issue_lock)
    ):
        recovery_assessment = _evaluate_issue_lock_recovery(
            existing_issue_lock,
            issue_number=issue_number,
            branch_name=branch_name,
            worktree_path=resolved_worktree,
            remote=remote,
            h=h,
            o=o,
            r=r,
            git_state=git_state,
        )

    recovery_sanctioned = bool(
        recovery_assessment and recovery_assessment.get("recovery_sanctioned")
    )
    # #755: a sanctioned dead-session recovery always has an owning open PR —
    # that is what makes it a recovery rather than a fresh claim. Carry the
    # server-derived owning-PR evidence into the duplicate-work gate below so
    # the PR this lock already owns is not mistaken for competing duplicate
    # work. Withheld (None) unless recovery was granted, so the ordinary
    # duplicate blocker is untouched on every other path.
    recovered_owning_pr = (
        issue_lock_recovery.owning_pr_recovery_evidence(recovery_assessment)
        if recovery_sanctioned
        else None
    )
    lock_assessment = issue_lock_worktree.assess_issue_lock_worktree(
        worktree_path=resolved_worktree,
        current_branch=git_state.get("current_branch"),
        porcelain_status=git_state.get("porcelain_status") or "",
        base_equivalent=git_state.get("base_equivalent"),
        inspected_git_root=git_state.get("inspected_git_root"),
        base_branch=git_state.get("base_branch"),
        recovery_sanctioned=recovery_sanctioned,
    )
    if lock_assessment["block"]:
        reasons = list(lock_assessment.get("reasons") or [])
        # Surface why recovery was unavailable, so a blocked caller sees the
        # exact missing evidence instead of only the base-equivalence text.
        if recovery_assessment and recovery_assessment.get("is_candidate"):
            reasons.append(
                issue_lock_recovery.format_recovery_refusal(recovery_assessment)
            )
        raise RuntimeError(
            issue_lock_worktree.format_issue_lock_worktree_error(
                {**lock_assessment, "reasons": reasons}
            )
        )

    auth = _auth(h)
    duplicate_gate = _assess_issue_duplicate_gate(
        issue_number,
        h=h,
        o=o,
        r=r,
        auth=auth,
        locked_branch=branch_name,
        phase=issue_work_duplicate_gate.PHASE_LOCK,
        recovered_owning_pr=recovered_owning_pr,
    )
    if duplicate_gate.get("block"):
        raise ValueError("; ".join(duplicate_gate.get("reasons") or [
            f"duplicate work gate blocked issue #{issue_number} (fail closed)"
        ]))

    branch_url = f"{repo_api_url(h, o, r)}/branches"
    try:
        branches = api_get_all(branch_url, auth)
    except Exception as e:
        raise RuntimeError(f"Could not list branches to verify issue lock: {e}")
    existing_branch_entries = [
        {
            "name": _branch_entry_name(branch),
            "commit_sha": _branch_entry_commit_sha(branch),
        }
        for branch in branches
    ]
    adoption = issue_lock_adoption.assess_own_branch_adoption(
        issue_number=issue_number,
        requested_branch=branch_name,
        existing_branches=existing_branch_entries,
    )
    if adoption["block"]:
        competing = ", ".join(adoption["competing_branches"])
        raise ValueError(
            f"Issue #{issue_number} already has matching branch '{competing}' "
            "that is not the requested branch (fail closed)"
        )

    work_lease = _build_author_issue_work_lease(
        issue_number=issue_number,
        branch_name=branch_name,
        worktree_path=resolved_worktree,
        host=h,
    )
    data = {
        "issue_number": issue_number,
        "branch_name": branch_name,
        "remote": remote,
        "org": o,
        "repo": r,
        "worktree_path": resolved_worktree,
        "work_lease": work_lease,
        "lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
            tool="gitea_lock_issue",
            claimant=work_lease.get("claimant"),
        ),
    }
    if stacked_approved:
        data["approved_stacked_base"] = stacked_approved
    if recovery_sanctioned and recovery_assessment:
        # #753 AC2/AC6: record that this claim was recovered after session
        # death, with the prior and replacement session identity, so the
        # takeover is auditable and never looks like an original claim.
        data["dead_session_recovery"] = issue_lock_recovery.build_recovery_record(
            recovery_assessment,
            recovered_at=_work_lease_timestamp(_work_lease_now()),
        )

    # #772 AC5: a recovery replaces a claim another session already owned, so
    # its write is a compare-and-swap against the generation the assessment was
    # made on. Two replacement sessions that both observed the same dead owner
    # cannot both succeed — the second finds a moved generation and fails
    # closed. Ordinary first-time claims keep the unconditional write.
    expected_generation = (
        issue_lock_store.lock_generation(existing_issue_lock)
        if recovery_sanctioned
        else None
    )
    lock_file_path = _save_issue_lock(data, expected_generation=expected_generation)
    lock_record = issue_lock_store.read_lock_file(lock_file_path) or data
    freshness = issue_lock_store.assess_lock_freshness(lock_record)
    competing = [
        entry
        for entry in issue_lock_store.list_live_locks()
        if entry.get("issue_number") != issue_number
    ]
    lock_proof = issue_lock_store.format_lock_proof(
        lock_record,
        freshness=freshness,
        competing_live_locks=competing,
        released=False,
    )

    agent_artifacts = agent_temp_artifacts.find_agent_temp_artifacts_from_porcelain(
        git_state.get("porcelain_status") or ""
    )
    result = {
        "success": True,
        "message": (
            f"Successfully locked issue #{issue_number} to branch '{branch_name}' "
            f"from worktree '{resolved_worktree}' (fail-closed check complete)."
        ),
        "issue_number": issue_number,
        "branch_name": branch_name,
        "worktree_path": resolved_worktree,
        "work_lease": work_lease,
        "lock_file_path": lock_file_path,
        "lock_freshness": freshness,
        "lock_proof": lock_proof,
    }
    if recovery_sanctioned and recovery_assessment:
        result["dead_session_recovery"] = data["dead_session_recovery"]
        result["message"] = (
            f"Recovered the durable lock for issue #{issue_number} on branch "
            f"'{branch_name}' after the owning MCP session (pid "
            f"{recovery_assessment['evidence'].get('prior_session_pid')}) exited; "
            "ownership evidence matched exactly (fail-closed check complete)."
        )
    if stacked_approved:
        result["approved_stacked_base"] = stacked_approved
        result["message"] = (
            f"Successfully locked issue #{issue_number} to branch '{branch_name}' "
            f"as a STACKED PR on base '{stacked_approved['branch']}' "
            f"(open PR #{stacked_approved['pr_number']}); fail-closed check complete."
        )
    if adoption["adopt"]:
        result["adoption"] = issue_lock_adoption.build_adoption_proof(
            issue_number=issue_number,
            branch_name=branch_name,
            assessment=adoption,
            open_pr_checked=True,
            competing_lock_checked=True,
            lock_file_path=lock_file_path,
            lock_file_status="written",
        )
        result["message"] = (
            f"Adopted existing branch '{branch_name}' and locked issue "
            f"#{issue_number} for recovery (fail-closed check complete)."
        )
    else:
        # #477 AC2: normal (no-adoption) lock responses carry explicit,
        # adoption-free proof metadata so they stay clear and cannot be
        # misread as claiming a branch was adopted.
        result["adoption_check"] = issue_lock_adoption.build_non_adoption_lock_proof(
            issue_number=issue_number,
            branch_name=branch_name,
        )
    if agent_artifacts:
        result["warnings"] = [
            "Agent temp artifacts at repo root (delete before implementation): "
            + ", ".join(agent_artifacts)
        ]
    return result


@mcp.tool()
def gitea_assess_work_issue_duplicate(
    issue_number: int,
    branch_name: str | None = None,
    phase: str = issue_work_duplicate_gate.PHASE_LOCK,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Read-only duplicate-work gate for author sessions before mutations (#400)."""
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "performed": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    # #768 AC2: recovery evidence comes only from the durable lock this session
    # already holds, and only when that lock is for the issue being assessed.
    # Nothing here is reachable through this tool's parameters.
    recovered_owning_pr = None
    lock_data = _load_existing_issue_lock()
    if lock_data and int(lock_data.get("issue_number") or 0) == int(issue_number):
        recovered_owning_pr = issue_lock_recovery.recovered_owning_pr_from_lock(
            lock_data
        )
    gate = _assess_issue_duplicate_gate(
        issue_number,
        h=h,
        o=o,
        r=r,
        auth=auth,
        locked_branch=branch_name,
        phase=phase,
        recovered_owning_pr=recovered_owning_pr,
    )
    # #772 AC9: report the recovery disposition this issue's durable lock would
    # receive, decided by the same evaluator the mutating lock path uses, so the
    # diagnostic and the mutator can never disagree. Read-only: assessment only,
    # never a write, and a probe failure degrades to a reported reason rather
    # than turning a diagnostic into a hard error.
    lock_recovery: dict | None = None
    if (
        lock_data
        and int(lock_data.get("issue_number") or 0) == int(issue_number)
        and not issue_lock_store.is_lease_live(lock_data)
    ):
        recovery_worktree = str(lock_data.get("worktree_path") or "")
        recovery_branch = str(
            branch_name or lock_data.get("branch_name") or ""
        )
        if recovery_worktree and recovery_branch:
            try:
                recovery_state = issue_lock_worktree.read_worktree_git_state(
                    recovery_worktree
                )
                assessment = _evaluate_issue_lock_recovery(
                    lock_data,
                    issue_number=int(issue_number),
                    branch_name=recovery_branch,
                    worktree_path=recovery_worktree,
                    remote=remote,
                    h=h,
                    o=o,
                    r=r,
                    git_state=recovery_state,
                )
                lock_recovery = {
                    "outcome": assessment.get("outcome"),
                    "recovery_sanctioned": assessment.get("recovery_sanctioned"),
                    "is_candidate": assessment.get("is_candidate"),
                    "reasons": assessment.get("reasons"),
                    "evidence": assessment.get("evidence"),
                }
            except Exception as exc:
                lock_recovery = {
                    "outcome": issue_lock_recovery.REFUSED,
                    "recovery_sanctioned": False,
                    "is_candidate": True,
                    "reasons": [
                        f"recovery evidence could not be gathered: {exc}"
                    ],
                    "evidence": {},
                }
    return {
        "success": not gate.get("block"),
        **gate,
        "lock_recovery": lock_recovery,
    }


@mcp.tool()
def gitea_create_pr(
    title: str,
    head: str,
    base: str = "main",
    body: str = "",
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    worktree_path: str | None = None,
) -> dict:
    """Create a pull request on a Gitea repository.

    Args:
        title: PR title (required).
        head: Source branch name (required).
        base: Target branch (default: 'main').
        body: PR description.
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.
        worktree_path: Author worktree path; must match the path stored at lock time.

    Returns:
        dict with 'number' of the created PR ('url' only with the reveal opt-in).
    """
    ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop(
        "create_pr")
    if not ok:
        return {
            "success": False,
            "performed": False,
            "number": None,
            "reasons": block_reasons,
        }
    blocked = _namespace_mutation_block(
        "create_pr", number=None, remote=remote)
    if blocked:
        return blocked
    blocked = _profile_permission_block(
        task_capability_map.required_permission("create_pr"),
        number=None,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
        org_explicit=org is not None,
        repo_explicit=repo is not None,
    )
    if blocked:
        return blocked
    with _mutation_stage("preflight_purity"):
        # #735: forward explicit org/repo into shared anti-stomp preflight.
        verify_preflight_purity(
            remote,
            worktree_path=worktree_path,
            task="create_pr",
            org=org,
            repo=repo,
        )
    h, o, r = _resolve(remote, host, org, repo)

    # ── Issue Lock Validation (Issue #194 / #196 / #443) ──
    lock_data = _resolve_issue_lock_for_pr(remote=remote, org=o, repo=r, head=head)

    lock_provenance_check = issue_lock_provenance.assess_lock_file_for_create_pr(
        lock_data
    )
    if lock_provenance_check["block"]:
        raise RuntimeError(
            issue_lock_provenance.format_lock_provenance_error(lock_provenance_check)
        )

    locked_issue = lock_data.get("issue_number")
    locked_branch = lock_data.get("branch_name")
    locked_worktree = lock_data.get("worktree_path")

    worktree_check = issue_lock_worktree.verify_pr_worktree_matches_lock(
        locked_worktree, worktree_path, _canonical_local_git_root()
    )
    if worktree_check["block"]:
        raise ValueError(worktree_check["reasons"][0])

    if head != locked_branch:
        raise ValueError(
            f"PR head branch '{head}' does not match locked branch '{locked_branch}' (fail closed)"
        )

    ownership = issue_lock_store.verify_lock_for_mutation(
        lock_data,
        issue_number=locked_issue,
        branch_name=head,
        worktree_path=worktree_path,
    )
    if ownership["block"]:
        raise ValueError(ownership["reasons"][0])

    # Check for forbidden terms anywhere in title/body
    forbidden_terms = ["equivalent", "related", "same as"]
    text_to_check = f"{title} {body}".lower()
    for term in forbidden_terms:
        if term in text_to_check:
            raise ValueError(
                f"PR title or body contains forbidden term '{term}' (fail closed)"
            )

    # Ensure Closes #<locked_issue> or Fixes #<locked_issue> is present exactly
    closes_pattern = re.compile(rf"\b(closes|fixes)\s+#{locked_issue}\b", re.IGNORECASE)
    if not closes_pattern.search(text_to_check):
        raise ValueError(
            f"PR title or body must contain 'Closes #{locked_issue}' or 'Fixes #{locked_issue}' exactly to ensure durable tracking (fail closed)"
        )

    # ── Stacked-PR base validation (#484) ──
    # Normal base branches (master/main/dev) pass unchanged. A non-base target is
    # allowed only when it matches the lock's approved stacked base, that base still
    # has an open PR, and the body documents the stack. This never bypasses the lock.
    base_open_prs = (
        []
        if stacked_pr_support.is_base_branch(base)
        else _list_open_pulls(h, o, r, _auth(h))
    )
    base_check = stacked_pr_support.assess_create_pr_base(
        base=base,
        approved_stacked_base=lock_data.get("approved_stacked_base"),
        body=body,
        open_prs=base_open_prs,
    )
    if base_check["block"]:
        raise ValueError("; ".join(base_check["reasons"]) + " (fail closed)")

    duplicate_block = _enforce_locked_issue_duplicate_recheck(
        remote,
        issue_work_duplicate_gate.PHASE_CREATE_PR,
        host=host,
        org=org,
        repo=repo,
    )
    if duplicate_block:
        return _duplicate_gate_block_response(
            duplicate_block,
            number=None,
            issue_number=locked_issue,
            branch_name=locked_branch,
        )

    auth = _auth(h)
    repo_base = repo_api_url(h, o, r)
    pr_open_transition = _prepare_issue_status_transition(
        base=repo_base,
        auth=auth,
        issue_number=int(locked_issue),
        status="status:pr-open",
    )
    if not pr_open_transition.get("success"):
        return {
            "success": False,
            "performed": False,
            "number": None,
            "issue_number": locked_issue,
            "issue_status_transition": pr_open_transition,
            "reasons": pr_open_transition.get("reasons", []),
        }
    url = f"{repo_base}/pulls"
    payload = {"title": title, "body": body, "head": head, "base": base}
    meta = {"title": title, "head": head, "base": base}
    try:
        data = api_request("POST", url, auth, payload)
    except Exception as exc:
        _audit("create_pr", host=h, remote=remote, org=o, repo=r,
               result=gitea_audit.FAILED, reason=_redact(str(exc)),
               target_branch=head, request_metadata=meta,
               mutation_task="create_pr")
        raise
    _audit("create_pr", host=h, remote=remote, org=o, repo=r,
           result=gitea_audit.SUCCEEDED, pr_number=data["number"],
           target_branch=head, request_metadata=meta,
           mutation_task="create_pr")
    with _audited(
        "set_issue_labels",
        host=h,
        remote=remote,
        org=o,
        repo=r,
        issue_number=int(locked_issue),
        request_metadata={
            "source": "create_pr",
            "status": "status:pr-open",
            "pr_number": data["number"],
        },
    ):
        _put_issue_label_names(
            base=repo_base,
            auth=auth,
            issue_number=int(locked_issue),
            names=pr_open_transition["labels"],
            label_ids_by_name=pr_open_transition["label_ids_by_name"],
        )
    result = {
        "number": data["number"],
        "issue_status_transition": {
            "success": True,
            "performed": True,
            "issue_number": int(locked_issue),
            "target_status": "status:pr-open",
            "labels": pr_open_transition["labels"],
        },
    }
    return _with_optional_url(result, data.get("html_url"))


def _format_list_pr_entry(pr: dict) -> dict:
    head_obj = pr.get("head") or {}
    base_obj = pr.get("base") or {}
    entry = {
        "number": pr["number"],
        "title": pr["title"],
        "state": pr["state"],
        "head": head_obj.get("ref") if isinstance(head_obj, dict) else head_obj,
        "base": base_obj.get("ref") if isinstance(base_obj, dict) else base_obj,
        "mergeable": pr.get("mergeable"),
        "updated_at": pr.get("updated_at"),
        "head_sha": head_obj.get("sha") if isinstance(head_obj, dict) else None,
        "author": (pr.get("user") or {}).get("login"),
        "labels": [
            label["name"]
            for label in pr.get("labels", [])
            if isinstance(label, dict) and label.get("name")
        ],
        "body": pr.get("body") or "",
    }
    return _with_optional_url(entry, pr.get("html_url"))


def _list_prs_pagination_envelope(
    prs: list[dict],
    *,
    page: int,
    per_page: int,
    pages_fetched: int,
    is_final_page: bool,
    has_more: bool,
    next_page: int | None,
    inventory_complete: bool,
) -> dict:
    return {
        "prs": prs,
        "pagination": {
            "page": page,
            "per_page": per_page,
            "returned_count": len(prs),
            "has_more": has_more,
            "next_page": next_page,
            "is_final_page": is_final_page,
            "pages_fetched": pages_fetched,
            "inventory_complete": inventory_complete,
            "total_count": len(prs) if inventory_complete else None,
        },
    }


@mcp.tool()
def gitea_list_prs(
    state: str = "open",
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    page: int | None = None,
    per_page: int = 50,
) -> dict:
    """List pull requests on a Gitea repository.

    Args:
        state: State filter — 'open', 'closed', or 'all' (default: 'open').
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.
        page: Optional 1-based page for explicit single-page fetch. When
            omitted, all pages are traversed and ``inventory_complete`` is set.
        per_page: Page size (1–50; Gitea caps at 50).

    Returns:
        Dict with ``prs`` (list of PR summaries) and ``pagination`` metadata
        (``has_more``, ``next_page``, ``is_final_page``, ``inventory_complete``,
        ``pages_fetched``, ``total_count`` when complete). Each PR entry carries
        ``number``, ``title``, ``state``, ``head``, ``base``, ``mergeable``,
        ``updated_at`` ('url' only with the reveal opt-in).
    """
    allowed, block_reasons = capability_stop_terminal.check_reviewer_queue_tool(
        "list_prs"
    )
    if not allowed:
        raise RuntimeError("; ".join(block_reasons))
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    url = f"{repo_api_url(h, o, r)}/pulls?state={state}"

    if page is not None:
        raw_page, pagination = api_fetch_page(
            url, auth, page=page, limit=per_page
        )
        prs = [_format_list_pr_entry(pr) for pr in raw_page]
        return _list_prs_pagination_envelope(
            prs,
            page=pagination["page"],
            per_page=pagination["per_page"],
            pages_fetched=1,
            is_final_page=pagination["is_final_page"],
            has_more=pagination["has_more"],
            next_page=pagination["next_page"],
            inventory_complete=False,
        )

    all_raw: list[dict] = []
    current_page = 1
    pages_fetched = 0
    last_meta: dict = {}
    while pages_fetched < 100:
        raw_page, last_meta = api_fetch_page(
            url, auth, page=current_page, limit=per_page
        )
        pages_fetched += 1
        all_raw.extend(raw_page)
        if last_meta["is_final_page"]:
            break
        current_page += 1

    prs = [_format_list_pr_entry(pr) for pr in all_raw]
    return _list_prs_pagination_envelope(
        prs,
        page=1,
        per_page=per_page,
        pages_fetched=pages_fetched,
        is_final_page=bool(last_meta.get("is_final_page")),
        has_more=False,
        next_page=None,
        inventory_complete=bool(last_meta.get("is_final_page")),
    )


@mcp.tool()
def gitea_view_pr(
    pr_number: int,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Get details of a single pull request.

    Args:
        pr_number: The pull request index/number.
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.

    Returns:
        dict with PR details including 'updated_at', 'merged_at',
        'merge_commit_sha', 'closed_at' (when present) to support
        live-state reconciliation and staleness detection against
        prior reports or handoffs.
    """
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}"
    pr = api_request("GET", url, auth)
    result = {
        "number": pr["number"],
        "title": pr["title"],
        "body": pr.get("body", ""),
        "state": pr["state"],
        "head": pr["head"]["ref"],
        "base": pr["base"]["ref"],
        "mergeable": pr.get("mergeable"),
        "user": pr.get("user", {}).get("login", ""),
        "updated_at": pr.get("updated_at"),
        "merged_at": pr.get("merged_at"),
        "merge_commit_sha": pr.get("merge_commit_sha"),
        "closed_at": pr.get("closed_at"),
    }
    return _with_optional_url(result, pr.get("html_url"))


# Actions whose eligibility this tool can evaluate.
_ELIGIBILITY_ACTIONS = ("review", "approve", "request_changes", "merge")


@mcp.tool()
def gitea_check_pr_eligibility(
    pr_number: int,
    action: str,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Read-only: is the current identity/profile eligible to perform *action*
    on a PR?

    Evaluates eligibility only — it NEVER reviews, approves, requests changes,
    merges, or mutates anything. It inspects the authenticated identity
    (via the /user endpoint), the active runtime profile metadata
    (``get_profile``), and the target PR (author, state, head SHA,
    mergeability), then returns a decision with clear reasons.

    Fail-closed rules:
    - Unknown action or unknown remote → not eligible.
    - Profile has no configured allowed operations, or the action is not in
      the profile's allowed operations (or is forbidden) → not eligible.
    - Authenticated identity cannot be determined → not eligible.
    - Authenticated user equals the PR author → not eligible to ``approve`` or
      ``merge``.
    - PR is not open → not eligible.
    - For ``merge``, PR must be reported mergeable.

    Never returns the token, Authorization header, or any credential material.

    Args:
        pr_number: Target PR number.
        action: One of 'review', 'approve', 'request_changes', 'merge'.
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.

    Returns:
        dict with 'eligible' (bool), the inputs inspected, and 'reasons';
        when the block is a missing profile permission, also a structured
        'permission_report' (#142).
    """
    action = (action or "").strip().lower()
    if action in ("review", "approve", "request_changes", "merge"):
        allowed, block_reasons = capability_stop_terminal.check_reviewer_queue_tool(
            "check_pr_eligibility"
        )
        if not allowed:
            return {
                "eligible": False,
                "requested_action": action,
                "reasons": block_reasons,
                "terminal_mode": True,
            }
    profile = get_profile()
    result = {
        "eligible": False,
        "requested_action": action,
        "authenticated_user": None,
        "profile_name": profile["profile_name"],
        "allowed_operations": profile["allowed_operations"],
        "pr_author": None,
        "pr_number": pr_number,
        "pr_state": None,
        "head_sha": None,
        "mergeable": None,
        "remote": remote if remote in REMOTES else None,
        "reasons": [],
    }
    reasons = result["reasons"]

    if action not in _ELIGIBILITY_ACTIONS:
        reasons.append(
            f"unknown action '{action}'; expected one of {list(_ELIGIBILITY_ACTIONS)}"
        )
        return result

    if remote not in REMOTES:
        reasons.append(f"unknown remote '{remote}'")
        return result

    # Profile capability check (metadata only; not enforcement of the action).
    # Both the action and the profile lists are normalized before comparison
    # (#106), so legacy spellings ("merge") and canonical namespaced ops
    # ("gitea.pr.merge") always match each other and never cross services.
    allowed = profile["allowed_operations"]
    forbidden = profile["forbidden_operations"]
    active_role = _role_kind(allowed, forbidden)
    if action == "merge" and active_role == "reviewer":
        op_ok = False
        op_reason = "reviewer-cannot-merge"
    else:
        op_ok, op_reason = gitea_config.check_operation(action, allowed, forbidden)

    if not op_ok:
        if op_reason == "no-allowed-operations":
            reasons.append(
                "profile has no configured allowed operations (fail closed)")
        elif op_reason == "forbidden":
            reasons.append(f"profile forbids '{action}'")
        elif op_reason == "reviewer-cannot-merge":
            reasons.append(
                "Reviewer profile cannot merge. Use a merger profile/session after formal approval at current head."
            )
        elif op_reason == "invalid-forbidden-entry":
            reasons.append(
                "profile has an unrecognized forbidden operation entry "
                "(fail closed)")
        else:
            reasons.append(f"profile is not allowed to {action}")

        # Stop here immediately if the action implies reviewer role and the
        # profile doesn't permit it. No identity/PR API calls on this path;
        # the structured #142 report is built config-only (no network).
        if action in ("review", "approve", "request_changes", "merge"):
            result["eligible"] = False
            missing_perm = (
                f"gitea.pr.{action}"
                if action in ("approve", "request_changes", "merge", "review")
                else f"gitea.{action}")
            result["missing_permission"] = missing_perm
            result["active_profile"] = profile["profile_name"]
            result["active_identity"] = None
            result["permission_report"] = _permission_block_report(
                missing_perm)

            req_profile = None
            role_noun = "reviewer"
            if action in ("approve", "request_changes", "review"):
                req_profile = "A profile with reviewer role permissions (allowing approve/review, and forbidding author operations)"
            elif action == "merge":
                req_profile = "A profile with merger role permissions (allowing merge, and forbidding author operations)"
                role_noun = "merger"
            result["required_profile"] = req_profile

            switching_supported = gitea_config.is_runtime_switching_enabled()
            if switching_supported:
                result["fixable_by_profile_switch"] = True
                result["requires_different_namespace"] = False
                safe_step = f"Switch to a {role_noun} profile by calling gitea_activate_profile with a profile that allows {action}."
            else:
                result["fixable_by_profile_switch"] = False
                result["requires_different_namespace"] = True
                safe_step = f"Switch to the {role_noun} MCP session (e.g. gitea-{role_noun}) which has {role_noun} permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a {role_noun} profile."

            result["safe_next_step"] = safe_step
            return result

    h, o, r = _resolve(remote, host, org, repo)

    # Authenticated identity (read-only). Fail soft; never leak error/secret.
    try:
        auth = _auth(h)
    except Exception:
        auth = None
    auth_user = None
    if auth:
        try:
            who = api_request("GET", gitea_url(h, "/api/v1/user"), auth)
            auth_user = (who or {}).get("login")
        except Exception:
            auth_user = None
    result["authenticated_user"] = auth_user
    if not auth_user:
        reasons.append("authenticated identity could not be determined")

    # PR facts (read-only GET; no mutation).
    pr_author = None
    pr_state = None
    if auth:
        try:
            pr = api_request(
                "GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth
            )
            pr_author = (pr or {}).get("user", {}).get("login")
            pr_state = (pr or {}).get("state")
            result["head_sha"] = ((pr or {}).get("head") or {}).get("sha")
            result["mergeable"] = (pr or {}).get("mergeable")
        except Exception:
            reasons.append("PR details could not be retrieved")
    else:
        reasons.append("PR details could not be retrieved (no credentials)")
    result["pr_author"] = pr_author
    result["pr_state"] = pr_state

    # PR must be open to act on.
    if pr_state is None:
        reasons.append("PR state unknown")
    elif pr_state != "open":
        reasons.append(f"PR is not open (state={pr_state})")

    # Self-author must not approve or merge their own PR.
    if auth_user and pr_author and auth_user == pr_author and action in ("approve", "merge"):
        reasons.append("authenticated user is PR author")

    # Merge needs a positive mergeability signal.
    if action == "merge":
        if result["mergeable"] is False:
            reasons.append("PR is not mergeable")
        elif result["mergeable"] is None:
            reasons.append("PR mergeability unknown")

    # #695: merge eligibility must honor quarantine-aware formal review feedback.
    # Contaminated approvals (e.g. review 427 on PR #694) must not make merge
    # eligible even when Gitea still shows APPROVED / mergeable=true.
    if action == "merge" and not reasons:
        try:
            feedback = gitea_get_pr_review_feedback(
                pr_number=pr_number, remote=remote, host=host, org=org, repo=repo,
            )
        except Exception as exc:  # noqa: BLE001 — fail closed, never leak secrets
            feedback = {
                "success": False,
                "reasons": [
                    "PR review feedback unavailable for merge eligibility "
                    f"(fail closed, #695): {_redact(str(exc))}"
                ],
            }
        result["approval_visible"] = feedback.get("approval_visible")
        result["approval_at_current_head"] = feedback.get("approval_at_current_head")
        result["quarantined_approvals_at_current_head"] = feedback.get(
            "quarantined_approvals_at_current_head"
        )
        result["stale_approval_block_reason"] = feedback.get(
            "stale_approval_block_reason"
        )
        result["has_blocking_change_requests"] = feedback.get(
            "has_blocking_change_requests"
        )
        if not feedback.get("success"):
            reasons.append(
                "PR review feedback unavailable for merge eligibility (fail closed, #695)"
            )
            reasons.extend(feedback.get("reasons") or [])
        elif feedback.get("has_blocking_change_requests"):
            reasons.append(
                "undismissed REQUEST_CHANGES review blocks merge eligibility (fail closed)"
            )
        elif not feedback.get("approval_at_current_head"):
            reasons.append(
                feedback.get("stale_approval_block_reason")
                or (
                    "no non-quarantined APPROVED review at current head; "
                    "merge eligibility denied (#695)"
                )
            )

    result["eligible"] = len(reasons) == 0
    if result["eligible"]:
        reasons.append("all eligibility checks passed")

    # Add enhanced error clarity details (#131)
    auth_user = result["authenticated_user"]
    pr_author = result["pr_author"]
    profile_name = result["profile_name"]
    is_self = bool(auth_user and pr_author and auth_user == pr_author)

    result["active_identity"] = auth_user
    result["active_profile"] = profile_name
    result["self_author"] = is_self

    # Determine missing permission
    missing_perm = None
    if not op_ok:
        missing_perm = (
            f"gitea.pr.{action}"
            if action in ("approve", "request_changes", "merge", "review")
            else f"gitea.{action}")

    result["missing_permission"] = missing_perm
    if missing_perm:
        # Structured denial guidance (#142) — read-only, never widens.
        result["permission_report"] = _permission_block_report(
            missing_perm, identity=auth_user)

    # Determine required profile
    req_profile = None
    if action in ("approve", "request_changes", "review"):
        req_profile = "A profile with reviewer role permissions (allowing approve/merge/review, and forbidding author operations)"
    elif action == "merge":
        req_profile = "A profile with reviewer role permissions and explicit merge permission"
    result["required_profile"] = req_profile

    # Determine required identity
    req_identity = None
    if is_self:
        req_identity = f"Any Gitea user other than PR author '{pr_author}'"
    result["required_identity"] = req_identity

    # Determine if fixable by profile switch vs requires different namespace
    switching_supported = gitea_config.is_runtime_switching_enabled()
    fixable_by_switch = False
    req_different_ns = False

    if not op_ok:
        config = gitea_config.load_config()
        has_capable_profile = False
        if config:
            for name, p in (config.get("profiles") or {}).items():
                p_allowed = p.get("allowed_operations", [])
                p_forbidden = p.get("forbidden_operations", [])
                ok, _ = gitea_config.check_operation(action, p_allowed, p_forbidden)
                if ok:
                    has_capable_profile = True
                    break
        if switching_supported and has_capable_profile:
            fixable_by_switch = True
        else:
            req_different_ns = True

    result["fixable_by_profile_switch"] = fixable_by_switch
    result["requires_different_namespace"] = req_different_ns

    # Determine safe next step
    safe_step = "Ready."
    if not result["eligible"]:
        if is_self:
            safe_step = "Self-review/self-merge is forbidden. Ask a different operator/reviewer to review and merge this PR."
        elif not auth_user:
            safe_step = f"Ask the operator to configure valid credentials/token for profile '{profile_name}'."
        elif not op_ok:
            if fixable_by_switch:
                safe_step = f"Switch to a reviewer profile by calling gitea_activate_profile with a profile that allows {action}."
            else:
                safe_step = "Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."
        elif result["pr_state"] != "open":
            safe_step = "No action can be taken on a closed/merged PR."
        elif action == "merge":
            if result["mergeable"] is False:
                safe_step = "Address conflicts in the PR branches first, or wait for CI checks to pass before merging."
            elif result["mergeable"] is None:
                safe_step = "Wait for Gitea to finish calculating mergeability or trigger a re-check."

    result["safe_next_step"] = safe_step
    return result


# Review actions this gated tool can perform, mapped to (eligibility action,
# Gitea review *event*). The eligibility action is fed to
# ``gitea_check_pr_eligibility`` (#14) so every mutation reuses the same
# identity/profile/author gates. Note: 'merge' is deliberately absent — merge
# belongs to a separate tool/issue and is never performed here.
_REVIEW_ACTIONS = {
    # 'comment' posts review findings without an approval/rejection state.
    # #14 names this eligibility category 'review'.
    "comment": ("review", "COMMENT"),
    # Gitea ReviewStateType uses APPROVED, not APPROVE — wrong event leaves PENDING (#244).
    "approve": ("approve", "APPROVED"),
    "request_changes": ("request_changes", "REQUEST_CHANGES"),
}

_TERMINAL_REVIEW_ACTIONS = frozenset({"approve", "request_changes"})

# Durable across MCP daemon process pools (#559), but never under /tmp (#211):
# host-global temp files are spoofable. Persistence uses the user-private
# cache directory from mcp_session_state (mode 0o700 / files 0o600), keyed by
# remote + profile identity with TTL.
_REVIEW_DECISION_LOCK: dict | None = None

# Session-scoped live MCP namespace health assessments (#543).
# Keyed by namespace name. Only client_namespace entries authorize mutations.
_LIVE_NAMESPACE_HEALTH: dict[str, dict] = {}


def _record_live_namespace_health(assessment: dict | None) -> None:
    """Store a namespace health assessment for mutation gates."""
    if not isinstance(assessment, dict):
        return
    ns = str(assessment.get("namespace") or "").strip()
    if not ns:
        return
    _LIVE_NAMESPACE_HEALTH[ns] = {
        "namespace": ns,
        "healthy": bool(assessment.get("healthy")),
        "ide_namespace_proven": bool(assessment.get("ide_namespace_proven")),
        "probe_source": assessment.get("probe_source"),
        "blocks_merge_workflow": bool(assessment.get("blocks_merge_workflow")),
        "error_type": assessment.get("error_type"),
        "required_tool": assessment.get("required_tool"),
    }


def _live_namespace_health_gate(task: str) -> list[str]:
    """Fail closed on recorded broken/non-client IDE namespace health (#543)."""
    return mcp_namespace_health.mutation_gate_from_session(
        task, _LIVE_NAMESPACE_HEALTH
    )


def _decision_lock_binding(lock: dict | None = None) -> dict:
    """Resolve key fields for durable decision-lock storage."""
    profile = get_profile()
    profile_name = (profile.get("profile_name") or "").strip()
    env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
    stored_lock = ((lock or {}).get("session_profile_lock") or "").strip()
    session_lock = env_lock or stored_lock or profile_name
    remote = ((lock or {}).get("remote") or "").strip() or None
    org = ((lock or {}).get("org") or (lock or {}).get("ready_org") or "").strip() or None
    repo = ((lock or {}).get("repo") or (lock or {}).get("ready_repo") or "").strip() or None
    return {
        "session_profile": profile_name,
        "session_profile_lock": session_lock,
        "profile_identity": mcp_session_state.current_profile_identity(
            profile_name=profile_name,
            session_profile_lock=session_lock,
        ),
        "remote": remote,
        "org": org,
        "repo": repo,
    }


def _load_review_decision_lock():
    """Load decision lock from memory, falling back to durable session state."""
    global _REVIEW_DECISION_LOCK
    if _REVIEW_DECISION_LOCK is not None:
        return _REVIEW_DECISION_LOCK
    binding = _decision_lock_binding()
    # Profile-keyed durable file; remote/org/repo validated from payload (#559).
    durable = mcp_session_state.load_state(
        kind=mcp_session_state.KIND_DECISION_LOCK,
        profile_identity=binding.get("profile_identity"),
    )
    if durable is not None:
        _REVIEW_DECISION_LOCK = dict(durable)
    return _REVIEW_DECISION_LOCK


def _save_review_decision_lock(data):
    """Persist decision lock to memory + durable shared state (#559)."""
    global _REVIEW_DECISION_LOCK
    if data is None:
        binding = _decision_lock_binding(_REVIEW_DECISION_LOCK)
        mcp_session_state.clear_state(
            kind=mcp_session_state.KIND_DECISION_LOCK,
            profile_identity=binding.get("profile_identity"),
        )
        _REVIEW_DECISION_LOCK = None
        return
    payload = dict(data)
    binding = _decision_lock_binding(payload)
    payload.setdefault("session_pid", os.getpid())
    payload["writer_pid"] = os.getpid()
    payload["session_profile"] = binding["session_profile"] or payload.get(
        "session_profile"
    )
    payload["session_profile_lock"] = binding["session_profile_lock"]
    payload["profile_identity"] = binding["profile_identity"]
    # #720: durable decision locks are recovery-critical terminal provenance,
    # not generic TTL session cache. Stamp kind + recovery_critical for
    # pre-existing readers and identity_match_reasons flag-based exempt.
    payload["kind"] = mcp_session_state.KIND_DECISION_LOCK
    payload["recovery_critical"] = True
    if binding.get("remote") and not payload.get("remote"):
        payload["remote"] = binding["remote"]
    # #695 AC6: stamp native transport provenance on durable decision locks.
    try:
        payload.update(
            {
                k: v
                for k, v in mcp_daemon_guard.mutation_provenance_fields().items()
                if v is not None
            }
        )
    except Exception:
        payload.setdefault("transport", "untrusted")
        payload.setdefault("native_mcp_transport", False)
    persisted = mcp_session_state.save_state(
        kind=mcp_session_state.KIND_DECISION_LOCK,
        payload=payload,
        remote=payload.get("remote"),
        org=payload.get("org") or payload.get("ready_org"),
        repo=payload.get("repo") or payload.get("ready_repo"),
        profile_identity=payload.get("profile_identity"),
    )
    _REVIEW_DECISION_LOCK = dict(persisted or payload)


def _review_decision_session_reasons(lock: dict | None) -> list[str]:
    """Reject locks that do not belong to this MCP session identity (#559).

    Different daemon PIDs in the same IDE session pool are allowed when the
    profile identity and remote match. Spoofed/stale locks still fail closed.
    """
    if lock is None:
        return []
    reasons = []
    env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
    stored_lock = (lock.get("session_profile_lock") or lock.get("profile_identity") or "").strip()
    if env_lock and stored_lock and env_lock != stored_lock:
        reasons.append(
            "review decision lock session profile lock mismatch (fail closed)"
        )
    # TTL / identity checks from durable envelope fields.
    for reason in mcp_session_state.identity_match_reasons(
        lock,
        remote=lock.get("remote"),
        org=lock.get("org") or lock.get("ready_org"),
        repo=lock.get("repo") or lock.get("ready_repo"),
        profile_identity=env_lock or stored_lock,
    ):
        if "profile identity mismatch" in reason or "expired" in reason or "future" in reason or "missing recorded_at" in reason:
            reasons.append(reason)
    return reasons


def init_review_decision_lock(remote: str | None, task: str | None, force: bool = True):
    """Seed read-only-until-ready state for reviewer PR review tasks.

    #709 AC2: never overwrite unresolved terminal decision-lock evidence with
    an empty initialized lock — even when *force* is True. Terminal ledgers
    are cleared only via moot cleanup or sanctioned recovery/archive paths.
    """
    if task != "review_pr":
        return
    existing = _load_review_decision_lock()
    if existing is not None:
        overwrite = stale_review_decision_lock.assess_init_overwrite(
            existing, force=force
        )
        if not overwrite.get("overwrite_allowed"):
            # Preserve terminal evidence; keep existing durable lock.
            return
        if not force:
            env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
            stored_lock = (lock_get_session_profile_lock(existing)).strip()
            same_remote = existing.get("remote") == remote
            same_profile = (not env_lock or not stored_lock or env_lock == stored_lock)
            if same_remote and same_profile and not _review_decision_session_reasons(existing):
                return
    review_workflow_load.clear_review_workflow_load()
    profile = get_profile()
    profile_name = (profile.get("profile_name") or "").strip()
    session_lock = (
        (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
        or profile_name
    )
    reviewer_pr_lease.clear_session_lease()
    _save_review_decision_lock({
        "task": task,
        "remote": remote,
        "session_pid": os.getpid(),
        "session_profile": profile_name,
        "session_profile_lock": session_lock,
        "profile_identity": mcp_session_state.current_profile_identity(
            profile_name=profile_name,
            session_profile_lock=session_lock,
        ),
        "final_review_decision_ready": False,
        "ready_pr_number": None,
        "ready_action": None,
        "ready_expected_head_sha": None,
        "ready_remote": None,
        "ready_org": None,
        "ready_repo": None,
        "live_mutations": [],
        "correction_authorized": False,
        "correction_reason": None,
        "correction_pr_number": None,
        "correction_head_sha": None,
    })


def lock_get_session_profile_lock(lock: dict | None) -> str:
    if not isinstance(lock, dict):
        return ""
    return (
        lock.get("session_profile_lock")
        or lock.get("profile_identity")
        or lock.get("session_profile")
        or ""
    )


def _review_workflow_load_gate_reasons() -> list[str]:
    """Fail closed when canonical review workflow was not loaded (#389)."""
    return review_workflow_load.review_workflow_load_blockers(PROJECT_ROOT)


def check_review_decision_gate(
    pr_number: int,
    action: str,
    *,
    final_review_decision_ready: bool,
    remote: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> list[str]:
    """Fail closed unless validation completed and the final decision is ready."""
    reasons = list(_review_workflow_load_gate_reasons())
    if reasons:
        reasons.extend(review_workflow_load.recovery_handoff_without_replay())
        return reasons
    lock = _load_review_decision_lock()
    if lock is None:
        reasons.append(
            "review decision lock missing; call gitea_resolve_task_capability "
            "for review_pr before live review mutations (fail closed)"
        )
        return reasons

    reasons.extend(_review_decision_session_reasons(lock))
    if reasons:
        return reasons

    if remote in REMOTES:
        _, org, repo = _resolve(remote, None, org, repo)

    if not final_review_decision_ready:
        reasons.append(
            "final_review_decision_ready must be true; validation-phase live "
            "review mutations are forbidden — use gitea_dry_run_pr_review "
            "instead (fail closed)"
        )
        return reasons

    if not lock.get("final_review_decision_ready"):
        reasons.append(
            "final review decision not marked ready; call "
            "gitea_mark_final_review_decision after validation completes "
            "(fail closed)"
        )
        return reasons

    if lock.get("ready_pr_number") != pr_number:
        reasons.append(
            f"ready PR #{lock.get('ready_pr_number')} does not match "
            f"requested PR #{pr_number} (fail closed)"
        )
    if lock.get("ready_action") != action:
        reasons.append(
            f"ready action '{lock.get('ready_action')}' does not match "
            f"requested action '{action}' (fail closed)"
        )
    if lock.get("ready_remote") != remote:
        reasons.append(
            f"ready remote '{lock.get('ready_remote')}' does not match "
            f"requested remote '{remote}' (fail closed)"
        )
    if lock.get("ready_org") != org:
        reasons.append(
            f"ready org '{lock.get('ready_org')}' does not match "
            f"requested org '{org}' (fail closed)"
        )
    if lock.get("ready_repo") != repo:
        reasons.append(
            f"ready repo '{lock.get('ready_repo')}' does not match "
            f"requested repo '{repo}' (fail closed)"
        )

    prior = list(lock.get("live_mutations") or [])
    ready_head = lock.get("ready_expected_head_sha")
    if stale_review_decision_lock.prior_live_mutations_block_boundary(
        lock, pr_number=pr_number, expected_head_sha=ready_head
    ):
        scoped = stale_review_decision_lock.correction_applies(
            lock, pr_number=pr_number, expected_head_sha=ready_head
        )
        if prior and not scoped:
            reasons.append(
                "live review mutation already recorded for this PR head in this "
                "run; only one live review mutation is allowed per head unless "
                "gitea_authorize_review_correction was invoked for this same "
                "PR/head (fail closed, #620/#693)"
            )
        elif (
            action in _TERMINAL_REVIEW_ACTIONS
            and any(
                isinstance(m, dict)
                and m.get("action") in _TERMINAL_REVIEW_ACTIONS
                and m.get("pr_number") == pr_number
                for m in prior
            )
            and not scoped
        ):
            reasons.append(
                "terminal review decision already submitted for this PR head in "
                "this run; blocked unless an operator-approved correction was "
                "authorized for this same PR/head (fail closed, #620/#693)"
            )

    return reasons


def record_live_review_mutation(pr_number: int, action: str, review_id: int | None = None):
    lock = _load_review_decision_lock() or {}
    mutations = list(lock.get("live_mutations") or [])
    head_sha = stale_review_decision_lock.normalize_head_sha(
        lock.get("ready_expected_head_sha")
    )
    entry = {
        "pr_number": pr_number,
        "action": action,
        "review_id": review_id,
        "review_state": action,
        # #695 AC6: audit records expose native session/transport provenance.
        **mcp_daemon_guard.mutation_provenance_fields(),
        "writer_pid": os.getpid(),
        "session_pid": lock.get("session_pid") or os.getpid(),
    }
    if head_sha:
        entry["head_sha"] = head_sha
    mutations.append(entry)
    lock["live_mutations"] = mutations
    if lock.get("correction_authorized"):
        lock["correction_authorized"] = False
        lock["correction_reason"] = None
        lock["correction_pr_number"] = None
        lock["correction_head_sha"] = None
    _save_review_decision_lock(lock)


def terminal_review_hard_stop_reasons(
    pr_number: int,
    operation: str,
    expected_head_sha: str | None = None,
) -> list[str]:
    """Session hard-stop after a terminal live review mutation (#332 / #620).

    After a terminal verdict is consumed for a given PR **head**, the only
    permitted continuation is the merge sequence for that same approved PR
    (merge does not require a new head). A REQUEST_CHANGES (or an approval of
    a different PR, or the same head) blocks further review/mark-ready/merge
    mutations for that boundary.

    #620: when *expected_head_sha* differs from the last terminal's reviewed
    head on the **same open PR**, mark_ready / review / resume may proceed so
    a fresh formal decision can be recorded for the new head. Historical
    mutations are preserved.

    An operator-approved correction (#211) re-opens the review path only —
    never a cross-PR merge.

    *operation* is one of 'merge', 'mark_ready', 'review', or 'resume'.
    Returns [] when the operation may proceed.
    """
    lock = _load_review_decision_lock()
    if lock is None:
        return []
    terminals = [
        m for m in (lock.get("live_mutations") or [])
        if m.get("action") in _TERMINAL_REVIEW_ACTIONS
    ]
    if not terminals:
        return []
    last = terminals[-1]
    if (
        operation == "merge"
        and last.get("action") == "approve"
        and last.get("pr_number") == pr_number
    ):
        return []
    if operation in ("mark_ready", "review", "resume") and (
        stale_review_decision_lock.correction_applies(
            lock, pr_number=pr_number, expected_head_sha=expected_head_sha
        )
    ):
        return []
    # #620 same-PR new head + #693 cross-PR isolation.
    if stale_review_decision_lock.terminal_boundary_allows_fresh_decision(
        lock,
        pr_number=pr_number,
        expected_head_sha=expected_head_sha,
        operation=operation,
    ):
        return []
    locked_head = stale_review_decision_lock.mutation_head_sha(last, lock)
    if last.get("action") == "approve":
        guidance = (
            f"only the merge sequence for approved PR "
            f"#{last.get('pr_number')} may continue"
        )
    else:
        guidance = "the session must stop and produce a final report"
    head_note = (
        f" at head {locked_head[:12]}…"
        if locked_head
        else ""
    )
    recovery = (
        "exact_next_action: if that PR is already merged/closed, use "
        "gitea_cleanup_stale_review_decision_lock(apply=true) after live-state "
        "proof (#594); if the same PR head moved, mark/submit with the new "
        "expected_head_sha (#620); if the target is a different PR, "
        "gitea_diagnose_review_decision_lock then mark_final on the target "
        "(cross-PR isolation, #693); if same-head verdict was mistaken, "
        "gitea_authorize_review_correction with matching target_pr_number "
        "(not a generic unlock); never delete session-state files by hand; "
        "if still blocked create/update a durable Gitea issue and stop"
    )
    return [
        "terminal review mutation already consumed in this run "
        f"({last.get('action')} on PR #{last.get('pr_number')}{head_note}); "
        f"{guidance} (fail closed, #332/#693); {recovery}"
    ]


# Patterns scrubbed from any surfaced error text so a credential can never leak.
_SECRET_PREFIXES = ("token ", "Basic ")


def _redact(text: str) -> str:
    """Strip anything that looks like an Authorization credential or raw URL from *text*.

    Errors raised by ``api_request`` echo the server response body, not the
    request headers, so a token should never appear — this is defence in depth
    so a future change can't leak ``token …`` / ``Basic …`` material into a
    tool result or log line.
    """
    if not text:
        return text
    out = text
    for prefix in _SECRET_PREFIXES:
        idx = 0
        while True:
            i = out.find(prefix, idx)
            if i == -1:
                break
            j = i + len(prefix)
            while j < len(out) and not out[j].isspace():
                j += 1
            out = out[:i] + prefix + "[REDACTED]" + out[j:]
            idx = i + len(prefix) + len("[REDACTED]")
    # Redact raw URLs, query secrets, hostnames, etc.
    import gitea_audit
    return gitea_audit.redact_urls(out)


# Review states that carry a submitted verdict. Gitea also emits PENDING
# (draft) and REQUEST_REVIEW (re-review ask); neither is a verdict and
# neither may drive the blocking/approval summaries.
_VERDICT_STATES = ("APPROVED", "REQUEST_CHANGES", "COMMENT")

# Terminal review events that must be visible after live submission (#244).
_SUBMIT_VISIBLE_EVENTS = frozenset({"APPROVED", "REQUEST_CHANGES"})


def _latest_review_state_for_reviewer(raw_reviews: list, reviewer: str) -> str | None:
    """Return the latest non-COMMENT verdict for *reviewer*, or None."""
    ordered = sorted(
        raw_reviews or [],
        key=lambda rv: ((rv.get("submitted_at") or ""), rv.get("id") or 0),
    )
    latest = None
    for rv in ordered:
        state = (rv.get("state") or "").upper()
        login = (rv.get("user") or {}).get("login", "")
        if login != reviewer or state not in _VERDICT_STATES or state == "COMMENT":
            continue
        latest = state
    return latest


def _submit_pending_pull_review(
    h: str,
    o: str,
    r: str,
    pr_number: int,
    review_id: int,
    event: str,
    body: str,
    auth,
) -> dict | None:
    """Submit a PENDING draft review via Gitea's pending-review endpoint."""
    submit_url = (
        f"{repo_api_url(h, o, r)}/pulls/{pr_number}/reviews/{review_id}"
    )
    return api_request("POST", submit_url, auth, {"body": body, "event": event})


@mcp.tool()
def gitea_get_pr_review_feedback(
    pr_number: int,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Read-only: discover formal PR review feedback through MCP (#167).

    Formal review verdicts (APPROVED / REQUEST_CHANGES / COMMENT) live on
    Gitea's review endpoints, not in the issue-comment thread. This tool is
    the MCP-native way to read them, so an LLM never has to infer review
    state from issue comments. It never submits reviews and never mutates
    anything; the profile must allow ``gitea.read`` (fail closed otherwise,
    with a structured #142 permission report and no API call made).

    Endpoints: ``GET /repos/{owner}/{repo}/pulls/{n}`` (current head SHA)
    and ``GET /repos/{owner}/{repo}/pulls/{n}/reviews``.

    Normal output is LLM-safe: review bodies pass through credential
    redaction and no endpoint URLs appear. Set GITEA_MCP_REVEAL_ENDPOINTS=1
    (admin/debug opt-in) to include each review's web link.

    Args:
        pr_number: The pull request index/number.
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.

    Returns:
        dict with 'success', 'pr_number', 'pr_state', 'current_head_sha',
        'reviews' (each: reviewer, verdict, body, submitted_at,
        reviewed_head_sha, dismissed, stale),
        'latest_review_state_by_reviewer' (latest non-COMMENT verdict per
        reviewer; PENDING drafts never count),
        'has_blocking_change_requests' (a reviewer's latest verdict is an
        undismissed REQUEST_CHANGES), 'approval_visible',
        'latest_reviewed_head_sha' (head at the most recent
        APPROVED/REQUEST_CHANGES review; COMMENT-only reviews never
        advance it), 'review_feedback_stale' (that verdict feedback
        predates the current head SHA), and
        'author_pushed_after_request_changes' (new commits landed after a
        blocking REQUEST_CHANGES). On a permission block: 'success' False,
        'feedback_not_attempted' True, 'reasons', and 'permission_report' —
        deliberately distinct from a successful "no reviews yet" result.
    """
    reasons = _profile_operation_gate("gitea.read")
    if reasons:
        return {
            "success": False,
            "pr_number": pr_number,
            "feedback_not_attempted": True,
            "reasons": reasons,
            "permission_report": _permission_block_report("gitea.read"),
        }
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    base = f"{repo_api_url(h, o, r)}/pulls/{pr_number}"
    pr = api_request("GET", base, auth) or {}
    raw_reviews = api_request("GET", f"{base}/reviews", auth) or []
    if not isinstance(pr, dict):
        return {
            "success": False,
            "pr_number": pr_number,
            "feedback_not_attempted": True,
            "reasons": ["PR payload unavailable for review feedback (fail closed)"],
        }
    if not isinstance(raw_reviews, list):
        raw_reviews = []
    current_head = (pr.get("head") or {}).get("sha")
    reveal = _reveal_endpoints()

    ordered = sorted(
        (rv for rv in raw_reviews if isinstance(rv, dict)),
        key=lambda rv: ((rv.get("submitted_at") or ""), rv.get("id") or 0),
    )
    reviews = []
    latest_by_reviewer = {}
    latest_reviewed_head = None
    quarantined_review_ids: set[int] = set()
    quarantined_at_head = 0
    for rv in ordered:
        state = (rv.get("state") or "").upper()
        reviewer = (rv.get("user") or {}).get("login", "")
        commit_id = rv.get("commit_id")
        rid = rv.get("id")
        try:
            rid_int = int(rid) if rid is not None else None
        except (TypeError, ValueError):
            rid_int = None
        q = review_quarantine.is_review_quarantined(
            remote=remote,
            org=o,
            repo=r,
            pr_number=pr_number,
            review_id=rid_int,
            reviewed_head_sha=commit_id or current_head,
        )
        entry = {
            "reviewer": reviewer,
            "verdict": state,
            "body": _redact(rv.get("body") or ""),
            "submitted_at": rv.get("submitted_at"),
            "reviewed_head_sha": commit_id,
            "review_id": rid_int,
            "dismissed": bool(rv.get("dismissed")),
            "stale": bool(rv.get("stale")) or bool(
                commit_id and current_head and commit_id != current_head),
            "quarantined": bool(q.get("quarantined")),
        }
        if q.get("quarantined"):
            entry["quarantine_reasons"] = q.get("reasons") or []
            if rid_int is not None:
                quarantined_review_ids.add(rid_int)
            if (
                state == "APPROVED"
                and not entry["dismissed"]
                and current_head
                and commit_id
                and commit_id == current_head
            ):
                quarantined_at_head += 1
        if reveal:
            entry["url"] = rv.get("html_url")
        reviews.append(entry)
        # COMMENT reviews never advance the reviewed-head marker or the
        # per-reviewer verdict — otherwise a drive-by comment on the
        # current head would mask the staleness of an older undismissed
        # REQUEST_CHANGES.
        # #695: quarantined formal reviews never authorize merge and must not
        # become the reviewer's latest active verdict for eligibility/merge.
        if state in _VERDICT_STATES and state != "COMMENT":
            if entry.get("quarantined"):
                continue
            latest_reviewed_head = commit_id or latest_reviewed_head
            if reviewer:
                latest_by_reviewer[reviewer] = entry

    blocking = [
        e for e in latest_by_reviewer.values()
        if e["verdict"] == "REQUEST_CHANGES" and not e["dismissed"]
    ]
    approvals = [
        e for e in latest_by_reviewer.values()
        if e["verdict"] == "APPROVED" and not e["dismissed"]
    ]
    approval_head = merge_approval_gate.assess_merge_approval_head(
        current_head_sha=current_head,
        latest_by_reviewer=latest_by_reviewer,
        quarantined_review_ids=quarantined_review_ids,
    )
    stale_reason = approval_head["stale_approval_block_reason"]
    # When the only approvals at head are quarantined, they were excluded from
    # latest_by_reviewer; surface an explicit #695 void reason for eligibility/merge.
    if (
        not approval_head["approval_at_current_head"]
        and quarantined_at_head
        and not stale_reason
    ):
        stale_reason = (
            "contaminated/quarantined approval at current head is void for "
            "merge authorization (#695); required next action: fresh native "
            "MCP re-review after controller quarantine evidence is recorded"
        )
    return {
        "success": True,
        "pr_number": pr_number,
        "pr_state": pr.get("state"),
        "current_head_sha": current_head,
        "reviews": reviews,
        "latest_review_state_by_reviewer": {
            login: e["verdict"] for login, e in latest_by_reviewer.items()},
        "has_blocking_change_requests": bool(blocking),
        "approval_visible": bool(approvals),
        "approval_at_current_head": approval_head["approval_at_current_head"],
        "latest_approved_head_sha": approval_head["latest_approved_head_sha"],
        "stale_approval_block_reason": stale_reason,
        "quarantined_review_ids": sorted(quarantined_review_ids),
        "quarantined_approvals_at_current_head": (
            max(
                approval_head.get("quarantined_approvals_at_current_head") or 0,
                quarantined_at_head,
            )
        ),
        "latest_reviewed_head_sha": latest_reviewed_head,
        "review_feedback_stale": bool(
            latest_reviewed_head and current_head
            and latest_reviewed_head != current_head),
        "author_pushed_after_request_changes": any(
            e["reviewed_head_sha"] and current_head
            and e["reviewed_head_sha"] != current_head
            for e in blocking),
        "native_runtime": mcp_daemon_guard.native_runtime_status(),
    }


def _fetch_pr_lease_comments_safe(
    pr_number: int,
    *,
    remote: str,
    host: str | None,
    org: str | None,
    repo: str | None,
    limit: int = 100,
    require_open: bool = False,
) -> dict:
    """Fetch PR thread comments with structured fail-closed errors (#519)."""
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    resolved_repo = f"{o}/{r}"
    pr_url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}"
    try:
        pr = api_request("GET", pr_url, auth)
    except RuntimeError as exc:
        return {
            "success": False,
            "comments": [],
            "reasons": [
                "PR lookup failed before conflict-fix push assessment "
                f"(pr_number={pr_number}, repo={resolved_repo}, remote={remote}): "
                f"{_redact(str(exc))}"
            ],
            "pr_lookup": "failed",
            "resolved_repo": resolved_repo,
            "remote": remote,
            "pr_number": pr_number,
        }
    pr_state = (pr.get("state") or "").strip().lower()
    if require_open and pr_state != "open":
        return {
            "success": False,
            "comments": [],
            "reasons": [
                f"PR #{pr_number} on {resolved_repo} is not open "
                f"(state={pr_state or 'unknown'})"
            ],
            "pr_lookup": "not_open",
            "resolved_repo": resolved_repo,
            "remote": remote,
            "pr_number": pr_number,
            "head_sha": (pr.get("head") or {}).get("sha"),
        }
    api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
    try:
        comments = api_request("GET", api, auth)
    except RuntimeError as exc:
        return {
            "success": False,
            "comments": [],
            "reasons": [
                "PR comment fetch failed during conflict-fix push assessment "
                f"(pr_number={pr_number}, issue_index={pr_number}, "
                f"repo={resolved_repo}, remote={remote}): "
                f"{_redact(str(exc))}"
            ],
            "pr_lookup": "ok",
            "resolved_repo": resolved_repo,
            "remote": remote,
            "pr_number": pr_number,
            "head_sha": (pr.get("head") or {}).get("sha"),
        }
    if not isinstance(comments, list):
        comments = []
    return {
        "success": True,
        "comments": list(comments[:limit]),
        "reasons": [],
        "pr_lookup": "ok",
        "resolved_repo": resolved_repo,
        "remote": remote,
        "pr_number": pr_number,
        "head_sha": (pr.get("head") or {}).get("sha"),
    }


def _list_pr_lease_comments(
    pr_number: int,
    *,
    remote: str,
    host: str | None,
    org: str | None,
    repo: str | None,
    limit: int = 100,
) -> list[dict]:
    """Fetch PR/issue thread comments used for reviewer/conflict-fix leases.

    Intentionally does **not** pre-fetch the PR via GET /pulls/{n}. Shared
    callers (merge approval feedback, reviewer lease gates) depend on a single
    comments GET so mock sequences and fail-open #485 non-list handling stay
    stable. Structured PR-lookup failures belong only to
    :func:`_fetch_pr_lease_comments_safe` used by conflict-fix push assessment
    (#519).
    """
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
    comments = api_request("GET", api, auth)
    # Fail safe to no lease comments when the API returns a non-list payload
    # (e.g. an error object such as an HTTP 401 body): lease state can only be
    # proven from real comment entries, never inferred from an error shape (#485).
    if not isinstance(comments, list):
        return []
    return list(comments[:limit])


def _pr_work_lease_reviewer_block(
    *,
    pr_number: int,
    reviewed_head_sha: str | None,
    live_head_sha: str | None,
    mutation: str,
    remote: str,
    host: str | None,
    org: str | None,
    repo: str | None,
) -> dict:
    comments = _list_pr_lease_comments(
        pr_number,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
    )
    return pr_work_lease.assess_reviewer_mutation_blocked(
        pr_number=pr_number,
        comments=comments,
        reviewed_head_sha=reviewed_head_sha,
        live_head_sha=live_head_sha,
        mutation=mutation,
    )


def _evaluate_pr_review_submission(
    pr_number: int,
    action: str,
    body: str = "",
    expected_head_sha: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    *,
    live: bool,
    final_review_decision_ready: bool = False,
    worktree_path: str | None = None,
) -> dict:
    """Shared gate chain for live submit and dry-run review tools."""
    action = (action or "").strip().lower()
    workflow_blockers = _review_workflow_load_gate_reasons() if live else []
    result = {
        "requested_action": action,
        "performed": False,
        "dry_run": not live,
        "would_perform": False,
        "authenticated_user": None,
        "profile_name": get_profile()["profile_name"],
        "pr_author": None,
        "pr_number": pr_number,
        "head_sha": None,
        "expected_head_sha": expected_head_sha,
        "remote": remote if remote in REMOTES else None,
        "reasons": [],
    }
    reasons = result["reasons"]
    try:
        _verify_role_mutation_workspace(
            remote,
            worktree_path=worktree_path,
            task="review_pr",
            org=org,
            repo=repo,
        )
    except RuntimeError as exc:
        result["blocker_kind"] = "workspace_role_binding"
        reasons.append(
            "workspace/role binding failed (fail closed, #723): "
            f"{_redact(str(exc))}"
        )
        return result
    if workflow_blockers:
        reasons.extend(workflow_blockers)
        reasons.extend(review_workflow_load.recovery_handoff_without_replay())
        return result
    if live:
        # #695 AC1/AC2: offline direct-import submit (PR #701 run_submit.py) fails closed.
        try:
            mcp_daemon_guard.assert_sanctioned_mutation_runtime(
                "gitea_submit_pr_review"
            )
            mcp_daemon_guard.assert_no_direct_import_bypass("gitea_submit_pr_review")
        except mcp_daemon_guard.UnsanctionedRuntimeError as exc:
            reasons.append(str(exc))
            return result
        ns_gate = _live_namespace_health_gate("review_pr")
        if ns_gate:
            reasons.extend(ns_gate)
            return result

    if action not in _REVIEW_ACTIONS:
        reasons.append(
            f"unknown review action '{action}'; expected one of "
            f"{sorted(_REVIEW_ACTIONS)}"
        )
        return result
    eligibility_action, event = _REVIEW_ACTIONS[action]

    if live:
        reasons.extend(check_review_decision_gate(
            pr_number,
            action,
            final_review_decision_ready=final_review_decision_ready,
            remote=remote,
            org=org,
            repo=repo,
        ))
        if reasons:
            return result

    elig = gitea_check_pr_eligibility(
        pr_number=pr_number,
        action=eligibility_action,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
    )
    result["authenticated_user"] = elig.get("authenticated_user")
    result["profile_name"] = elig.get("profile_name", result["profile_name"])
    result["pr_author"] = elig.get("pr_author")
    result["head_sha"] = elig.get("head_sha")
    if not elig.get("eligible"):
        reasons.append(
            f"eligibility check for '{eligibility_action}' failed (fail closed)"
        )
        reasons.extend(elig.get("reasons", []))
        if elig.get("permission_report"):
            result["permission_report"] = elig["permission_report"]
        return result

    if live:
        reasons.extend(_reviewer_pr_lease_gate(
            pr_number=pr_number,
            remote=remote,
            host=host,
            org=org,
            repo=repo,
            mutation=action,
            live_head_sha=result.get("head_sha"),
            pinned_head_sha=expected_head_sha,
        ))
        if reasons:
            return result

    auth_user = result["authenticated_user"]
    pr_author = result["pr_author"]
    if action == "approve" and auth_user and pr_author and auth_user == pr_author:
        reasons.append("self-approval blocked (authenticated user is PR author)")
        return result

    actual_sha = result["head_sha"]
    pinned_sha = expected_head_sha
    lock = _load_review_decision_lock() or {}
    if live and lock.get("ready_expected_head_sha"):
        pinned_sha = lock.get("ready_expected_head_sha")
    if live and not pinned_sha:
        reasons.append(
            "reviewed head SHA required before live review mutation (fail closed, #399)"
        )
        return result
    if pinned_sha and actual_sha and pinned_sha != actual_sha:
        reasons.append(
            "expected head SHA does not match current PR head (fail closed)"
        )
        return result
    if not actual_sha:
        reasons.append("PR head SHA unavailable (fail closed)")
        return result

    lease_block = _pr_work_lease_reviewer_block(
        pr_number=pr_number,
        reviewed_head_sha=pinned_sha,
        live_head_sha=actual_sha,
        mutation=action,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
    )
    if lease_block.get("block"):
        reasons.extend(lease_block.get("reasons") or [])
        result["pr_work_lease"] = lease_block
        return result

    if live:
        # #604: common anti-stomp with live head + lease proof (typed blocker).
        anti_task = {
            "approve": "approve_pr",
            "request_changes": "request_changes_pr",
            "comment": "submit_pr_review",
        }.get(action, "submit_pr_review")
        try:
            _run_anti_stomp_preflight(
                anti_task,
                remote=remote,
                worktree_path=worktree_path,
                org=org,
                repo=repo,
                expected_head_sha=pinned_sha,
                live_head_sha=actual_sha,
                require_head_sha=True,
                foreign_lease=False,
                terminal_lock_blocks=False,
            )
        except RuntimeError as exc:
            reasons.append(str(exc))
            return result

    if (body or "").strip():
        gate = _canonical_comment_gate(body, context="pr_review")
        if gate["blocked"]:
            reasons.extend(gate["reasons"])
            result["canonical_comment_validation"] = gate["canonical_comment_validation"]
            return result

    result["would_perform"] = True
    if not live:
        reasons.append(
            f"dry-run only: would submit '{event}' review on PR #{pr_number}; "
            "no live mutation performed"
        )
        return result

    # Gate 5 — in-process mutation authority (#199): the last check before
    # the mutating POST, using the identity the eligibility gate proved.
    try:
        verify_mutation_authority(remote, host, required_role="reviewer",
                                  active_identity=auth_user)
    except RuntimeError as e:
        reasons.append(str(e))
        return result

    h, o, r = _resolve(remote, host, org, repo)
    review_id = None
    submitted_state = None
    try:
        auth = _auth(h)
        review_url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}/reviews"
        payload = {"body": body, "event": event, "commit_id": actual_sha}
        resp = api_request("POST", review_url, auth, payload)
        if isinstance(resp, dict):
            review_id = resp.get("id")
            submitted_state = (resp.get("state") or "").upper() or None
        if (
            submitted_state == "PENDING"
            and review_id
            and event in _SUBMIT_VISIBLE_EVENTS
        ):
            resp = _submit_pending_pull_review(
                h, o, r, pr_number, review_id, event, body, auth,
            )
            if isinstance(resp, dict):
                submitted_state = (resp.get("state") or "").upper() or None
    except Exception as exc:  # noqa: BLE001 — redact before surfacing
        reasons.append(f"review submission failed: {_redact(str(exc))}")
        return result

    if action in _TERMINAL_REVIEW_ACTIONS:
        try:
            auth = _auth(h)
            raw_reviews = (
                api_request("GET", f"{review_url}", auth) or []
            )
            visible = _latest_review_state_for_reviewer(raw_reviews, auth_user)
        except Exception as exc:  # noqa: BLE001 — redact before surfacing
            reasons.append(
                f"could not verify submitted review verdict (fail closed): "
                f"{_redact(str(exc))}"
            )
            return result
        result["submitted_verdict"] = visible
        result["review_verdict_visible"] = visible == event
        if visible != event:
            reasons.append(
                f"review submission left verdict '{submitted_state or visible or 'PENDING'}'; "
                f"expected visible '{event}' for reviewer '{auth_user}' (fail closed)"
            )
            return result

    record_live_review_mutation(pr_number, action, review_id)
    result["performed"] = True
    reasons.append(f"all gates passed; submitted '{event}' review on PR #{pr_number}")
    return result


@mcp.tool()
def gitea_mark_final_review_decision(
    pr_number: int,
    action: str,
    expected_head_sha: str | None = None,
    remote: str = "dadeschools",
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Mark validation complete; the final review decision is ready to submit."""
    # #695 AC1/AC2: direct import / redirected session state cannot mark final.
    try:
        mcp_daemon_guard.assert_sanctioned_mutation_runtime(
            "gitea_mark_final_review_decision"
        )
        mcp_daemon_guard.assert_no_direct_import_bypass(
            "gitea_mark_final_review_decision"
        )
    except mcp_daemon_guard.UnsanctionedRuntimeError as exc:
        return {"marked_ready": False, "reasons": [str(exc)]}
    action = (action or "").strip().lower()
    lock = _load_review_decision_lock()
    if lock is None:
        return {
            "marked_ready": False,
            "reasons": [
                "review decision lock missing; resolve review_pr capability first"
            ],
            "exact_next_action": (
                "gitea_resolve_task_capability(task='review_pr') then retry "
                "mark_final"
            ),
            "durable_issue_handoff": {
                "required": True,
                "instruction": (
                    "If the lock cannot be seeded, create/update a durable "
                    "Gitea issue and stop (#693 AC8)"
                ),
            },
        }
    session_reasons = _review_decision_session_reasons(lock)
    if session_reasons:
        classification = stale_review_decision_lock.classify_review_decision_lock(
            lock,
            target_pr_number=pr_number,
            target_head_sha=expected_head_sha,
            session_blockers=session_reasons,
        )
        fail = stale_review_decision_lock.build_precise_mark_final_failure(
            classification
        )
        return {
            "marked_ready": False,
            **fail,
        }
    if remote != lock.get("remote"):
        return {
            "marked_ready": False,
            "reasons": [
                f"requested remote '{remote}' does not match locked remote "
                f"'{lock.get('remote')}' (fail closed)"
            ],
            "exact_next_action": "use the remote bound on the decision lock",
        }
    try:
        _, resolved_org, resolved_repo = _resolve(remote, None, org, repo)
    except ValueError as exc:
        return {"marked_ready": False, "reasons": [str(exc)]}
    if org is not None and org != resolved_org:
        return {
            "marked_ready": False,
            "reasons": [
                f"requested org '{org}' does not match resolved org "
                f"'{resolved_org}' (fail closed)"
            ],
        }
    if repo is not None and repo != resolved_repo:
        return {
            "marked_ready": False,
            "reasons": [
                f"requested repo '{repo}' does not match resolved repo "
                f"'{resolved_repo}' (fail closed)"
            ],
        }
    org = resolved_org
    repo = resolved_repo
    workflow_blockers = _review_workflow_load_gate_reasons()
    if workflow_blockers:
        return {
            "marked_ready": False,
            "reasons": workflow_blockers + (
                review_workflow_load.recovery_handoff_without_replay()),
        }
    hard_stop = terminal_review_hard_stop_reasons(
        pr_number, "mark_ready", expected_head_sha=expected_head_sha
    )
    if hard_stop:
        classification = stale_review_decision_lock.classify_review_decision_lock(
            lock,
            target_pr_number=pr_number,
            target_head_sha=expected_head_sha,
            active_profile_identity=_decision_lock_binding().get(
                "profile_identity"
            ),
        )
        fail = stale_review_decision_lock.build_precise_mark_final_failure(
            classification
        )
        # Prefer classification next_action when more precise; keep hard_stop
        # text as primary reasons for operators.
        return {
            "marked_ready": False,
            "reasons": hard_stop + list(fail.get("reasons") or []),
            "classification": fail.get("classification"),
            "exact_next_action": fail.get("exact_next_action"),
            "durable_issue_handoff": fail.get("durable_issue_handoff"),
        }
    if action not in _REVIEW_ACTIONS:
        return {
            "marked_ready": False,
            "reasons": [
                f"unknown review action '{action}'; expected one of "
                f"{sorted(_REVIEW_ACTIONS)}"
            ],
        }
    if not (expected_head_sha or "").strip():
        return {
            "marked_ready": False,
            "reasons": [
                "expected_head_sha required before marking final review "
                "decision (fail closed, #399)"
            ],
            "exact_next_action": "retry mark_final with expected_head_sha pin",
        }
    # Safe idempotency: already ready for same PR/action/head (#693 AC4).
    ready_head = stale_review_decision_lock.normalize_head_sha(
        lock.get("ready_expected_head_sha")
    )
    target_head = stale_review_decision_lock.normalize_head_sha(expected_head_sha)
    if (
        lock.get("final_review_decision_ready")
        and lock.get("ready_pr_number") == pr_number
        and lock.get("ready_action") == action
        and ready_head
        and target_head
        and stale_review_decision_lock.heads_equal(ready_head, target_head)
        and lock.get("ready_remote") == remote
        and lock.get("ready_org") == org
        and lock.get("ready_repo") == repo
    ):
        return {
            "marked_ready": True,
            "idempotent": True,
            "pr_number": pr_number,
            "action": action,
            "expected_head_sha": expected_head_sha,
            "remote": remote,
            "org": org,
            "repo": repo,
            "final_review_decision_ready": True,
            "head_scoped": True,
            "reasons": [
                "final review decision already marked ready for this PR/action/"
                "head (idempotent, #693)"
            ],
        }
    if stale_review_decision_lock.prior_live_mutations_block_boundary(
        lock, pr_number=pr_number, expected_head_sha=expected_head_sha
    ):
        classification = stale_review_decision_lock.classify_review_decision_lock(
            lock,
            target_pr_number=pr_number,
            target_head_sha=expected_head_sha,
        )
        fail = stale_review_decision_lock.build_precise_mark_final_failure(
            classification
        )
        return {
            "marked_ready": False,
            "reasons": [
                "cannot mark final decision after a live review mutation was "
                "already recorded for this PR head unless an operator-approved "
                "correction was authorized for this same PR/head "
                "(fail closed, #620/#693)"
            ]
            + list(fail.get("reasons") or []),
            "classification": fail.get("classification"),
            "exact_next_action": fail.get("exact_next_action"),
            "durable_issue_handoff": fail.get("durable_issue_handoff"),
        }
    elig = gitea_check_pr_eligibility(
        pr_number=pr_number,
        action="review",
        remote=remote,
        host=None,
        org=org,
        repo=repo,
    )
    live_head = elig.get("head_sha")
    lease_block = _pr_work_lease_reviewer_block(
        pr_number=pr_number,
        reviewed_head_sha=expected_head_sha,
        live_head_sha=live_head,
        mutation="mark_ready",
        remote=remote,
        host=None,
        org=org,
        repo=repo,
    )
    if lease_block.get("block"):
        return {
            "marked_ready": False,
            "reasons": lease_block.get("reasons") or [],
            "pr_work_lease": lease_block,
        }
    if action == "request_changes":
        # Duplicate request-changes suppression (#332): an unresolved
        # REQUEST_CHANGES at the current head must not be duplicated.
        feedback = gitea_get_pr_review_feedback(
            pr_number, remote=remote, org=org, repo=repo)
        if not feedback.get("success"):
            return {
                "marked_ready": False,
                "reasons": [
                    "could not verify existing request-changes state for "
                    f"PR #{pr_number}; refusing to submit a possibly "
                    "duplicate REQUEST_CHANGES (fail closed, #332)"
                ],
            }
        if (
            feedback.get("has_blocking_change_requests")
            and not feedback.get("review_feedback_stale")
        ):
            return {
                "marked_ready": False,
                "reasons": [
                    f"PR #{pr_number} already has an unresolved "
                    "REQUEST_CHANGES at the current head SHA; do not "
                    "duplicate the request-changes review (fail closed, "
                    "#332)"
                ],
            }
    # Preserve historical terminal head boundaries before advancing ready_* (#620).
    stale_review_decision_lock.backfill_terminal_heads_from_ready(lock)
    lock["final_review_decision_ready"] = True
    lock["ready_pr_number"] = pr_number
    lock["ready_action"] = action
    lock["ready_expected_head_sha"] = expected_head_sha
    lock["ready_remote"] = remote
    lock["ready_org"] = org
    lock["ready_repo"] = repo
    _save_review_decision_lock(lock)
    return {
        "marked_ready": True,
        "pr_number": pr_number,
        "action": action,
        "expected_head_sha": expected_head_sha,
        "remote": remote,
        "org": org,
        "repo": repo,
        "final_review_decision_ready": True,
        "head_scoped": True,
        "reasons": [],
    }


@mcp.tool()
def gitea_authorize_review_correction(
    prior_review_id: int,
    prior_review_state: str,
    reason: str,
    operator_authorized: bool = False,
    target_pr_number: int | None = None,
    expected_head_sha: str | None = None,
    remote: str = "dadeschools",
    org: str | None = None,
    repo: str | None = None,
    post_audit_comment: bool = True,
) -> dict:
    """Authorize one operator-approved correction after a mistaken live review.

    #693: correction is scoped to the named prior review's **PR and head**.
    It cannot unlock a different PR (no generic decision-lock bypass).
    Successful authorization posts a thread-visible audit on the target PR.
    """
    reason = (reason or "").strip()
    prior_review_state = (prior_review_state or "").strip().lower()
    lock = _load_review_decision_lock()
    if lock is None:
        return {"authorized": False, "reasons": ["review decision lock missing"]}
    session_reasons = _review_decision_session_reasons(lock)
    if session_reasons:
        return {"authorized": False, "reasons": session_reasons}
    if not operator_authorized:
        return {
            "authorized": False,
            "reasons": [
                "operator authorization is required for review correction "
                "(fail closed)"
            ],
        }
    prior = list(lock.get("live_mutations") or [])
    if not prior:
        return {
            "authorized": False,
            "reasons": ["no prior live review mutation to correct"],
        }
    last_mutation = prior[-1]
    last_review_id = last_mutation.get("review_id")
    last_review_state = last_mutation.get("action")
    last_pr = last_mutation.get("pr_number")
    last_head = stale_review_decision_lock.mutation_head_sha(last_mutation, lock)
    reasons = []
    if target_pr_number is None:
        reasons.append(
            "target_pr_number is required so correction cannot become a "
            "generic unlock (fail closed, #693)"
        )
    elif last_pr is not None and int(target_pr_number) != int(last_pr):
        reasons.append(
            f"target_pr_number #{target_pr_number} does not match last "
            f"recorded mutation PR #{last_pr}; correction cannot unlock a "
            f"different PR (fail closed, #693)"
        )
    if last_review_id is not None and last_review_id != prior_review_id:
        reasons.append(
            f"prior review ID '{prior_review_id}' does not match last "
            f"recorded review ID '{last_review_id}' (fail closed)"
        )
    if last_review_state != prior_review_state:
        reasons.append(
            f"prior review state '{prior_review_state}' does not match last "
            f"recorded review state '{last_review_state}' (fail closed)"
        )
    want_head = stale_review_decision_lock.normalize_head_sha(expected_head_sha)
    if want_head and last_head and not stale_review_decision_lock.heads_equal(
        want_head, last_head
    ):
        reasons.append(
            f"expected_head_sha does not match last mutation head "
            f"{last_head[:12]}… (fail closed, #693)"
        )
    if not reason:
        reasons.append("correction reason is required")
    try:
        actor = None
        h, o, r = _resolve(remote, None, org, repo)
        try:
            actor = _authenticated_username(h)
        except Exception:
            actor = None
    except Exception as exc:  # noqa: BLE001
        return {
            "authorized": False,
            "reasons": [f"could not resolve remote for audit: {_redact(str(exc))}"],
        }
    profile = get_profile()
    profile_name = (profile.get("profile_name") or "").strip() or None
    audit = stale_review_decision_lock.build_correction_audit_record(
        prior_review_id=prior_review_id,
        prior_review_state=prior_review_state,
        target_pr_number=target_pr_number if target_pr_number is not None else last_pr,
        target_head_sha=want_head or last_head,
        reason=reason,
        actor_username=actor,
        profile_name=profile_name,
        authorized=False,
    )
    if reasons:
        return {
            "authorized": False,
            "reasons": reasons,
            "audit": audit,
            "exact_next_action": (
                "use same-PR correction only, or for a different PR use "
                "gitea_diagnose_review_decision_lock / cross-PR isolation "
                "(#693) or moot cleanup (#594)"
            ),
        }
    scope_pr = int(target_pr_number) if target_pr_number is not None else int(last_pr)
    scope_head = want_head or last_head
    lock["correction_authorized"] = True
    lock["correction_reason"] = reason
    lock["correction_pr_number"] = scope_pr
    lock["correction_head_sha"] = scope_head
    _save_review_decision_lock(lock)
    audit = stale_review_decision_lock.build_correction_audit_record(
        prior_review_id=prior_review_id,
        prior_review_state=prior_review_state,
        target_pr_number=scope_pr,
        target_head_sha=scope_head,
        reason=reason,
        actor_username=actor,
        profile_name=profile_name,
        authorized=True,
    )
    report = {
        "authorized": True,
        "correction_reason": reason,
        "correction_pr_number": scope_pr,
        "correction_head_sha": scope_head,
        "audit": audit,
        "audit_comment_id": None,
        "reasons": [],
        "scope": "same_pr_head_only",
    }
    if post_audit_comment and scope_pr is not None:
        comment_block = _profile_operation_gate("gitea.pr.comment")
        if comment_block:
            report["audit_comment_skipped"] = comment_block
        else:
            try:
                auth = _auth(h)
                body = stale_review_decision_lock.format_correction_audit_comment(
                    audit
                )
                with _audited(
                    "comment_pr",
                    host=h,
                    remote=remote,
                    org=o,
                    repo=r,
                    pr_number=scope_pr,
                    request_metadata={"source": "authorize_review_correction"},
                ):
                    posted = api_request(
                        "POST",
                        f"{repo_api_url(h, o, r)}/issues/{scope_pr}/comments",
                        auth,
                        {"body": body},
                    )
                report["audit_comment_id"] = (posted or {}).get("id")
            except Exception as exc:  # noqa: BLE001
                report["audit_comment_error"] = _redact(str(exc))
    return report


@mcp.tool()
def gitea_diagnose_review_decision_lock(
    target_pr_number: int,
    expected_head_sha: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Read-only: classify durable review-decision lock state for a target PR (#693).

    Returns a deterministic classification, owner/evidence, exact next_action,
    and whether mark_final / cleanup / scoped correction are appropriate.
    Never mutates the lock and never authorizes correction.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    binding = _decision_lock_binding()
    lock = _load_review_decision_lock()
    session_blockers = _review_decision_session_reasons(lock) if lock else []
    last = stale_review_decision_lock.last_terminal_mutation(lock)
    pr_live = None
    pr_lookup_error = None
    last_pr = last.get("pr_number") if last else None
    if last_pr is not None:
        try:
            pr_live = api_request(
                "GET", f"{repo_api_url(h, o, r)}/pulls/{last_pr}", auth
            ) or {}
            if not pr_live:
                pr_lookup_error = "empty PR payload"
        except Exception as exc:  # noqa: BLE001
            pr_lookup_error = _redact(str(exc))
    classification = stale_review_decision_lock.classify_review_decision_lock(
        lock,
        target_pr_number=target_pr_number,
        target_head_sha=expected_head_sha,
        pr_live=pr_live,
        pr_lookup_error=pr_lookup_error,
        active_profile_identity=binding.get("profile_identity"),
        session_blockers=session_blockers,
    )
    recovery = stale_review_decision_lock.assess_open_pr_decision_lock_recovery(
        lock,
        target_pr_number=target_pr_number,
        target_head_sha=expected_head_sha,
        last_terminal_pr_live=pr_live,
        pr_lookup_error=pr_lookup_error,
        active_profile_identity=binding.get("profile_identity"),
        session_blockers=session_blockers,
    )
    try:
        actor = _authenticated_username(h)
    except Exception:
        actor = None
    return {
        "success": True,
        "remote": remote,
        "org": o,
        "repo": r,
        "authenticated_username": actor,
        "active_profile_identity": binding.get("profile_identity"),
        "classification": classification.get("classification"),
        "mark_final_allowed": classification.get("mark_final_allowed"),
        "cleanup_allowed": classification.get("cleanup_allowed"),
        "correction_eligible": classification.get("correction_eligible"),
        "recovery_allowed": recovery.get("recovery_allowed"),
        "recovery_mode": recovery.get("recovery_mode"),
        "exact_next_action": classification.get("next_action"),
        "owner_profile_identity": classification.get("owner_profile_identity"),
        "last_terminal_pr": classification.get("last_terminal_pr"),
        "last_terminal_action": classification.get("last_terminal_action"),
        "locked_head_sha": classification.get("locked_head_sha"),
        "target_pr_number": target_pr_number,
        "target_head_sha": stale_review_decision_lock.normalize_head_sha(
            expected_head_sha
        ),
        "lock_summary": classification.get("lock_summary"),
        "evidence": classification.get("evidence"),
        "reasons": list(classification.get("reasons") or []),
        "manual_file_delete_forbidden": True,
        "correction_as_generic_unlock_forbidden": True,
        "durable_issue_handoff": stale_review_decision_lock.build_precise_mark_final_failure(
            classification
        ).get("durable_issue_handoff"),
    }



def _record_post_merge_decision_recovery(
    *,
    pr_number: int,
    head_sha: str | None,
    merge_commit_sha: str | None,
    target_profile_identity: str | None,
    failed_step: str,
    error: str | None,
    remote: str | None,
    org: str | None,
    repo: str | None,
) -> dict:
    """Persist durable post-merge recovery-required state (#709 AC3)."""
    try:
        actor = None
        try:
            if remote in REMOTES:
                h, _, _ = _resolve(remote, None, org, repo)
                actor = _authenticated_username(h)
        except Exception:
            actor = None
        profile = get_profile()
        profile_name = (profile.get("profile_name") or "").strip() or None
        payload = stale_review_decision_lock.build_post_merge_recovery_record(
            pr_number=pr_number,
            head_sha=head_sha,
            merge_commit_sha=merge_commit_sha,
            target_profile_identity=target_profile_identity,
            failed_step=failed_step,
            error=error,
            remote=remote,
            org=org,
            repo=repo,
            actor_username=actor,
            profile_name=profile_name,
        )
        # Key by merger/active profile so the merging session owns the recovery row.
        binding = _decision_lock_binding()
        saved = mcp_session_state.save_state(
            kind=mcp_session_state.KIND_POST_MERGE_DECISION_RECOVERY,
            payload=payload,
            remote=remote,
            org=org,
            repo=repo,
            profile_identity=binding.get("profile_identity"),
        )
        return dict(saved or payload)
    except Exception as exc:  # noqa: BLE001
        return {"status": "recovery_record_failed", "error": _redact(str(exc))}


def _clear_decision_lock_for_profile(
    *,
    profile_identity: str,
    pr_number: int,
    expected_head_sha: str | None,
    remote: str | None,
    org: str | None,
    repo: str | None,
) -> dict:
    """Clear one profile's durable decision lock when it targets *pr_number* approve.

    #709 F3: require remote/org/repo scope match on load and exact head identity
    for any terminal match. PR-number-only fallback is forbidden — malformed,
    legacy incomplete, cross-repository, or wrong-head locks are never cleared.
    """
    try:
        import irrecoverable_provenance as _irp

        path_gate = _irp.assess_profile_path_identity(profile_identity)
        if not path_gate.get("valid"):
            return {
                "profile_identity": profile_identity,
                "cleared": False,
                "reason": "; ".join(path_gate.get("reasons") or ["invalid profile"]),
                "recovery_required": True,
            }
    except Exception:
        raw = str(profile_identity or "")
        if ".." in raw or "/" in raw or "\\" in raw:
            return {
                "profile_identity": profile_identity,
                "cleared": False,
                "reason": "profile identity path traversal rejected (fail closed, #709 F3)",
                "recovery_required": True,
            }

    # expected_head_sha is mandatory for destructive clear (#709 F3).
    want_head = stale_review_decision_lock.normalize_head_sha(expected_head_sha)
    if not want_head:
        return {
            "profile_identity": profile_identity,
            "cleared": False,
            "reason": (
                "expected_head_sha required for decision-lock clear "
                "(no PR-number-only fallback; fail closed, #709 F3)"
            ),
            "recovery_required": False,
        }
    if not (remote and org and repo):
        return {
            "profile_identity": profile_identity,
            "cleared": False,
            "reason": (
                "remote/org/repo required for decision-lock clear "
                "(no incomplete-identity clear; fail closed, #709 F3)"
            ),
            "recovery_required": False,
        }

    lock = mcp_session_state.load_state_for_profile(
        kind=mcp_session_state.KIND_DECISION_LOCK,
        profile_identity=profile_identity,
        remote=remote,
        org=org,
        repo=repo,
        skip_identity_match=True,
        enforce_repo_scope=True,
    )
    if lock is None:
        return {
            "profile_identity": profile_identity,
            "cleared": False,
            "reason": (
                "no durable lock for profile at exact remote/org/repo scope "
                "(or identity/expiry mismatch; fail closed)"
            ),
        }

    # Primary: approve of this PR at exact head.
    targets_approve = stale_review_decision_lock.lock_targets_merged_pr_approval(
        lock, pr_number=pr_number, expected_head_sha=want_head
    )
    if not targets_approve:
        # Secondary: any terminal for this PR **only** when head also matches.
        # Never fall back to PR-number alone (#709 F3 / review 434).
        last = stale_review_decision_lock.last_terminal_mutation(lock)
        if not last or last.get("pr_number") != pr_number:
            return {
                "profile_identity": profile_identity,
                "cleared": False,
                "reason": "lock terminal does not target this PR approval",
            }
        locked_head = stale_review_decision_lock.mutation_head_sha(last, lock)
        if not locked_head:
            return {
                "profile_identity": profile_identity,
                "cleared": False,
                "reason": (
                    "legacy/incomplete terminal head identity; refuse destructive "
                    "clear (inspect/report recovery-required only, #709 F3)"
                ),
                "recovery_required": True,
                "prior_summary": stale_review_decision_lock.lock_summary(lock),
            }
        if not stale_review_decision_lock.heads_equal(locked_head, want_head):
            return {
                "profile_identity": profile_identity,
                "cleared": False,
                "reason": (
                    "lock terminal head does not match expected_head_sha "
                    "(fail closed, #709 F3)"
                ),
            }

    # Archive then clear — archival is a hard prerequisite (#709 F8 review 438).
    # A failed, empty, or unconfirmed archive must never be followed by a clear:
    # that is exactly the terminal-evidence destruction #709 exists to prevent.
    archive_identity = f"{profile_identity}-archive-pr{pr_number}"
    archive_payload = {
        **dict(lock),
        "archived_reason": "post_merge_cross_profile_cleanup",
        "archived_for_pr": pr_number,
        "archived_for_head": want_head,
        "archived_remote": remote,
        "archived_org": org,
        "archived_repo": repo,
        "recovery_critical": True,
        "kind": mcp_session_state.KIND_DECISION_LOCK_ARCHIVE,
        # The copied lock carries the *source* profile's identity. Leaving it in
        # place makes save_state key the archive under that profile instead of
        # the archive identity, so the archive would silently overwrite/land
        # elsewhere and never read back (#709 F8 review 438).
        "profile_identity": archive_identity,
        "session_profile_lock": archive_identity,
        "archived_from_profile_identity": profile_identity,
    }

    def _archive_failure(step: str, detail: str) -> dict:
        """Retain the terminal lock and report an actionable, retryable failure."""
        try:
            _record_post_merge_decision_recovery(
                pr_number=int(pr_number),
                head_sha=want_head,
                merge_commit_sha=None,
                target_profile_identity=profile_identity,
                failed_step=step,
                error=detail,
                remote=remote,
                org=org,
                repo=repo,
            )
        except Exception as exc:  # noqa: BLE001
            detail = f"{detail}; recovery record write also failed: {_redact(str(exc))}"
        return {
            "profile_identity": profile_identity,
            "cleared": False,
            "archive_ok": False,
            "archive_failed_step": step,
            "archive_identity": archive_identity,
            "terminal_lock_retained": True,
            "recovery_required": True,
            "retry_safe": True,
            "reason": (
                f"decision-lock archival failed at {step}: {detail}. Terminal "
                "evidence retained and NOT cleared; resolve the archive failure "
                "and retry this cleanup (fail closed, #709 F8 review 438)"
            ),
            "prior_summary": stale_review_decision_lock.lock_summary(lock),
        }

    try:
        archived = mcp_session_state.save_state(
            kind=mcp_session_state.KIND_DECISION_LOCK_ARCHIVE,
            payload=archive_payload,
            remote=remote,
            org=org,
            repo=repo,
            profile_identity=archive_identity,
        )
    except Exception as exc:  # noqa: BLE001
        return _archive_failure("archive_save_state", _redact(str(exc)))
    if not archived:
        return _archive_failure(
            "archive_save_state",
            "save_state returned no durable archive record (false/empty result)",
        )

    # Durable read-back: a write that cannot be re-read is not an archive.
    try:
        confirmed = mcp_session_state.load_state_for_profile(
            kind=mcp_session_state.KIND_DECISION_LOCK_ARCHIVE,
            profile_identity=archive_identity,
            remote=remote,
            org=org,
            repo=repo,
            skip_identity_match=True,
            enforce_repo_scope=True,
        )
    except Exception as exc:  # noqa: BLE001
        return _archive_failure("archive_readback", _redact(str(exc)))
    if not confirmed:
        return _archive_failure(
            "archive_readback",
            "archive record could not be read back from durable session state",
        )
    if int(confirmed.get("archived_for_pr") or -1) != int(
        pr_number
    ) or not stale_review_decision_lock.heads_equal(
        confirmed.get("archived_for_head"), want_head
    ):
        return _archive_failure(
            "archive_readback",
            "archive read-back does not match the PR/head being cleaned "
            "(partial or stale archive)",
        )

    mcp_session_state.clear_state(
        kind=mcp_session_state.KIND_DECISION_LOCK,
        profile_identity=profile_identity,
        remote=remote,
        org=org,
        repo=repo,
    )
    # If this is the in-memory active profile lock, clear memory too.
    active = _decision_lock_binding().get("profile_identity")
    if active and active == profile_identity:
        global _REVIEW_DECISION_LOCK
        _REVIEW_DECISION_LOCK = None
    return {
        "profile_identity": profile_identity,
        "cleared": True,
        "archive_ok": True,
        "archive_identity": archive_identity,
        "reason": (
            f"cleared terminal lock for merged PR #{pr_number} "
            f"at head {want_head[:12]}… (exact-scope; durable archive confirmed)"
        ),
        "prior_summary": stale_review_decision_lock.lock_summary(lock),
    }


def _reconcile_decision_locks_after_merge(
    *,
    pr_number: int,
    head_sha: str | None,
    merge_commit_sha: str | None,
    remote: str,
    host: str,
    org: str,
    repo: str,
    auth,
) -> dict:
    """Cross-profile decision-lock reconcile after a successful merge (#709)."""
    report: dict = {
        "cleared_any": False,
        "profiles_scanned": [],
        "cleared_profiles": [],
        "audit_comment_ids": [],
        "recovery_required": False,
        "reason_lines": [],
        "applied": False,  # overall "historical applied cleanup" never claimed blindly
    }
    try:
        actor = _authenticated_username(host)
    except Exception:
        actor = None
    profile = get_profile()
    profile_name = (profile.get("profile_name") or "").strip() or None

    identities = list(mcp_session_state.list_decision_lock_profile_identities())
    active = _decision_lock_binding().get("profile_identity")
    if active and active not in identities:
        identities.append(active)
    # Always consider common role profiles so empty local merger lock cannot
    # hide a reviewer terminal ledger (#709 AC1).
    for candidate in ("prgs-reviewer", "prgs-merger", "prgs-author", "prgs-reconciler"):
        if candidate not in identities:
            identities.append(candidate)
    report["profiles_scanned"] = list(identities)

    for identity in identities:
        try:
            outcome = _clear_decision_lock_for_profile(
                profile_identity=identity,
                pr_number=pr_number,
                expected_head_sha=head_sha,
                remote=remote,
                org=org,
                repo=repo,
            )
        except Exception as exc:  # noqa: BLE001
            report["recovery_required"] = True
            report["reason_lines"].append(
                f"failed clearing decision lock for {identity}: {_redact(str(exc))}"
            )
            _record_post_merge_decision_recovery(
                pr_number=pr_number,
                head_sha=head_sha,
                merge_commit_sha=merge_commit_sha,
                target_profile_identity=identity,
                failed_step="clear_profile_lock",
                error=_redact(str(exc)),
                remote=remote,
                org=org,
                repo=repo,
            )
            continue
        if outcome.get("cleared"):
            report["cleared_any"] = True
            report["cleared_profiles"].append(identity)
            report["reason_lines"].append(
                f"cleared decision lock profile={identity} after merge of "
                f"PR #{pr_number} (#709)"
            )
            # Audit publication (AC4): post and require comment id for full reconcile.
            audit = stale_review_decision_lock.build_cleanup_audit_record(
                assessment={
                    "last_terminal_pr": pr_number,
                    "last_terminal_action": "approve",
                    "pr_state": "closed",
                    "pr_merged": True,
                    "pr_merged_or_closed": True,
                    "merge_commit_sha": merge_commit_sha,
                    "cleanup_allowed": True,
                    "is_moot": True,
                    "lock_summary": outcome.get("prior_summary"),
                    "reasons": [outcome.get("reason") or "cleared"],
                },
                actor_username=actor,
                profile_name=profile_name,
                applied=True,
            )
            audit["cleanup_target_profile"] = identity
            audit["issue_ref"] = "#709"
            comment_block = _profile_operation_gate("gitea.pr.comment")
            if comment_block:
                report["recovery_required"] = True
                report["reason_lines"].append(
                    f"audit comment blocked for {identity}: {comment_block}"
                )
                _record_post_merge_decision_recovery(
                    pr_number=pr_number,
                    head_sha=head_sha,
                    merge_commit_sha=merge_commit_sha,
                    target_profile_identity=identity,
                    failed_step="audit_comment_permission",
                    error=str(comment_block),
                    remote=remote,
                    org=org,
                    repo=repo,
                )
                continue
            try:
                body = stale_review_decision_lock.format_cleanup_audit_comment(audit)
                comment_url = f"{repo_api_url(host, org, repo)}/issues/{pr_number}/comments"
                with _audited(
                    "comment_pr",
                    host=host,
                    remote=remote,
                    org=org,
                    repo=repo,
                    pr_number=pr_number,
                    request_metadata={"source": "post_merge_decision_lock_reconcile"},
                ):
                    posted = api_request("POST", comment_url, auth, {"body": body})
                cid = (posted or {}).get("id")
                if not cid:
                    raise RuntimeError("audit comment missing id on readback")
                report["audit_comment_ids"].append(cid)
                report["reason_lines"].append(
                    f"audit comment published id={cid} for profile={identity}"
                )
            except Exception as exc:  # noqa: BLE001
                report["recovery_required"] = True
                report["reason_lines"].append(
                    f"audit comment failed for {identity}: {_redact(str(exc))}"
                )
                _record_post_merge_decision_recovery(
                    pr_number=pr_number,
                    head_sha=head_sha,
                    merge_commit_sha=merge_commit_sha,
                    target_profile_identity=identity,
                    failed_step="audit_comment_publish",
                    error=_redact(str(exc)),
                    remote=remote,
                    org=org,
                    repo=repo,
                )

    if report["cleared_any"] and not report["recovery_required"]:
        report["applied"] = True  # this-session successful cleanup only
    elif not report["cleared_any"]:
        report["reason_lines"].append(
            f"no cross-profile decision lock targeted PR #{pr_number} for cleanup"
        )
    return report


@mcp.tool()
def gitea_cleanup_stale_review_decision_lock(
    apply: bool = False,
    expected_terminal_pr: int | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    post_audit_comment: bool = True,
) -> dict:
    """Clear a durable #332 review-decision lock only when it is moot (#594).

    Read-first by default (``apply=False``). A lock is moot when its last
    terminal live mutation references a PR that is already **merged** or
    **closed** on live Gitea — so no same-PR merge sequence remains.

    Fail-closed when:
    - no lock / no terminal mutation
    - live PR is still open (active #332 hard-stop preserved)
    - live PR state cannot be fetched
    - active profile identity does not match the lock
    - apply requested without reviewer capability (``gitea.pr.review``)

    Never weakens #332 for open PRs. Never instructs manual session-state
    file deletion. On successful apply, clears memory + durable store and
    returns a durable audit record (optionally posted as a PR comment).
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "cleanup_performed": False,
            "is_moot": False,
            "cleanup_allowed": False,
            "mode": "apply" if apply else "read_only",
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }

    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    binding = _decision_lock_binding()
    active_identity = binding.get("profile_identity")
    lock = _load_review_decision_lock()
    # #720: when normal load yields no lock, inspect disk so assessment does not
    # silently report "absent" while an expired/non-critical envelope remains.
    disk_inspect = mcp_session_state.inspect_state_envelope(
        kind=mcp_session_state.KIND_DECISION_LOCK,
        profile_identity=active_identity,
    )
    last = stale_review_decision_lock.last_terminal_mutation(lock)
    pr_live = None
    pr_lookup_error = None
    last_pr = last.get("pr_number") if last else None

    if last_pr is not None:
        try:
            pr_live = api_request(
                "GET", f"{repo_api_url(h, o, r)}/pulls/{last_pr}", auth
            ) or {}
            if not pr_live:
                pr_lookup_error = "empty PR payload"
        except Exception as exc:  # noqa: BLE001 — surface redacted
            pr_lookup_error = _redact(str(exc))

    assessment = stale_review_decision_lock.assess_stale_review_decision_lock(
        lock,
        pr_live=pr_live,
        pr_lookup_error=pr_lookup_error,
        active_profile_identity=active_identity,
    )
    if lock is None and disk_inspect.get("on_disk"):
        assessment = dict(assessment)
        assessment["reasons"] = list(assessment.get("reasons") or []) + [
            "decision-lock file is present on disk but not loadable via normal "
            f"TTL/identity gates: {disk_inspect.get('summary')} "
            "(do not rm session-state files; #720)"
        ]
        assessment["disk_inspect"] = {
            k: disk_inspect.get(k)
            for k in (
                "on_disk",
                "has_payload",
                "age_hours",
                "age_exceeds_default_ttl",
                "recovery_critical",
                "ttl_exempt",
                "would_ttl_reject",
                "summary",
            )
        }

    # Optional pin: refuse apply against a different terminal PR than expected.
    if (
        expected_terminal_pr is not None
        and assessment.get("last_terminal_pr") is not None
        and int(expected_terminal_pr) != int(assessment["last_terminal_pr"])
    ):
        assessment = dict(assessment)
        assessment["cleanup_allowed"] = False
        assessment["reasons"] = list(assessment.get("reasons") or []) + [
            f"expected_terminal_pr #{expected_terminal_pr} does not match "
            f"last terminal PR #{assessment.get('last_terminal_pr')} "
            "(fail closed, #594)"
        ]

    try:
        actor = _authenticated_username(h)
    except Exception:
        actor = None
    profile = get_profile()
    profile_name = (profile.get("profile_name") or "").strip() or None

    audit = stale_review_decision_lock.build_cleanup_audit_record(
        assessment=assessment,
        actor_username=actor,
        profile_name=profile_name,
        applied=False,
    )

    report = {
        "success": True,
        "cleanup_performed": False,
        "mode": "apply" if apply else "read_only",
        "remote": remote,
        "org": o,
        "repo": r,
        "active_profile_identity": active_identity,
        "authenticated_username": actor,
        "has_lock": assessment.get("has_lock"),
        "is_moot": assessment.get("is_moot"),
        "cleanup_allowed": assessment.get("cleanup_allowed"),
        "last_terminal_pr": assessment.get("last_terminal_pr"),
        "last_terminal_action": assessment.get("last_terminal_action"),
        "locked_head_sha": assessment.get("locked_head_sha"),
        "current_pr_head_sha": assessment.get("current_pr_head_sha"),
        "stale_by_head": assessment.get("stale_by_head"),
        "fresh_review_on_current_head_allowed": assessment.get(
            "fresh_review_on_current_head_allowed"
        ),
        "pr_state": assessment.get("pr_state"),
        "pr_merged": assessment.get("pr_merged"),
        "pr_merged_or_closed": assessment.get("pr_merged_or_closed"),
        "merge_commit_sha": assessment.get("merge_commit_sha"),
        "lock_summary": assessment.get("lock_summary"),
        "disk_inspect": assessment.get("disk_inspect") or {
            k: disk_inspect.get(k)
            for k in (
                "on_disk",
                "has_payload",
                "age_hours",
                "age_exceeds_default_ttl",
                "recovery_critical",
                "ttl_exempt",
                "would_ttl_reject",
                "summary",
            )
        },
        "audit": audit,
        "audit_comment_id": None,
        "reasons": list(assessment.get("reasons") or []),
        "manual_file_delete_forbidden": True,
    }

    if not apply:
        return report

    if not assessment.get("cleanup_allowed"):
        report["success"] = False
        report["cleanup_skipped_reason"] = list(assessment.get("reasons") or [])
        return report

    if not actor:
        report["success"] = False
        report["reasons"] = [
            "authenticated identity could not be verified; refuse lock "
            "cleanup (fail closed, #594)"
        ]
        return report

    review_block = _profile_operation_gate("gitea.pr.review")
    if review_block:
        report["success"] = False
        report["reasons"] = review_block
        report["permission_report"] = _permission_block_report("gitea.pr.review")
        return report

    session_reasons = _review_decision_session_reasons(lock)
    if session_reasons:
        report["success"] = False
        report["reasons"] = session_reasons
        return report

    # Clear durable + in-memory lock only after all gates pass.
    prior_summary = assessment.get("lock_summary")
    _save_review_decision_lock(None)
    audit = stale_review_decision_lock.build_cleanup_audit_record(
        assessment=assessment,
        actor_username=actor,
        profile_name=profile_name,
        applied=True,
    )
    audit["prior_lock_summary"] = prior_summary
    report["cleanup_performed"] = True
    report["audit"] = audit
    report["reasons"] = list(assessment.get("reasons") or []) + [
        f"cleared durable review decision lock for moot terminal mutation "
        f"on PR #{assessment.get('last_terminal_pr')} (#594)"
    ]

    if post_audit_comment and assessment.get("last_terminal_pr") is not None:
        comment_block = _profile_operation_gate("gitea.pr.comment")
        if comment_block:
            report["audit_comment_skipped"] = comment_block
        else:
            try:
                body = stale_review_decision_lock.format_cleanup_audit_comment(
                    audit
                )
                comment_url = (
                    f"{repo_api_url(h, o, r)}/issues/"
                    f"{assessment['last_terminal_pr']}/comments"
                )
                with _audited(
                    "comment_pr",
                    host=h,
                    remote=remote,
                    org=o,
                    repo=r,
                    pr_number=assessment["last_terminal_pr"],
                    request_metadata={
                        "source": "cleanup_stale_review_decision_lock"
                    },
                ):
                    posted = api_request(
                        "POST", comment_url, auth, {"body": body}
                    )
                report["audit_comment_id"] = (posted or {}).get("id")
                if not report["audit_comment_id"]:
                    report["reconciled"] = False
                    report["recovery_required"] = True
                    report["reasons"] = list(report.get("reasons") or []) + [
                        "cleanup applied but audit comment id missing on readback "
                        "(#709 AC4 fail-closed for full reconcile)"
                    ]
                    _record_post_merge_decision_recovery(
                        pr_number=int(assessment["last_terminal_pr"]),
                        head_sha=assessment.get("locked_head_sha"),
                        merge_commit_sha=assessment.get("merge_commit_sha"),
                        target_profile_identity=active_identity,
                        failed_step="audit_comment_missing_id",
                        error="comment response missing id",
                        remote=remote,
                        org=o,
                        repo=r,
                    )
                else:
                    report["reconciled"] = True
            except Exception as exc:  # noqa: BLE001
                report["audit_comment_error"] = _redact(str(exc))
                report["reconciled"] = False
                report["recovery_required"] = True
                report["reasons"] = list(report.get("reasons") or []) + [
                    "cleanup applied but audit comment failed; recovery-required "
                    "recorded (#709 AC4)"
                ]
                try:
                    _record_post_merge_decision_recovery(
                        pr_number=int(assessment["last_terminal_pr"]),
                        head_sha=assessment.get("locked_head_sha"),
                        merge_commit_sha=assessment.get("merge_commit_sha"),
                        target_profile_identity=active_identity,
                        failed_step="audit_comment_publish",
                        error=_redact(str(exc)),
                        remote=remote,
                        org=o,
                        repo=r,
                    )
                except Exception:
                    pass
        if post_audit_comment is False:
            report["reconciled"] = False
            report["reasons"] = list(report.get("reasons") or []) + [
                "cleanup applied without audit comment (post_audit_comment=false); "
                "not fully reconciled (#709 AC4)"
            ]

    return report


def _irrecoverable_capability_gate() -> list[str] | None:
    """Dedicated recovery capability (gitea.read alone is insufficient)."""
    import irrecoverable_provenance as irp

    profile = get_profile()
    assessment = irp.assess_capability_for_irrecoverable_recovery(
        allowed_operations=profile.get("allowed_operations") or [],
        forbidden_operations=profile.get("forbidden_operations") or [],
        role_kind=profile.get("role") or profile.get("role_kind"),
        profile_name=profile.get("profile_name"),
    )
    if assessment.get("allowed"):
        return None
    return list(assessment.get("reasons") or ["capability denied"])


@mcp.tool()
def gitea_issue_irrecoverable_provenance_authorization(
    pr_number: int,
    expected_head_sha: str,
    incident_issue: int,
    incident_comment_id: int,
    decision_lock_id: str = "",
    confirmation: str = "",
    destroyed_subject: str | None = None,
    recorded_head_sha: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Mint a server-side authorization artifact for irrecoverable recovery (#709 F1/F5).

    Non-forgeable: requires production native MCP transport (or pytest), the
    dedicated ``gitea.decision_lock.irrecoverable_recovery`` capability (no
    reconciler equivalence), live head equality, durable HMAC key, and
    authoritative incident evidence (author + canonical content_digest).
    Confirmation is human intent only — never authorization. Caller Booleans
    are not accepted.
    """
    import irrecoverable_provenance as irp

    h, o, r = _resolve(remote, host, org, repo)
    report: dict = {
        "success": False,
        "performed": False,
        "authorization": None,
        "authorization_id": None,
        "pr_number": pr_number,
        "expected_head_sha": expected_head_sha,
        "incident_issue": incident_issue,
        "incident_comment_id": incident_comment_id,
        "reasons": [],
    }

    cap_block = _irrecoverable_capability_gate()
    if cap_block:
        report["reasons"].extend(cap_block)
        report["permission_report"] = {
            "required_operation": irp.CAPABILITY_IRRECOVERABLE_RECOVERY,
            "reasons": cap_block,
        }
        return report

    transport = irp.assess_transport_for_auth_mint()
    if not transport.get("allowed"):
        report["reasons"].extend(transport.get("reasons") or [])
        return report

    expected_confirm = irp.expected_confirmation(pr_number)
    if (confirmation or "").strip() != expected_confirm:
        report["reasons"].append(
            f"confirmation must equal exactly {expected_confirm!r} "
            "(human intent only; not an authorization credential; fail closed)"
        )
        return report

    if not (decision_lock_id or "").strip():
        report["reasons"].append(
            "decision_lock_id is required so evidence is bound to the exact "
            "decision lock being recovered (fail closed, #709 F7 review 438)"
        )
        return report

    try:
        actor = _authenticated_username(h)
    except Exception:
        actor = None
    if not actor:
        report["reasons"].append(
            "authenticated identity could not be verified (fail closed)"
        )
        return report
    actor_identity = _authenticated_actor(h)
    if actor_identity.get("user_id") is None:
        report["reasons"].append(
            "authenticated actor id could not be verified; display-name-only "
            "identity is not accepted (fail closed, #709 F7 review 438)"
        )
        return report
    profile = get_profile()
    profile_name = (profile.get("profile_name") or "").strip() or None

    # Live PR head (authoritative).
    live_head = None
    pr_state = None
    pr_err = None
    try:
        pr_live = api_request(
            "GET", f"{repo_api_url(h, o, r)}/pulls/{int(pr_number)}", _auth(h)
        )
        live_head = (pr_live or {}).get("head", {})
        if isinstance(live_head, dict):
            live_head = live_head.get("sha")
        else:
            live_head = (pr_live or {}).get("head_sha") or (pr_live or {}).get(
                "head_commit_sha"
            )
        pr_state = (pr_live or {}).get("state")
    except Exception as exc:  # noqa: BLE001
        pr_err = _redact(str(exc))
    head_gate = irp.assess_live_head_binding(
        expected_head_sha=expected_head_sha,
        live_head_sha=live_head,
        pr_lookup_error=pr_err,
        pr_state=pr_state,
    )
    if not head_gate.get("valid"):
        report["reasons"].extend(head_gate.get("reasons") or [])
        return report

    # Incident evidence live validation (author + canonical digest, #709 F5).
    comment_payload = None
    comment_err = None
    try:
        comment_payload = api_request(
            "GET",
            f"{repo_api_url(h, o, r)}/issues/comments/{int(incident_comment_id)}",
            _auth(h),
        )
    except Exception as exc:  # noqa: BLE001
        comment_err = _redact(str(exc))
    try:
        active_key_version = irp.auth_key_version()
    except irp.AuthSecretError as exc:
        report["reasons"].append(str(exc))
        return report
    incident_gate = irp.assess_incident_evidence(
        incident_issue=incident_issue,
        incident_comment_id=incident_comment_id,
        comment_payload=comment_payload if isinstance(comment_payload, dict) else None,
        comment_lookup_error=comment_err,
        expected_remote=remote,
        expected_org=o,
        expected_repo=r,
        expected_pr_number=pr_number,
        expected_head_sha=str(expected_head_sha),
        expected_recorded_head_sha=recorded_head_sha,
        expected_decision_lock_id=decision_lock_id.strip(),
        expected_recovery_action=irp.RECOVERY_ACTION_IRRECOVERABLE_PROVENANCE,
        expected_key_version=active_key_version,
        mint_actor_id=actor_identity.get("user_id"),
        mint_actor_username=actor,
        reject_self_authored=True,
    )
    if not incident_gate.get("valid"):
        report["reasons"].extend(incident_gate.get("reasons") or [])
        return report

    auth_profile = irp.auth_state_profile_identity(
        remote=remote,
        org=o,
        repo=r,
        pr_number=pr_number,
        expected_head_sha=str(expected_head_sha),
    )
    # Idempotent: return unconsumed matching auth.
    existing = mcp_session_state.load_state(
        kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH,
        remote=remote,
        org=o,
        repo=r,
        profile_identity=auth_profile,
    )
    if isinstance(existing, dict):
        v = irp.verify_authorization_artifact(
            existing,
            remote=remote,
            org=o,
            repo=r,
            pr_number=pr_number,
            expected_head_sha=str(expected_head_sha),
            incident_issue=incident_issue,
            incident_comment_id=incident_comment_id,
            require_unconsumed=True,
        )
        if v.get("valid"):
            report["success"] = True
            report["performed"] = False
            report["authorization"] = existing
            report["authorization_id"] = existing.get("authorization_id")
            report["reasons"].append(
                "idempotent: unconsumed matching authorization already present"
            )
            return report

    try:
        artifact = irp.build_authorization_artifact(
            remote=remote,
            org=o,
            repo=r,
            pr_number=pr_number,
            expected_head_sha=str(expected_head_sha),
            incident_issue=int(incident_issue),
            incident_comment_id=int(incident_comment_id),
            destroyed_subject=destroyed_subject,
            issuer_username=actor,
            issuer_profile=profile_name or "unknown",
            native_provenance=mcp_daemon_guard.mutation_provenance_fields(),
        )
    except irp.AuthSecretError as exc:
        report["reasons"].append(str(exc))
        return report
    artifact["kind"] = mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH
    saved = mcp_session_state.save_state(
        kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH,
        payload=artifact,
        remote=remote,
        org=o,
        repo=r,
        profile_identity=auth_profile,
    )
    report["authorization"] = dict(saved or artifact)
    report["authorization_id"] = report["authorization"].get("authorization_id")
    report["performed"] = True
    report["success"] = True
    report["reasons"].append(
        "issued server-side irrecoverable provenance authorization "
        "(non-forgeable; bound to remote/org/repo/PR/head/incident; "
        f"key_version={artifact.get('key_version')})"
    )
    return report


@mcp.tool()
def gitea_record_irrecoverable_decision_lock_provenance(
    pr_number: int,
    reason: str,
    confirmation: str = "",
    expected_head_sha: str | None = None,
    incident_issue: int | None = None,
    incident_comment_id: int | None = None,
    authorization_id: str | None = None,
    decision_lock_id: str = "",
    destroyed_subject: str | None = None,
    incident_ref: str | None = None,
    # Deprecated: retained so callers that still pass it get an explicit deny.
    operator_authorized: bool = False,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    post_audit_comment: bool = True,
) -> dict:
    """Record truthful absence of decision-lock cleanup proof (#709 AC5).

    Never emits applied=true or claims historical cleanup was proven.

    Authorization is a **server-side artifact** (see
    ``gitea_issue_irrecoverable_provenance_authorization``). Caller-supplied
    ``operator_authorized`` is **never** authorization evidence (review 434 F1).
    Confirmation is human intent only. ``expected_head_sha``,
    ``incident_issue``, and ``incident_comment_id`` are mandatory.
    """
    import irrecoverable_provenance as irp

    h, o, r = _resolve(remote, host, org, repo)
    expected_confirm = irp.expected_confirmation(pr_number)
    report = {
        "success": False,
        "performed": False,
        "applied": False,
        "historical_cleanup_proven": False,
        "status": "provenance_irrecoverable",
        "pr_number": pr_number,
        "expected_head_sha": expected_head_sha,
        "reasons": [],
        "record": None,
        "audit_comment_id": None,
        "authorization_id": authorization_id,
        "merger_may_accept": False,
    }

    # Explicitly reject self-assertable Boolean as sole/any authorization.
    if operator_authorized:
        report["reasons"].append(
            "operator_authorized is not accepted as authorization evidence "
            "(#709 F1 / review 434); mint a server-side authorization via "
            "gitea_issue_irrecoverable_provenance_authorization"
        )
        # Do not return yet — still report other failures — but never authorize.
        # Actually fail immediately so success cannot be claimed.
        return report

    cap_block = _irrecoverable_capability_gate()
    if cap_block:
        report["reasons"].extend(cap_block)
        report["permission_report"] = {
            "required_operation": irp.CAPABILITY_IRRECOVERABLE_RECOVERY,
            "reasons": cap_block,
        }
        return report

    transport = irp.assess_transport_for_auth_mint()
    if not transport.get("allowed"):
        report["reasons"].extend(transport.get("reasons") or [])
        return report

    if (confirmation or "").strip() != expected_confirm:
        report["reasons"].append(
            f"confirmation must equal exactly {expected_confirm!r} "
            "(human intent only; not authorization; fail closed)"
        )
        return report
    if not (reason or "").strip():
        report["reasons"].append("reason is required (fail closed)")
        return report
    if not expected_head_sha or not str(expected_head_sha).strip():
        report["reasons"].append(
            "expected_head_sha is mandatory (fail closed, #709 F1)"
        )
        return report
    if incident_issue is None or int(incident_issue) <= 0:
        report["reasons"].append(
            "incident_issue is mandatory (canonical incident evidence, #709 F1)"
        )
        return report
    if incident_comment_id is None or int(incident_comment_id) <= 0:
        report["reasons"].append(
            "incident_comment_id is mandatory (canonical incident evidence, #709 F1)"
        )
        return report
    # incident_ref alone is never sufficient (legacy arg ignored as authority).
    _ = incident_ref

    try:
        actor = _authenticated_username(h)
    except Exception:
        actor = None
    if not actor:
        report["reasons"].append(
            "authenticated identity could not be verified (fail closed)"
        )
        return report
    profile = get_profile()
    profile_name = (profile.get("profile_name") or "").strip() or None

    # Live head must match.
    live_head = None
    pr_err = None
    try:
        pr_live = api_request(
            "GET", f"{repo_api_url(h, o, r)}/pulls/{int(pr_number)}", _auth(h)
        )
        live_head = (pr_live or {}).get("head", {})
        if isinstance(live_head, dict):
            live_head = live_head.get("sha")
        else:
            live_head = (pr_live or {}).get("head_sha") or (pr_live or {}).get(
                "head_commit_sha"
            )
    except Exception as exc:  # noqa: BLE001
        pr_err = _redact(str(exc))
    head_gate = irp.assess_live_head_binding(
        expected_head_sha=expected_head_sha,
        live_head_sha=live_head,
        pr_lookup_error=pr_err,
    )
    if not head_gate.get("valid"):
        report["reasons"].extend(head_gate.get("reasons") or [])
        return report

    # Re-validate incident evidence at record time.
    comment_payload = None
    comment_err = None
    try:
        comment_payload = api_request(
            "GET",
            f"{repo_api_url(h, o, r)}/issues/comments/{int(incident_comment_id)}",
            _auth(h),
        )
    except Exception as exc:  # noqa: BLE001
        comment_err = _redact(str(exc))
    try:
        active_key_version = irp.auth_key_version()
    except irp.AuthSecretError as exc:
        report["reasons"].append(str(exc))
        return report
    incident_gate = irp.assess_incident_evidence(
        incident_issue=incident_issue,
        incident_comment_id=incident_comment_id,
        comment_payload=comment_payload if isinstance(comment_payload, dict) else None,
        comment_lookup_error=comment_err,
        expected_remote=remote,
        expected_org=o,
        expected_repo=r,
        expected_pr_number=pr_number,
        expected_head_sha=str(expected_head_sha),
        expected_decision_lock_id=(decision_lock_id or "").strip() or None,
        expected_recovery_action=irp.RECOVERY_ACTION_IRRECOVERABLE_PROVENANCE,
        expected_key_version=active_key_version,
        mint_actor_id=_authenticated_actor(h).get("user_id"),
        mint_actor_username=actor,
        reject_self_authored=True,
    )
    if not incident_gate.get("valid"):
        report["reasons"].extend(incident_gate.get("reasons") or [])
        return report

    # Load server-side authorization artifact (by scope; optional id check).
    auth_profile = irp.auth_state_profile_identity(
        remote=remote,
        org=o,
        repo=r,
        pr_number=pr_number,
        expected_head_sha=str(expected_head_sha),
    )
    authorization = mcp_session_state.load_state(
        kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH,
        remote=remote,
        org=o,
        repo=r,
        profile_identity=auth_profile,
    )
    if not isinstance(authorization, dict):
        report["reasons"].append(
            "no server-side authorization artifact for this exact scope; call "
            "gitea_issue_irrecoverable_provenance_authorization first (fail closed)"
        )
        return report
    if authorization_id and authorization.get("authorization_id") != authorization_id:
        report["reasons"].append(
            "authorization_id does not match durable artifact for this scope "
            "(fail closed)"
        )
        return report
    auth_check = irp.verify_authorization_artifact(
        authorization,
        remote=remote,
        org=o,
        repo=r,
        pr_number=pr_number,
        expected_head_sha=str(expected_head_sha),
        incident_issue=int(incident_issue),
        incident_comment_id=int(incident_comment_id),
        require_unconsumed=True,
    )
    if not auth_check.get("valid"):
        report["reasons"].extend(auth_check.get("reasons") or [])
        return report

    recovery_profile = irp.recovery_state_profile_identity(
        remote=remote,
        org=o,
        repo=r,
        pr_number=pr_number,
        expected_head_sha=str(expected_head_sha),
    )
    existing = mcp_session_state.load_state(
        kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE,
        remote=remote,
        org=o,
        repo=r,
        profile_identity=recovery_profile,
    )
    if (
        isinstance(existing, dict)
        and existing.get("pr_number") == pr_number
        and stale_review_decision_lock.heads_equal(
            existing.get("head_sha"), expected_head_sha
        )
        and existing.get("status") == "provenance_irrecoverable"
        and existing.get("authorization_id") == authorization.get("authorization_id")
    ):
        report["success"] = True
        report["performed"] = False
        report["record"] = existing
        report["merger_may_accept"] = bool(existing.get("merger_may_accept"))
        report["authorization_id"] = existing.get("authorization_id")
        report["reasons"].append(
            "idempotent: matching irrecoverable record already present"
        )
        return report

    record = irp.build_irrecoverable_provenance_record(
        pr_number=pr_number,
        head_sha=str(expected_head_sha),
        remote=remote,
        org=o,
        repo=r,
        actor_username=actor,
        profile_name=profile_name,
        reason=reason.strip(),
        incident_issue=int(incident_issue),
        incident_comment_id=int(incident_comment_id),
        authorization=authorization,
        destroyed_subject=destroyed_subject,
    )
    if not record.get("merger_may_accept"):
        report["reasons"].append(
            "built record is not merger-acceptable (authorization verify failed)"
        )
        report["record"] = record
        return report

    record["kind"] = mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE
    saved = mcp_session_state.save_state(
        kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE,
        payload=record,
        remote=remote,
        org=o,
        repo=r,
        profile_identity=recovery_profile,
    )
    report["record"] = dict(saved or record)
    report["performed"] = True
    report["success"] = True
    report["merger_may_accept"] = True
    report["authorization_id"] = record.get("authorization_id")
    report["reasons"].append(
        "recorded provenance_irrecoverable (applied=false; historical cleanup "
        "not proven; server authorization bound)"
    )

    if post_audit_comment:
        issue_block = _profile_operation_gate("gitea.issue.comment")
        if issue_block:
            report["reasons"].append(f"audit comment skipped: {issue_block}")
        else:
            try:
                body = irp.format_irrecoverable_audit_comment(report["record"])
                comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
                with _audited(
                    "comment_issue",
                    host=h,
                    remote=remote,
                    org=o,
                    repo=r,
                    issue_number=pr_number,
                    request_metadata={
                        "source": "record_irrecoverable_decision_lock_provenance"
                    },
                ):
                    posted = api_request(
                        "POST",
                        comment_url,
                        _auth(h),
                        {"body": body},
                    )
                report["audit_comment_id"] = (posted or {}).get("id")
                if report["audit_comment_id"]:
                    report["record"]["audit_comment_id"] = report["audit_comment_id"]
                    mcp_session_state.save_state(
                        kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE,
                        payload=report["record"],
                        remote=remote,
                        org=o,
                        repo=r,
                        profile_identity=recovery_profile,
                    )
            except Exception as exc:  # noqa: BLE001
                report["reasons"].append(
                    f"audit comment failed: {_redact(str(exc))} (record still durable)"
                )
    return report


@mcp.tool()
def gitea_consume_irrecoverable_decision_lock_provenance(
    pr_number: int,
    expected_head_sha: str,
    confirmation: str = "",
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Merger-side fail-closed consumption of an irrecoverable recovery record (#709 F2).

    Resolves **only** the historical prior-provenance blocker for the exact
    remote/org/repo/PR/head. Never bypasses approval, change-requests, lease,
    mergeability, anti-stomp, runtime, or workspace gates. Consumption is
    durable, auditable, and idempotent.
    """
    import irrecoverable_provenance as irp

    h, o, r = _resolve(remote, host, org, repo)
    expected_confirm = f"CONSUME IRRECOVERABLE PROVENANCE PR {int(pr_number)}"
    report: dict = {
        "success": False,
        "performed": False,
        "pr_number": pr_number,
        "expected_head_sha": expected_head_sha,
        "historical_cleanup_proven": False,
        "historical_cleanup_not_proven": True,
        "irrecoverable_recovery_authorized": False,
        "recovery_record_consumed": False,
        "resolves_prior_provenance_blocker": False,
        "normal_approval_and_merge_gates": "not_substituted",
        "reasons": [],
        "assessment": None,
    }

    # Merger or reconciler may consume; gitea.read alone insufficient.
    merge_block = _profile_operation_gate("gitea.pr.merge")
    cap_block = _irrecoverable_capability_gate()
    if merge_block and cap_block:
        report["reasons"].append(
            "consume requires gitea.pr.merge (merger) or irrecoverable-recovery "
            "capability (reconciler); gitea.read alone is insufficient (#709 F2)"
        )
        if merge_block:
            report["reasons"].extend(
                merge_block if isinstance(merge_block, list) else [str(merge_block)]
            )
        report["reasons"].extend(cap_block)
        return report

    if (confirmation or "").strip() != expected_confirm:
        report["reasons"].append(
            f"confirmation must equal exactly {expected_confirm!r} "
            "(human intent; fail closed)"
        )
        return report

    try:
        actor = _authenticated_username(h)
    except Exception:
        actor = None
    if not actor:
        report["reasons"].append("authenticated identity unverified (fail closed)")
        return report
    profile = get_profile()
    profile_name = (profile.get("profile_name") or "").strip() or None

    # Live head.
    live_head = None
    pr_err = None
    try:
        pr_live = api_request(
            "GET", f"{repo_api_url(h, o, r)}/pulls/{int(pr_number)}", _auth(h)
        )
        live_head = (pr_live or {}).get("head", {})
        if isinstance(live_head, dict):
            live_head = live_head.get("sha")
        else:
            live_head = (pr_live or {}).get("head_sha")
    except Exception as exc:  # noqa: BLE001
        pr_err = _redact(str(exc))
    head_gate = irp.assess_live_head_binding(
        expected_head_sha=expected_head_sha,
        live_head_sha=live_head,
        pr_lookup_error=pr_err,
    )
    if not head_gate.get("valid"):
        report["reasons"].extend(head_gate.get("reasons") or [])
        return report

    recovery_profile = irp.recovery_state_profile_identity(
        remote=remote,
        org=o,
        repo=r,
        pr_number=pr_number,
        expected_head_sha=str(expected_head_sha),
    )
    recovery = mcp_session_state.load_state(
        kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE,
        remote=remote,
        org=o,
        repo=r,
        profile_identity=recovery_profile,
    )
    auth_profile = irp.auth_state_profile_identity(
        remote=remote,
        org=o,
        repo=r,
        pr_number=pr_number,
        expected_head_sha=str(expected_head_sha),
    )
    authorization = mcp_session_state.load_state(
        kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH,
        remote=remote,
        org=o,
        repo=r,
        profile_identity=auth_profile,
    )

    # Pull formal review state so consumption cannot be claimed when normal
    # gates would fail (assessment only — does not merge).
    feedback = gitea_get_pr_review_feedback(
        pr_number=pr_number, remote=remote, host=host, org=org, repo=repo
    )
    assessment = irp.assess_merger_consumption(
        recovery if isinstance(recovery, dict) else None,
        authorization if isinstance(authorization, dict) else None,
        remote=remote,
        org=o,
        repo=r,
        pr_number=pr_number,
        live_head_sha=live_head,
        approval_at_current_head=feedback.get("approval_at_current_head")
        if feedback.get("success")
        else None,
        has_blocking_change_requests=feedback.get("has_blocking_change_requests")
        if feedback.get("success")
        else None,
    )
    report["assessment"] = assessment
    report["reasons"].extend(assessment.get("reasons") or [])
    report["irrecoverable_recovery_authorized"] = bool(
        assessment.get("irrecoverable_recovery_authorized")
    )
    report["resolves_prior_provenance_blocker"] = bool(
        assessment.get("resolves_prior_provenance_blocker")
    )
    report["historical_cleanup_proven"] = False
    report["historical_cleanup_not_proven"] = True

    if not assessment.get("allowed") and not assessment.get(
        "recovery_record_consumed"
    ):
        return report

    # Atomic-ish durable consumption (auth then recovery under exclusive locks
    # via save_state).
    if isinstance(authorization, dict) and not authorization.get("consumed_at"):
        consumed_auth = irp.mark_consumed(
            authorization,
            consumer_username=actor,
            consumer_profile=profile_name,
        )
        mcp_session_state.save_state(
            kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH,
            payload=consumed_auth,
            remote=remote,
            org=o,
            repo=r,
            profile_identity=auth_profile,
        )
    if isinstance(recovery, dict) and not recovery.get("consumed_at"):
        consumed_rec = irp.mark_consumed(
            recovery,
            consumer_username=actor,
            consumer_profile=profile_name,
        )
        consumed_rec["prior_provenance_blocker_resolved"] = True
        saved = mcp_session_state.save_state(
            kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE,
            payload=consumed_rec,
            remote=remote,
            org=o,
            repo=r,
            profile_identity=recovery_profile,
        )
        report["record"] = dict(saved or consumed_rec)
        report["performed"] = True
    else:
        report["record"] = recovery
        report["performed"] = False

    report["recovery_record_consumed"] = True
    report["success"] = True
    report["resolves_prior_provenance_blocker"] = True
    report["reasons"].append(
        "prior-provenance blocker resolved for exact scope; historical cleanup "
        "remains unproven; normal merge gates still required"
    )
    return report


@mcp.tool()
def gitea_dry_run_pr_review(
    pr_number: int,
    action: str,
    body: str = "",
    expected_head_sha: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    worktree_path: str | None = None,
) -> dict:
    """Validate review submission mechanics without a live PR mutation."""
    return _evaluate_pr_review_submission(
        pr_number=pr_number,
        action=action,
        body=body,
        expected_head_sha=expected_head_sha,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
        live=False,
        worktree_path=worktree_path,
    )


@mcp.tool()
@_audit_pr_result("submit_pr_review")
def gitea_submit_pr_review(
    pr_number: int,
    action: str,
    body: str = "",
    expected_head_sha: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    final_review_decision_ready: bool = False,
    worktree_path: str | None = None,
) -> dict:
    """Gated PR review mutation: comment findings, request changes, or approve.

    Live mutations require ``final_review_decision_ready=True`` and a prior
    ``gitea_mark_final_review_decision`` call. Use ``gitea_dry_run_pr_review``
    during validation instead of probing with live submissions.
    """
    return _evaluate_pr_review_submission(
        pr_number=pr_number,
        action=action,
        body=body,
        expected_head_sha=expected_head_sha,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
        live=True,
        final_review_decision_ready=final_review_decision_ready,
        worktree_path=worktree_path,
    )


@mcp.tool()
def gitea_save_review_draft(
    pr_number: int,
    action: str,
    body: str = "",
    expected_head_sha: str | None = None,
    validation_commands: str | None = None,
    validation_results: str | None = None,
    blocker_reason: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    worktree_path: str | None = None,
) -> dict:
    """Save a prepared review verdict as non-terminal draft evidence without submitting a formal review.

    This permits resuming the review later (e.g. after a stale runtime restart or connection drop).
    """
    action = (action or "").strip().lower()
    if action not in _REVIEW_ACTIONS:
        return {
            "success": False,
            "reasons": [
                f"unknown review action '{action}'; expected one of "
                f"{sorted(_REVIEW_ACTIONS)}"
            ],
        }

    # 1. Resolve remote context
    try:
        h, resolved_org, resolved_repo = _resolve(remote, host, org, repo)
    except ValueError as exc:
        return {"success": False, "reasons": [str(exc)]}

    auth = _auth(h)

    # 2. Fetch current PR details to get target base branch and base branch SHA
    try:
        pr_url = f"{repo_api_url(h, resolved_org, resolved_repo)}/pulls/{pr_number}"
        pr = api_request("GET", pr_url, auth) or {}
    except Exception as exc:
        return {
            "success": False,
            "reasons": [f"failed to fetch PR #{pr_number} from Gitea: {str(exc)}"],
        }

    current_head = (pr.get("head") or {}).get("sha")
    target_branch = (pr.get("base") or {}).get("ref")
    target_branch_sha = (pr.get("base") or {}).get("sha")

    if expected_head_sha and current_head and expected_head_sha != current_head:
        return {
            "success": False,
            "reasons": [
                f"expected_head_sha '{expected_head_sha}' does not match current PR head SHA '{current_head}'"
            ],
        }

    # 3. Resolve worktree path
    try:
        resolved_worktree = _resolve_preflight_workspace_path(worktree_path)
    except Exception as exc:
        return {"success": False, "reasons": [f"failed to resolve worktree path: {str(exc)}"]}

    # 4. Save payload to durable session state
    profile = get_profile()
    payload = {
        "pr_number": pr_number,
        "action": action,
        "body": body,
        "head_sha": expected_head_sha or current_head,
        "target_branch": target_branch,
        "target_branch_sha": target_branch_sha,
        "worktree_path": resolved_worktree,
        "validation_commands": validation_commands,
        "validation_results": validation_results,
        "blocker_reason": blocker_reason,
        "saved_by_identity": _authenticated_username(h) or "",
        "saved_by_profile": profile.get("profile_name"),
        "remote": remote,
        "org": resolved_org,
        "repo": resolved_repo,
    }

    try:
        mcp_session_state.save_state(
            kind=mcp_session_state.KIND_REVIEW_DRAFT,
            payload=payload,
            remote=remote,
            org=resolved_org,
            repo=resolved_repo,
            profile_identity=profile.get("profile_name"),
        )
    except Exception as exc:
        return {"success": False, "reasons": [f"failed to save draft state: {str(exc)}"]}

    return {
        "success": True,
        "pr_number": pr_number,
        "action": action,
        "head_sha": payload["head_sha"],
        "target_branch": target_branch,
        "target_branch_sha": target_branch_sha,
        "worktree_path": resolved_worktree,
    }


@mcp.tool()
def gitea_resume_review_draft(
    pr_number: int,
    submit: bool = False,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    worktree_path: str | None = None,
) -> dict:
    """Resume a previously saved review draft for the given PR number.

    Performs live-state checks (PR head SHA, target branch SHA, terminal lock, active leases,
    runtime/master parity, reviewer identity, worktree binding, validation freshness).
    Refuses to submit or mark ready if any state has changed unsafely.
    """
    profile = get_profile()
    active_profile = profile.get("profile_name")

    # 1. Load draft state
    try:
        draft = mcp_session_state.load_state(
            kind=mcp_session_state.KIND_REVIEW_DRAFT,
            remote=remote,
            org=org,
            repo=repo,
            profile_identity=active_profile,
        )
    except Exception as exc:
        return {"success": False, "reasons": [f"failed to load draft state: {str(exc)}"]}

    if not draft:
        return {
            "success": False,
            "reasons": [f"no review draft found for PR #{pr_number} under profile '{active_profile}'"],
        }

    if draft.get("pr_number") != pr_number:
        return {
            "success": False,
            "reasons": [
                f"loaded draft is for PR #{draft.get('pr_number')}, but requested PR #{pr_number}"
            ],
        }

    # 2. Resolve remote context
    try:
        h, resolved_org, resolved_repo = _resolve(remote, host, org, repo)
    except ValueError as exc:
        return {"success": False, "reasons": [str(exc)]}

    auth = _auth(h)

    # 3. Fetch live PR state
    try:
        pr_url = f"{repo_api_url(h, resolved_org, resolved_repo)}/pulls/{pr_number}"
        pr = api_request("GET", pr_url, auth) or {}
    except Exception as exc:
        return {
            "success": False,
            "reasons": [f"failed to fetch PR #{pr_number} from Gitea: {str(exc)}"],
        }

    # 4. Perform live-state checks
    reasons = []

    # PR must be open
    if pr.get("state") != "open":
        reasons.append(f"PR #{pr_number} is not open (state: '{pr.get('state')}')")

    # Live head SHA must match saved head_sha
    live_head = (pr.get("head") or {}).get("sha")
    saved_head = draft.get("head_sha")
    if live_head != saved_head:
        reasons.append(
            f"PR head SHA changed (live={live_head!r}, saved={saved_head!r})"
        )

    # Live target branch SHA must match saved target_branch_sha
    live_target_sha = (pr.get("base") or {}).get("sha")
    saved_target_sha = draft.get("target_branch_sha")
    if live_target_sha != saved_target_sha:
        reasons.append(
            f"target branch SHA changed (live={live_target_sha!r}, saved={saved_target_sha!r})"
        )

    # Check #332 terminal lock
    hard_stop = terminal_review_hard_stop_reasons(pr_number, "resume")
    if hard_stop:
        reasons.extend(hard_stop)

    # Check active reviewer lease (must be claimed by the current session)
    try:
        comments = _fetch_pr_comments(pr_number, remote=remote, host=host, org=resolved_org, repo=resolved_repo)
    except Exception as exc:
        reasons.append(f"failed to fetch PR comments: {str(exc)}")
        comments = []

    active_lease = reviewer_pr_lease.find_active_reviewer_lease(comments, pr_number=pr_number)
    if not active_lease:
        reasons.append(f"no active reviewer lease found on PR #{pr_number}")
    else:
        # Check that lease owner is our session_id
        session = reviewer_pr_lease.get_session_lease()
        session_id = (session or {}).get("session_id")
        owner_session_id = active_lease.get("session_id")
        if owner_session_id != session_id:
            reasons.append(
                f"active reviewer lease belongs to session_id={owner_session_id!r}, "
                f"but current session has session_id={session_id!r}"
            )

    # Check runtime/master parity
    if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ:
        config = gitea_config.load_config()
        matching_profiles = []
        if config and "profiles" in config:
            for p_name, p_data in config["profiles"].items():
                p_allowed = p_data.get("allowed_operations") or []
                p_forbidden = p_data.get("forbidden_operations") or []
                p_allowed_n = []
                for op in p_allowed:
                    try:
                        p_allowed_n.append(gitea_config.normalize_operation(op))
                    except Exception:
                        pass
                p_forbidden_n = []
                for op in p_forbidden:
                    try:
                        p_forbidden_n.append(gitea_config.normalize_operation(op))
                    except Exception:
                        pass
                # Check for "review_pr" or similar capability
                ok, _ = gitea_config.check_operation("gitea.pr.review", p_allowed_n, p_forbidden_n)
                if ok:
                    matching_profiles.append(p_name)
        runtime_reasons = _check_mcp_runtimes_diagnostics("review_pr", matching_profiles)
        if runtime_reasons:
            reasons.extend(runtime_reasons)

    # Check reviewer identity
    identity = _authenticated_username(h)
    if identity != draft.get("saved_by_identity"):
        reasons.append(
            f"reviewer identity mismatch (active={identity!r}, saved={draft.get('saved_by_identity')!r})"
        )

    # Check worktree binding
    try:
        resolved_worktree = _resolve_preflight_workspace_path(worktree_path)
    except Exception as exc:
        reasons.append(f"failed to resolve current worktree: {str(exc)}")
        resolved_worktree = None
    if resolved_worktree != draft.get("worktree_path"):
        reasons.append(
            f"worktree binding mismatch (current={resolved_worktree!r}, saved={draft.get('worktree_path')!r})"
        )

    if reasons:
        return {"success": False, "reasons": reasons}

    # 5. All checks pass! Mark decision lock as ready.
    action = draft.get("action")
    body = draft.get("body")
    saved_head = draft.get("head_sha")

    lock = _load_review_decision_lock() or {}
    lock["final_review_decision_ready"] = True
    lock["ready_pr_number"] = pr_number
    lock["ready_action"] = action
    lock["ready_expected_head_sha"] = saved_head
    lock["ready_remote"] = remote
    lock["ready_org"] = resolved_org
    lock["ready_repo"] = resolved_repo
    _save_review_decision_lock(lock)

    submitted = False
    submission_res = None
    if submit:
        # Submit the review!
        submission_res = gitea_submit_pr_review(
            pr_number=pr_number,
            action=action,
            body=body,
            expected_head_sha=saved_head,
            remote=remote,
            host=host,
            org=resolved_org,
            repo=resolved_repo,
            final_review_decision_ready=True,
            worktree_path=resolved_worktree,
        )
        if submission_res.get("performed") or submission_res.get("success"):
            submitted = True
            # Delete the draft payload on success
            mcp_session_state.save_state(
                kind=mcp_session_state.KIND_REVIEW_DRAFT,
                payload=None,
                remote=remote,
                org=resolved_org,
                repo=resolved_repo,
                profile_identity=active_profile,
            )
        else:
            # If submit fails, return the submit failure reasons
            return {
                "success": False,
                "reasons": submission_res.get("reasons") or ["submission failed"],
                "submission_result": submission_res,
            }

    return {
        "success": True,
        "pr_number": pr_number,
        "action": action,
        "marked_ready": True,
        "submitted": submitted,
        "submission_result": submission_res,
    }


@mcp.tool()
def gitea_edit_pr(
    pr_number: int,
    title: str | None = None,
    body: str | None = None,
    state: str | None = None,
    base: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Edit an existing pull request on a Gitea repository.

    Closing a PR (``state='closed'``) is a distinct capability from other
    edits (#216): it requires the ``gitea.pr.close`` operation (resolver
    task ``close_pr``), fails closed with a structured permission report
    when the active profile lacks it, and is audited as a distinct
    ``close_pr`` action. Title/body/base edits and reopening stay on the
    ordinary edit path, so the edit tool can never be used as an untracked
    close fallback.

    Args:
        pr_number: The pull request index/number (required).
        title: New PR title.
        body: New PR description.
        state: New state — 'open' or 'closed'. 'closed' requires the
            ``gitea.pr.close`` capability.
        base: Target branch name.
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.

    Returns:
        dict with success status and details of the edited PR. A close
        attempt without ``gitea.pr.close`` returns 'success'/'performed'
        False with 'reasons' and a structured 'permission_report' and makes
        no API call.
    """
    # Validate inputs BEFORE any auth/profile resolution or API setup: a
    # no-fields call is a pure validation error and must not depend on
    # credentials, network, or environment configuration.
    payload = {}
    if title is not None:
        payload["title"] = title
    if body is not None:
        payload["body"] = body
    if state is not None:
        if state not in ("open", "closed"):
            raise ValueError(
                f"Invalid state {state!r}: must be 'open' or 'closed' (fail closed).")
        payload["state"] = state
    if base is not None:
        payload["base"] = base

    if not payload:
        raise ValueError("At least one field to edit (title, body, state, base) must be provided.")

    closing = payload.get("state") == "closed"
    # Closing uses close_pr; non-closing title/body/base edits use edit_pr so
    # the shared #604 anti-stomp inventory stays consistent with wiring.
    if closing:
        verify_preflight_purity(remote, task="close_pr", org=org, repo=repo)
    else:
        verify_preflight_purity(remote, task="edit_pr", org=org, repo=repo)

    # PR closure is a first-class capability, distinct from retitling or
    # rebasing edits (#216). Gate BEFORE auth/API setup so a blocked close
    # never touches the network.
    if closing:
        gate_reasons = _profile_operation_gate("gitea.pr.close")
        if gate_reasons:
            return {
                "success": False,
                "performed": False,
                "pr_number": pr_number,
                "requested_state": "closed",
                "required_permission": "gitea.pr.close",
                "reasons": gate_reasons,
                "permission_report": _permission_block_report("gitea.pr.close"),
            }

    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}"

    request_metadata = {"fields": sorted(payload)}
    if closing:
        request_metadata["required_permission"] = "gitea.pr.close"
    with _audited("close_pr" if closing else "edit_pr",
                  host=h, remote=remote, org=o, repo=r,
                  pr_number=pr_number, request_metadata=request_metadata):
        data = api_request("PATCH", url, auth, payload)

    cleanup_status = None
    if state == "closed":
        cleanup = cleanup_in_progress_for_pr(data, remote, host, org, repo)
        cleanup_status = cleanup.get("cleanup_status")
        if isinstance(cleanup_status, dict):
            for issue_num, st in cleanup_status.items():
                if st == "released":
                    try:
                        comment_url = f"{repo_api_url(h, o, r)}/issues/{issue_num}/comments"
                        api_request("POST", comment_url, auth, {"body": f"Tracker cleanup: removed `status:in-progress` from this issue because linked PR #{pr_number} was closed."})
                    except Exception:
                        pass

    result = {
        "success": True,
        "number": data["number"],
        "title": data["title"],
        "body": data.get("body", ""),
        "state": data["state"],
        "cleanup_status": cleanup_status,
    }
    return _with_optional_url(result, data.get("html_url"))


@mcp.tool()
def gitea_get_file(
    filepath: str,
    ref: str = "main",
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Retrieve metadata and content of a file from a Gitea repository.

    Args:
        filepath: The path to the file in the repository (e.g. 'README.md' or 'src/main.py').
        ref: The branch, tag, or commit hash to retrieve the file from (default: 'main').
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.

    Returns:
        dict containing 'name', 'path', 'sha', 'size', 'encoding', and 'content' (base64).
    """
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    import urllib.parse
    encoded_path = urllib.parse.quote(filepath, safe="")
    url = f"{repo_api_url(h, o, r)}/contents/{encoded_path}?ref={ref}"
    data = api_request("GET", url, auth)
    return {
        "name": data.get("name", ""),
        "path": data.get("path", ""),
        "sha": data.get("sha", ""),
        "size": data.get("size", 0),
        "encoding": data.get("encoding", ""),
        "content": data.get("content", ""),
    }


def _prepare_commit_payload_files(files: list[dict]) -> tuple[list[dict], list[dict]]:
    import base64
    import json
    import os

    processed_files = []
    source_proofs = []

    lock_data = issue_lock_store.read_session_issue_lock() or {}

    locked_worktree = lock_data.get("worktree_path")
    if locked_worktree:
        locked_worktree = os.path.realpath(locked_worktree)

    for f in files:
        f_copy = dict(f)
        path = f_copy.get("path", "")

        content_keys = [k for k in ["content", "content_plain", "workspace_path", "local_path"] if k in f_copy]
        if len(content_keys) > 1:
            raise ValueError(
                f"Multiple content sources specified for file '{path}'; provide exactly one of "
                f"'content', 'content_plain', 'workspace_path', or 'local_path' (fail closed)"
            )

        source = "none"
        if "content_plain" in f_copy:
            content_plain = f_copy.pop("content_plain")
            if content_plain is not None:
                content_bytes = content_plain.encode("utf-8")
                f_copy["content"] = base64.b64encode(content_bytes).decode("utf-8")
                source = "inline_plain"
        elif "workspace_path" in f_copy:
            workspace_path = f_copy.pop("workspace_path")
            if not locked_worktree:
                raise RuntimeError(
                    f"Issue lock is missing or does not define a worktree path. "
                    f"Cannot resolve workspace_path '{workspace_path}' (fail closed)."
                )
            target_path = os.path.realpath(os.path.abspath(os.path.join(locked_worktree, workspace_path)))

            is_inside = target_path == locked_worktree or target_path.startswith(os.path.join(locked_worktree, ""))
            is_tmp = target_path.startswith(os.path.realpath("/tmp") + os.sep) or target_path.startswith("/tmp" + os.sep)

            if not (is_inside or is_tmp):
                raise ValueError(
                    f"workspace_path '{workspace_path}' resolves to '{target_path}' which falls outside "
                    f"of locked worktree '{locked_worktree}' (fail closed)"
                )

            if not os.path.exists(target_path):
                raise FileNotFoundError(f"File not found: {target_path} (fail closed)")

            with open(target_path, "rb") as fh:
                file_bytes = fh.read()
            f_copy["content"] = base64.b64encode(file_bytes).decode("utf-8")
            source = "workspace_path"
        elif "local_path" in f_copy:
            local_path = f_copy.pop("local_path")
            if not locked_worktree:
                raise RuntimeError(
                    f"Issue lock is missing or does not define a worktree path. "
                    f"Cannot resolve local_path '{local_path}' (fail closed)."
                )
            target_path = os.path.realpath(os.path.abspath(local_path))

            is_inside = target_path == locked_worktree or target_path.startswith(os.path.join(locked_worktree, ""))
            is_tmp = target_path.startswith(os.path.realpath("/tmp") + os.sep) or target_path.startswith("/tmp" + os.sep)

            if not (is_inside or is_tmp):
                raise ValueError(
                    f"local_path '{local_path}' resolves to '{target_path}' which falls outside "
                    f"of locked worktree '{locked_worktree}' (fail closed)"
                )

            if not os.path.exists(target_path):
                raise FileNotFoundError(f"File not found: {target_path} (fail closed)")

            with open(target_path, "rb") as fh:
                file_bytes = fh.read()
            f_copy["content"] = base64.b64encode(file_bytes).decode("utf-8")
            source = "local_path"
        elif "content" in f_copy:
            source = "inline_base64"

        processed_files.append(f_copy)
        source_proofs.append({
            "path": path,
            "source": source
        })

    return processed_files, source_proofs


@mcp.tool()
def gitea_commit_files(
    files: list[dict],
    message: str,
    branch: str | None = None,
    new_branch: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Commit changes to multiple files in a Gitea repository in a single atomic commit.

    Args:
        files: List of file operations. Each file dict must contain 'operation' ('create', 'update', 'delete', 'rename'), 'path', and one of content payload sources: 'content' (base64), 'content_plain', 'workspace_path', or 'local_path'.
        message: The commit message.
        branch: Optional existing branch to start/commit from.
        new_branch: Optional new branch name to create for this commit.
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.

    Returns:
        dict with success status and commit/branch information.
    """
    ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop(
        "commit_files"
    )
    if not ok:
        return {
            "success": False,
            "performed": False,
            "commit": "",
            "branch": "",
            "reasons": block_reasons,
        }
    blocked = _namespace_mutation_block(
        "commit_files", commit="", branch="", remote=remote
    )
    if blocked:
        return blocked
    blocked = _profile_permission_block(
        task_capability_map.required_permission("commit_files"),
        commit="", branch="", remote=remote, host=host, org=org, repo=repo,
        org_explicit=org is not None,
        repo_explicit=repo is not None,
    )
    if blocked:
        return blocked

    duplicate_block = _enforce_locked_issue_duplicate_recheck(
        remote,
        issue_work_duplicate_gate.PHASE_COMMIT,
        host=host,
        org=org,
        repo=repo,
    )
    if duplicate_block:
        return _duplicate_gate_block_response(
            duplicate_block,
            commit="",
            branch="",
        )

    # #735: forward explicit org/repo into shared anti-stomp preflight.
    verify_preflight_purity(remote, task="commit_files", org=org, repo=repo)
    processed_files, source_proofs = _prepare_commit_payload_files(files)

    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    url = f"{repo_api_url(h, o, r)}/contents"

    payload = {
        "files": processed_files,
        "message": message,
    }
    if branch is not None:
        payload["branch"] = branch
    if new_branch is not None:
        payload["new_branch"] = new_branch

    with _audited("commit_files", host=h, remote=remote, org=o, repo=r,
                  target_branch=(new_branch or branch),
                  request_metadata={"message": message,
                                    "paths": [f.get("path") for f in processed_files],
                                    "operations": [f.get("operation") for f in processed_files]}):
        data = api_request("POST", url, auth, payload)
    return {
        "success": True,
        "commit": data.get("commit", {}).get("sha", ""),
        "branch": data.get("branch", {}).get("name", ""),
        "content_source_proof": source_proofs,
    }


# Merge methods supported by the Gitea merge API.
_MERGE_METHODS = ("merge", "squash", "rebase")


@mcp.tool()
@_audit_pr_result("merge_pr")
def gitea_merge_pr(
    pr_number: int,
    confirmation: str = "",
    expected_head_sha: str | None = None,
    expected_changed_files: list[str] | None = None,
    do: str = "merge",
    title: str | None = None,
    message: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    worktree_path: str | None = None,
) -> dict:
    """Gated merge of a Gitea pull request (#16).

    This is the ONLY merge path this server exposes, and it mutates only after
    every safety gate passes. No ungated merge tool remains: legacy
    ``gitea_review_pr`` fails closed on ``merge=True`` and
    ``gitea_submit_pr_review`` never merges.

    Gate order (fail-closed at each step; the merge API is called only if all
    gates pass):

    1. Merge method (``do``) is 'merge', 'squash', or 'rebase'.
    2. Explicit confirmation: ``confirmation`` must equal ``"MERGE PR <n>"``.
       Without it, the tool makes no API calls at all.
    3. Reuse ``gitea_check_pr_eligibility`` (#14) with action 'merge': this
       proves the authenticated identity, the active profile (and that it
       allows merge), the PR author, blocks self-merge, requires the PR to be
       open, and fails closed when the PR is not mergeable or mergeability is
       unknown.
    4. If ``expected_head_sha`` is given and the PR head moved → refuse.
    5. If ``expected_changed_files`` is given and the PR's changed file set
       differs → refuse.
    6. Redundant self-merge block (authenticated user == PR author).
    7. Re-read formal review feedback (#167): refuse when
       ``approval_visible`` is false or undismissed REQUEST_CHANGES block
       merge — PENDING draft approvals do not count (#244).

    No force / ignore-checks option is exposed. Gitea's own ``mergeable`` signal
    (which reflects branch-protection required reviews and status checks) must
    be positive, so required approval/check state is honoured, never bypassed.

    Never returns the token, Authorization header, or any credential material.

    Args:
        pr_number: The PR number to merge.
        confirmation: Must be exactly ``"MERGE PR <pr_number>"`` or merge is refused.
        expected_head_sha: Strongly recommended. If set and the PR head differs, refuse.
        expected_changed_files: Optional. If set and the PR's changed file set
            differs, refuse.
        do: Merge style — 'merge', 'squash', or 'rebase'.
        title: Optional merge commit title.
        message: Optional merge commit message.
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.
        worktree_path: Merger workspace path under ``branches/`` or clean
            control checkout; defaults to ``GITEA_MERGER_WORKTREE``,
            ``GITEA_ACTIVE_WORKTREE``, or the MCP server process root. Ignores
            foreign ``GITEA_AUTHOR_WORKTREE`` bindings (#510).

    Returns:
        dict describing the attempt: performed, authenticated user, profile
        name, PR author, PR number, head SHA checked, merge method,
        reasons/gates passed or blocked, and merge result / merge commit if
        available. Never secrets.
    """
    _verify_role_mutation_workspace(
        remote,
        worktree_path=worktree_path,
        task="merge_pr",
        org=org,
        repo=repo,
    )
    allowed = get_profile()["allowed_operations"]
    forbidden = get_profile()["forbidden_operations"]
    active_role = _role_kind(allowed, forbidden)
    if active_role == "reviewer":
        raise RuntimeError(
            "Reviewer profile cannot merge. Use a merger profile/session "
            "after formal approval at current head."
        )
    workflow_blockers = _review_workflow_load_gate_reasons()
    do = (do or "").strip().lower()
    result = {
        "performed": False,
        "authenticated_user": None,
        "profile_name": get_profile()["profile_name"],
        "pr_author": None,
        "pr_number": pr_number,
        "head_sha": None,
        "expected_head_sha": expected_head_sha,
        "merge_method": do,
        "mergeable": None,
        "remote": remote if remote in REMOTES else None,
        "merge_result": None,
        "merge_commit": None,
        "reasons": [],
        "active_profile": get_profile()["profile_name"],
        "role_kind": active_role,
        "merge_capability_source": "profile" if "gitea.pr.merge" in allowed else "config",
        "explicit_operator_auth": confirmation,
    }
    reasons = result["reasons"]
    if workflow_blockers:
        reasons.extend(workflow_blockers)
        reasons.extend(review_workflow_load.recovery_handoff_without_replay())
        return result

    # Gate 0b — recorded live MCP namespace health must not be broken (#543).
    ns_gate = _live_namespace_health_gate("merge_pr")
    if ns_gate:
        reasons.extend(ns_gate)
        return result

    # Gate 1 — valid merge method (no API call on a bad method).
    if do not in _MERGE_METHODS:
        reasons.append(
            f"unknown merge method '{do}'; expected one of {list(_MERGE_METHODS)}"
        )
        return result

    # Gate 2 — explicit confirmation (fail fast; zero API calls without it).
    expected_confirmation = f"MERGE PR {pr_number}"
    if (confirmation or "").strip() != expected_confirmation:
        reasons.append(
            f"explicit confirmation required: pass confirmation='{expected_confirmation}'"
        )
        return result

    # Gate 2b — session hard-stop after a terminal review mutation (#332):
    # merge may only continue for the same PR that was just approved.
    # Local in-process check; zero API calls.
    hard_stop = terminal_review_hard_stop_reasons(pr_number, "merge")
    if hard_stop:
        reasons.extend(hard_stop)
        return result

    # Gate 3 — reuse #14 eligibility (identity + profile + merge-allowed +
    # author + self-merge block + open + mergeable/unknown fail-closed).
    # Read-only GETs only.
    elig = gitea_check_pr_eligibility(
        pr_number=pr_number, action="merge", remote=remote,
        host=host, org=org, repo=repo,
    )
    result["authenticated_user"] = elig.get("authenticated_user")
    result["profile_name"] = elig.get("profile_name", result["profile_name"])
    result["pr_author"] = elig.get("pr_author")
    result["head_sha"] = elig.get("head_sha")
    result["mergeable"] = elig.get("mergeable")
    # Surface #695 approval/quarantine fields from eligibility even on deny.
    for _k in (
        "approval_visible",
        "approval_at_current_head",
        "quarantined_approvals_at_current_head",
        "stale_approval_block_reason",
        "has_blocking_change_requests",
    ):
        if _k in elig:
            result[_k] = elig.get(_k)
    if not elig.get("eligible"):
        reasons.append("eligibility check for 'merge' failed (fail closed)")
        reasons.extend(elig.get("reasons", []))
        if elig.get("permission_report"):
            result["permission_report"] = elig["permission_report"]
        return result

    reasons.extend(_reviewer_pr_lease_gate(
        pr_number=pr_number,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
        mutation="merge",
        live_head_sha=result.get("head_sha"),
        pinned_head_sha=expected_head_sha,
    ))
    # Always surface how the in-session lease was established (#535), even when
    # the lease gate blocks (e.g. unsanctioned manual _SESSION_LEASE seed).
    result.update(
        merger_lease_adoption.describe_session_lease_proof(
            reviewer_pr_lease.get_session_lease()
        )
    )
    if reasons:
        return result

    # Gate 4 — reviewed head SHA is mandatory and must match live PR head (#399).
    actual_sha = result["head_sha"]
    if not (expected_head_sha or "").strip():
        reasons.append(
            "expected_head_sha required before merge (fail closed, #399)"
        )
        return result
    lease_block = _pr_work_lease_reviewer_block(
        pr_number=pr_number,
        reviewed_head_sha=expected_head_sha,
        live_head_sha=actual_sha,
        mutation="merge",
        remote=remote,
        host=host,
        org=org,
        repo=repo,
    )
    if lease_block.get("block"):
        reasons.extend(lease_block.get("reasons") or [])
        result["pr_work_lease"] = lease_block
        return result

    # #604: common anti-stomp with live head + lease proof (typed blocker).
    try:
        _run_anti_stomp_preflight(
            "merge_pr",
            remote=remote,
            worktree_path=worktree_path,
            org=org,
            repo=repo,
            expected_head_sha=expected_head_sha,
            live_head_sha=actual_sha,
            require_head_sha=True,
            foreign_lease=False,
            terminal_lock_blocks=False,
        )
    except RuntimeError as exc:
        reasons.append(str(exc))
        return result

    if expected_head_sha and actual_sha and expected_head_sha != actual_sha:
        reasons.append(
            "expected head SHA does not match current PR head (fail closed)"
        )
        return result
    if not actual_sha:
        # Unreachable — eligibility fails closed without a head SHA — but never
        # merge a PR whose head commit we could not read.
        reasons.append("PR head SHA unavailable (fail closed)")
        return result

    h, o, r = _resolve(remote, host, org, repo)

    # Gate 5 — changed files must match the reviewed set, if provided.
    if expected_changed_files is not None:
        try:
            auth = _auth(h)
            files = api_request(
                "GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}/files", auth
            )
            actual_files = sorted(
                (f or {}).get("filename", "") for f in (files or [])
            )
        except Exception as exc:  # noqa: BLE001 — redact before surfacing
            reasons.append(
                f"could not verify changed files (fail closed): {_redact(str(exc))}"
            )
            return result
        result["changed_files"] = actual_files
        if actual_files != sorted(expected_changed_files):
            reasons.append(
                "PR changed files do not match expected_changed_files (fail closed)"
            )
            return result

    # Gate 6 — redundant self-merge block (belt-and-suspenders over #14).
    auth_user = result["authenticated_user"]
    pr_author = result["pr_author"]
    if auth_user and pr_author and auth_user == pr_author:
        reasons.append("self-merge blocked (authenticated user is PR author)")
        return result

    # Gate 7 — visible formal approval required (#244). PENDING drafts and
    # absent verdicts do not satisfy this gate even when Gitea mergeable is true.
    feedback = gitea_get_pr_review_feedback(
        pr_number=pr_number, remote=remote, host=host, org=org, repo=repo,
    )
    if not feedback.get("success"):
        reasons.append("PR review feedback unavailable before merge (fail closed)")
        reasons.extend(feedback.get("reasons", []))
        if feedback.get("permission_report"):
            result["permission_report"] = feedback["permission_report"]
        return result
    result["approval_visible"] = feedback.get("approval_visible")
    result["approval_at_current_head"] = feedback.get("approval_at_current_head")
    result["latest_approved_head_sha"] = feedback.get("latest_approved_head_sha")
    result["review_feedback_stale"] = feedback.get("review_feedback_stale")
    result["has_blocking_change_requests"] = feedback.get(
        "has_blocking_change_requests")
    result["quarantined_review_ids"] = feedback.get("quarantined_review_ids") or []
    result["quarantined_approvals_at_current_head"] = feedback.get(
        "quarantined_approvals_at_current_head"
    )
    if feedback.get("has_blocking_change_requests"):
        reasons.append(
            "undismissed REQUEST_CHANGES review blocks merge (fail closed)"
        )
        return result
    if not feedback.get("approval_visible"):
        # #695: quarantined APPROVED reviews are not "visible" for merge auth.
        if feedback.get("quarantined_approvals_at_current_head"):
            reasons.append(
                feedback.get("stale_approval_block_reason")
                or (
                    "only contaminated/quarantined approval(s) at current head "
                    "(#695); merge authorization is void — fresh native MCP "
                    "re-review required after controller quarantine evidence"
                )
            )
        else:
            reasons.append(
                "no visible APPROVED review on PR; verify review submission "
                "completed before merge (fail closed)"
            )
        return result
    if not feedback.get("approval_at_current_head"):
        reasons.append(
            feedback.get("stale_approval_block_reason")
            or (
                "approval does not apply to current PR head SHA "
                "(fail closed); required next action: re-review PR at "
                "current head before merge"
            )
        )
        return result

    # Gate 8 — in-process mutation authority (#199): the last check before
    # the merge mutation, using the identity the eligibility gate proved.
    # A profile/identity flip or side-channel override between preflight
    # and merge fails closed here.
    try:
        verify_mutation_authority(remote, host, required_role="merger",
                                  active_identity=auth_user)
    except RuntimeError as e:
        reasons.append(str(e))
        return result

    # Gate 8b — optional irrecoverable prior-provenance recovery report (#709 F2).
    # Never substitutes for approval/lease/mergeability gates above. Surfaces
    # truthful distinctions for controllers/mergers.
    try:
        import irrecoverable_provenance as _irp_merge

        _rec_prof = _irp_merge.recovery_state_profile_identity(
            remote=remote,
            org=o,
            repo=r,
            pr_number=pr_number,
            expected_head_sha=str(expected_head_sha or actual_sha or ""),
        )
        _auth_prof = _irp_merge.auth_state_profile_identity(
            remote=remote,
            org=o,
            repo=r,
            pr_number=pr_number,
            expected_head_sha=str(expected_head_sha or actual_sha or ""),
        )
        _recovery = mcp_session_state.load_state(
            kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE,
            remote=remote,
            org=o,
            repo=r,
            profile_identity=_rec_prof,
        )
        _auth_art = mcp_session_state.load_state(
            kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH,
            remote=remote,
            org=o,
            repo=r,
            profile_identity=_auth_prof,
        )
        if isinstance(_recovery, dict) or isinstance(_auth_art, dict):
            _assess = _irp_merge.assess_merger_consumption(
                _recovery if isinstance(_recovery, dict) else None,
                _auth_art if isinstance(_auth_art, dict) else None,
                remote=remote,
                org=o,
                repo=r,
                pr_number=pr_number,
                live_head_sha=actual_sha,
                approval_at_current_head=bool(
                    feedback.get("approval_at_current_head")
                ),
                has_blocking_change_requests=bool(
                    feedback.get("has_blocking_change_requests")
                ),
                mergeable=result.get("mergeable"),
                lease_ok=True,
                runtime_ok=True,
                workspace_ok=True,
                anti_stomp_ok=True,
            )
            result["irrecoverable_provenance"] = {
                "historical_cleanup_proven": False,
                "historical_cleanup_not_proven": True,
                "irrecoverable_recovery_authorized": bool(
                    _assess.get("irrecoverable_recovery_authorized")
                ),
                "recovery_record_consumed": bool(
                    _assess.get("recovery_record_consumed")
                    or (
                        isinstance(_recovery, dict) and _recovery.get("consumed_at")
                    )
                ),
                "resolves_prior_provenance_blocker": bool(
                    _assess.get("resolves_prior_provenance_blocker")
                ),
                "assessment_reasons": list(_assess.get("reasons") or []),
            }
            # Consume on successful merge path when allowed and not yet consumed.
            if _assess.get("allowed") and isinstance(_recovery, dict) and not _recovery.get(
                "consumed_at"
            ):
                try:
                    if isinstance(_auth_art, dict) and not _auth_art.get("consumed_at"):
                        mcp_session_state.save_state(
                            kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH,
                            payload=_irp_merge.mark_consumed(
                                _auth_art,
                                consumer_username=auth_user,
                                consumer_profile=result.get("profile_name"),
                            ),
                            remote=remote,
                            org=o,
                            repo=r,
                            profile_identity=_auth_prof,
                        )
                    mcp_session_state.save_state(
                        kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE,
                        payload={
                            **_irp_merge.mark_consumed(
                                _recovery,
                                consumer_username=auth_user,
                                consumer_profile=result.get("profile_name"),
                            ),
                            "prior_provenance_blocker_resolved": True,
                        },
                        remote=remote,
                        org=o,
                        repo=r,
                        profile_identity=_rec_prof,
                    )
                    result["irrecoverable_provenance"][
                        "recovery_record_consumed"
                    ] = True
                    result["irrecoverable_provenance"][
                        "consumed_at_merge_preflight"
                    ] = True
                except Exception as _cons_exc:  # noqa: BLE001
                    result["irrecoverable_provenance"]["consume_error"] = _redact(
                        str(_cons_exc)
                    )
        else:
            result["irrecoverable_provenance"] = {
                "historical_cleanup_proven": False,
                "historical_cleanup_not_proven": True,
                "irrecoverable_recovery_authorized": False,
                "recovery_record_consumed": False,
                "resolves_prior_provenance_blocker": False,
                "note": "no irrecoverable recovery record for this exact scope",
            }
    except Exception as _irp_exc:  # noqa: BLE001 — never block merge path
        result["irrecoverable_provenance"] = {
            "error": _redact(str(_irp_exc)),
            "historical_cleanup_proven": False,
            "historical_cleanup_not_proven": True,
        }

    # All gates passed — perform the single merge mutation.
    try:
        auth = _auth(h)
        merge_url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}/merge"
        payload = {"Do": do}
        if title:
            payload["MergeTitleField"] = title
        if message:
            payload["MergeMessageField"] = message
        api_request("POST", merge_url, auth, payload)
        # Best-effort read-back of the merge commit SHA (redacted on error).
        merged = None
        try:
            merged = api_request(
                "GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth
            )
            result["merge_commit"] = (merged or {}).get("merged_commit_sha")
        except Exception:
            result["merge_commit"] = None

        # Tracker cleanup (#98): never blocks the merge, and always surfaces an
        # explicit cleanup_status once the merge mutation has happened. Cleanup
        # needs the PR title/body/branch, which only the read-back payload
        # carries here — so a failed read-back is an explicit skip, not a
        # silent one.
        if merged is None:
            result["cleanup_status"] = "skipped (merge read-back failed)"
        else:
            try:
                cleanup = cleanup_in_progress_for_pr(merged, remote, host, org, repo)
                result["cleanup_status"] = cleanup.get("cleanup_status")
            except Exception as cleanup_exc:  # noqa: BLE001 — redact before surfacing
                result["cleanup_status"] = (
                    f"skipped (cleanup error: {_redact(str(cleanup_exc))})"
                )
    except Exception as exc:  # noqa: BLE001 — redact before surfacing
        reasons.append(f"merge failed: {_redact(str(exc))}")
        return result

    result["performed"] = True
    result["merge_result"] = f"PR #{pr_number} merged via '{do}'."
    # Re-attach after success in case session lease was heartbeated mid-path.
    result.update(
        merger_lease_adoption.describe_session_lease_proof(
            reviewer_pr_lease.get_session_lease()
        )
    )
    # #709 AC1/AC3/AC4: reconcile decision locks after irreversible merge.
    # Clears the same-profile lock when it holds the approve, and also scans
    # other durable profile locks (e.g. prgs-reviewer) so merger-local empty
    # init cannot leave the reviewer terminal ledger behind. Failures write a
    # recovery-required record (applied=false) — merge is never undone.
    try:
        reconcile = _reconcile_decision_locks_after_merge(
            pr_number=pr_number,
            head_sha=expected_head_sha,
            merge_commit_sha=(
                (merged or {}).get("merge_commit_sha")
                if isinstance(merged, dict)
                else None
            ),
            remote=remote,
            host=h,
            org=o,
            repo=r,
            auth=auth,
        )
        result["review_decision_lock_reconcile"] = reconcile
        result["review_decision_lock_cleared_after_merge"] = bool(
            reconcile.get("cleared_any")
        )
        for line in reconcile.get("reason_lines") or []:
            reasons.append(line)
    except Exception as clear_exc:  # noqa: BLE001 — never fail the merge
        result["review_decision_lock_cleared_after_merge"] = False
        result["review_decision_lock_clear_error"] = _redact(str(clear_exc))
        try:
            _record_post_merge_decision_recovery(
                pr_number=pr_number,
                head_sha=expected_head_sha,
                merge_commit_sha=None,
                target_profile_identity=None,
                failed_step="reconcile_exception",
                error=_redact(str(clear_exc)),
                remote=remote,
                org=o,
                repo=r,
            )
        except Exception:
            pass
    reasons.append(f"all gates passed; merged PR #{pr_number} via '{do}'")
    return result


def _local_git_remote_url(remote_name: str) -> str | None:
    """Best-effort local ``git remote get-url`` for trust-gate corroboration.

    #741: this runs in the **canonical target repository root**, not the
    Gitea-Tools installation checkout. Every consumer of this function derives
    *target repository identity* from it (``_resolve``, the #530 remote/repo
    guard, the anti-stomp org/repo fill, ``_workspace_repository_slug``), so
    running it in ``PROJECT_ROOT`` inverted those guards for a cross-repository
    namespace: the genuinely bound target was rejected while Gitea-Tools was
    accepted. Single-repository namespaces are unaffected —
    :func:`_canonical_local_git_root` returns ``PROJECT_ROOT`` when no
    cross-repository binding is configured.
    """
    try:
        proc = subprocess.run(
            ["git", "remote", "get-url", remote_name],
            capture_output=True,
            text=True,
            cwd=_canonical_local_git_root(),
        )
        if proc.returncode != 0:
            return None
        url = (proc.stdout or "").strip()
        return url or None
    except Exception:
        return None


@mcp.tool()
def gitea_review_pr(
    pr_number: int,
    event: str = "APPROVE",
    body: str = "",
    merge: bool = False,
    merge_method: str = "merge",
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    final_review_decision_ready: bool = False,
) -> dict:
    """Submit a review on a Gitea pull request (Legacy wrapper).

    This tool is a compatibility wrapper around the safe `gitea_submit_pr_review`.
    It uses the same #14 eligibility gates.
    Merging via this tool is no longer supported and will fail closed (see #16).

    Args:
        pr_number: The PR number to review.
        event: Review type — 'APPROVE', 'COMMENT', or 'REQUEST_CHANGES'.
        body: Review body text / comment.
        merge: Merging is disabled; if True, the tool fails closed.
        merge_method: Ignored.
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.

    Returns:
        dict with success status and message.
    """
    if merge:
        return {
            "success": False,
            "message": "merge=True is no longer supported in this tool (belongs to #16)."
        }

    if event not in ["APPROVE", "COMMENT", "REQUEST_CHANGES"]:
        raise ValueError(f"Invalid review event: '{event}'. Choose from 'APPROVE', 'COMMENT', 'REQUEST_CHANGES'.")

    # Map legacy event string to the action expected by gitea_submit_pr_review
    event_map = {
        "APPROVE": "approve",
        "COMMENT": "comment",
        "REQUEST_CHANGES": "request_changes"
    }
    action = event_map[event]

    # --- Phase 1: Read-only PR queue inventory ---
    profile = get_profile()
    allowed = profile.get("allowed_operations") or []
    forbidden = profile.get("forbidden_operations") or []
    switching_supported = gitea_config.is_runtime_switching_enabled()

    can_review, _ = gitea_config.check_operation("gitea.pr.review", allowed, forbidden)
    can_approve, _ = gitea_config.check_operation("gitea.pr.approve", allowed, forbidden)
    can_merge, _ = gitea_config.check_operation("gitea.pr.merge", allowed, forbidden)
    can_read, _ = gitea_config.check_operation("gitea.read", allowed, forbidden)

    is_reviewer = can_review or can_approve or can_merge

    inventory_attempted = False
    prs_found_count = 0
    pr_details_list = []
    inventory_msg = ""
    inventory_trust_gate = None
    prs: list = []

    h, o, r = _resolve(remote, host, org, repo)
    auth = None
    try:
        auth = _auth(h)
    except Exception:
        pass

    auth_user = None
    if auth:
        try:
            who = api_request("GET", gitea_url(h, "/api/v1/user"), auth)
            auth_user = (who or {}).get("login")
        except Exception:
            pass

    if can_read and auth:
        inventory_attempted = True
        try:
            prs = gitea_list_prs(
                state="open",
                remote=remote,
                host=h,
                org=o,
                repo=r,
            )
            prs_list = prs.get("prs") or []
            prs_found_count = len(prs_list)
            for pr in prs_list:
                labels = list(pr.get("labels") or [])
                body_text = pr.get("body") or ""
                linked = []
                # Simple extraction of linked issues from body
                for match in re.findall(r'#(\d+)', body_text):
                    linked.append(f"#{match}")

                pr_head_sha = pr.get("head_sha")
                pr_author = pr.get("author")

                # Determine review block status
                req_non_author = False
                if pr_author and auth_user and pr_author == auth_user:
                    req_non_author = True

                pr_details_list.append({
                    "number": pr["number"],
                    "title": pr["title"],
                    "author": pr_author,
                    "base": pr["base"],
                    "head": pr["head"],
                    "mergeable": pr.get("mergeable"),
                    "linked_issues": list(set(linked)),
                    "labels": labels,
                    "head_sha": pr_head_sha,
                    "updated_at": pr.get("updated_at"),
                    "requires_non_author_reviewer": req_non_author
                })
        except Exception as e:
            inventory_msg = f"PR inventory failed: {_redact(str(e))}"
    else:
        inventory_msg = "PR inventory not attempted"

    # Build inventory report string
    report_lines = []
    if inventory_attempted:
        report_lines.append(f"Repository: {o}/{r}")
        if pr_details_list:
            report_lines.append(f"Open PRs found: {prs_found_count}")
            for item in pr_details_list:
                status_str = "Review/merge blocked (Current profile is Author)"
                if item["requires_non_author_reviewer"]:
                    status_str = "Review/merge blocked (Self-author). Requires a genuine non-author reviewer"
                elif is_reviewer:
                    status_str = "Review/merge eligible under current profile"

                report_lines.append(
                    f"- PR #{item['number']}: {item['title']}\n"
                    f"  Author: {item['author']}\n"
                    f"  Base/Head: {item['base']} / {item['head']}\n"
                    f"  Head SHA: {item['head_sha']}\n"
                    f"  Updated: {item.get('updated_at') or 'unknown'}\n"
                    f"  Mergeability: {item['mergeable']}\n"
                    f"  Linked Issues: {item['linked_issues']}\n"
                    f"  Labels: {item['labels']}\n"
                    f"  Status: {status_str}"
                )
        else:
            if inventory_msg:
                report_lines.append(inventory_msg)
            else:
                pagination = (
                    prs.get("pagination") if isinstance(prs, dict) else {}
                ) or {}
                has_finality_metadata = bool(
                    pagination.get("inventory_complete")
                    or (
                        pagination.get("is_final_page")
                        and not pagination.get("has_more")
                    )
                )
                inventory_trust_gate = review_proofs.pr_inventory_trust_gate(
                    prs if inventory_attempted else None,
                    remote=remote,
                    org=o,
                    repo=r,
                    state="open",
                    authenticated_profile=profile,
                    local_remote_url=_local_git_remote_url(remote),
                    has_finality_metadata=has_finality_metadata,
                )
                report_lines.extend(
                    review_proofs.format_pr_inventory_trust_gate_report(
                        inventory_trust_gate
                    )
                )
                if inventory_trust_gate.get("status") == "trusted_empty":
                    report_lines.append("Open PRs found: 0 (trusted_empty)")
                else:
                    report_lines.append(
                        "Empty-queue claim blocked: "
                        "pr_inventory_trust_gate did not return trusted_empty"
                    )
    else:
        report_lines.append(inventory_msg)

    inventory_report = "\n".join(report_lines)

    # If the current profile is not a reviewer, or lacks necessary permissions for review action
    if not is_reviewer:
        # Determine the safe next step based on static vs dynamic profile switching support
        if switching_supported:
            safe_step = f"Switch to a reviewer profile by calling gitea_activate_profile with a profile that allows {action}."
        else:
            safe_step = "Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."

        blocked_message = (
            f"=== PR Queue Inventory ===\n{inventory_report}\n\n"
            f"=== Review/Merge Blocked ===\n"
            f"The active profile '{profile['profile_name']}' is an Author profile and does not have review or merge permissions.\n"
            f"Safe next step: {safe_step}"
        )
        return {
            "success": False,
            "message": blocked_message
        }

    # --- Phase 2: Reviewer-only review/merge actions ---
    # Since it is a reviewer profile, we proceed to submit the review
    result = gitea_submit_pr_review(
        pr_number=pr_number,
        action=action,
        body=body,
        expected_head_sha=None,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
        final_review_decision_ready=final_review_decision_ready
    )

    # Include the inventory report in the response message
    if result.get("performed"):
        msg = (
            f"=== PR Queue Inventory ===\n{inventory_report}\n\n"
            f"Successfully submitted review for PR #{pr_number} with event '{event}'."
        )
        return {"success": True, "message": msg}
    else:
        reasons = result.get("reasons", [])
        msg = (
            f"=== PR Queue Inventory ===\n{inventory_report}\n\n"
            f"Review submission failed eligibility gates: {reasons}"
        )
        out = {"success": False, "message": msg}
        if result.get("permission_report"):
            out["permission_report"] = result["permission_report"]
        return out


def _repository_binding_block(
    remote: str | None,
    *,
    org: str | None,
    repo: str | None,
    required_permission: str = "gitea.branch.delete",
) -> dict | None:
    """#733: validate an explicit mutation target against the workspace binding.

    The trusted repository identity comes only from the verified,
    workspace-aligned git remote (never ``REMOTES`` defaults, never
    unvalidated caller input). Explicit ``org``/``repo`` are a *request target*
    checked against that binding:

    * matching or omitted coordinates pass — omitted targets are still resolved
      and re-validated by the shared #604 anti-stomp preflight, which fails
      closed on a remote-wide default mismatch (bare prgs → Timesheet);
    * coordinates that disagree with the binding are rejected (wrong or
      substituted repository);
    * explicit coordinates with no establishable workspace identity fail closed
      (unproven target).

    Returns a structured block dict, or ``None`` when the target is authorized.

    #739 F2: the trusted identity is the session's configured
    ``canonical_repository_root`` when the namespace declares one. Deriving it
    from ``_workspace_repository_slug`` alone reads the *installation* checkout
    (``_local_git_remote_url`` runs in ``PROJECT_ROOT``, always Gitea-Tools),
    which inverts the guard for a cross-repository namespace: the genuinely
    bound target is rejected and Gitea-Tools is accepted. A configured root that
    cannot be resolved fails closed rather than falling back to the install
    identity; unconfigured namespaces keep the install-derived default.
    """
    canonical_slug, canonical_reasons = _canonical_repository_slug(
        get_profile(), remote
    )
    if canonical_reasons:
        return {
            "success": False,
            "performed": False,
            "required_permission": required_permission,
            "reasons": canonical_reasons,
            "blocker_kind": "repository_binding",
        }
    workspace_slug = canonical_slug or _workspace_repository_slug(remote)
    workspace_parts = (
        session_ctx.parse_repository_slug(workspace_slug) if workspace_slug else None
    )
    if workspace_parts:
        override = session_ctx.assess_repository_override(
            requested_org=org,
            requested_repo=repo,
            bound_org=workspace_parts[0],
            bound_repo=workspace_parts[1],
        )
        if override.get("block"):
            return {
                "success": False,
                "performed": False,
                "required_permission": required_permission,
                "reasons": list(override.get("reasons") or [
                    "target repository does not match the workspace "
                    "binding (fail closed)"
                ]),
                "blocker_kind": "repository_binding",
            }
        return None
    if org is not None or repo is not None:
        return {
            "success": False,
            "performed": False,
            "required_permission": required_permission,
            "reasons": [
                "repository binding unverified: no workspace repository "
                "identity could be established to corroborate the explicit "
                "target (fail closed)"
            ],
            "blocker_kind": "repository_binding",
        }
    return None


def _delete_branch_repository_binding_block(
    remote: str | None,
    *,
    org: str | None,
    repo: str | None,
) -> dict | None:
    """Delete-branch view of :func:`_repository_binding_block` (#733).

    Retained as the named entry point for the delete path so #733/#739
    regression coverage keeps exercising the exact permission label that
    ``gitea_delete_branch`` reports.
    """
    return _repository_binding_block(
        remote, org=org, repo=repo,
        required_permission="gitea.branch.delete",
    )


def _bound_repository_slug(remote: str | None) -> str | None:
    """Canonical repository slug for the active session, or None (#745).

    Same trust order as ``_repository_binding_block``: the configured canonical
    root when the namespace declares one, otherwise the workspace-derived
    identity. Request parameters are never consulted, so they cannot redirect a
    mutation at a foreign repository.
    """
    canonical_slug, canonical_reasons = _canonical_repository_slug(
        get_profile(), remote
    )
    if canonical_reasons:
        return None
    return canonical_slug or _workspace_repository_slug(remote)


@mcp.tool()
def gitea_delete_branch(
    branch: str,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Delete a remote branch from a Gitea repository.

    Args:
        branch: The remote branch name (e.g. 'feat/branch-name').
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.

    Returns:
        dict with 'success' and 'message'; on a permission block,
        'success'/'performed' False, 'reasons', and a structured
        'permission_report' (#142) with no API call made.
    """
    gate_reasons = _profile_operation_gate("gitea.branch.delete")
    if gate_reasons:
        return {
            "success": False,
            "performed": False,
            "required_permission": "gitea.branch.delete",
            "reasons": gate_reasons,
            "permission_report": _permission_block_report("gitea.branch.delete"),
        }

    # Possessing gitea.branch.delete alone is not enough for arbitrary deletion.
    # #729: task_capability_map maps delete_branch → reconciler (only the
    # reconciler profile holds gitea.branch.delete). The reconciler performs raw
    # deletion through the reconciliation-cleanup-phase authorization gate below
    # (operator approval + safe_to_delete proof), which is the sanctioned
    # authorized-cleanup role for raw branch deletion (#514/#687). Author,
    # reviewer, and merger are denied by the permission gate above and, even if
    # they somehow hold the permission, by the required-role gate here.
    # gitea_cleanup_merged_pr_branch remains a separate reconciler-owned path
    # with independent merged/ancestry/ownership verification.
    profile = get_profile()
    active_role = _profile_role_kind(profile)
    required_role = task_capability_map.required_role("delete_branch")
    if active_role != required_role:
        return {
            "success": False,
            "performed": False,
            "required_permission": "gitea.branch.delete",
            "required_role_kind": required_role,
            "active_role_kind": active_role,
            "reasons": [
                f"Active profile role '{active_role}' cannot perform "
                f"{required_role} task 'delete_branch' even when "
                "gitea.branch.delete is present (fail closed)"
            ],
            "permission_report": _permission_block_report("gitea.branch.delete"),
        }

    if branch_cleanup_guard.is_preservation_or_evidence_branch(branch):
        return {
            "success": False,
            "performed": False,
            "required_permission": "gitea.branch.delete",
            "reasons": [
                f"branch '{branch}' is a preservation/evidence branch and "
                "cannot be deleted (fail closed)"
            ],
        }
    if branch in branch_cleanup_guard.PROTECTED_BRANCHES:
        return {
            "success": False,
            "performed": False,
            "required_permission": "gitea.branch.delete",
            "reasons": [f"branch '{branch}' is protected (fail closed)"],
        }

    audit_allowed, audit_reasons = (
        audit_reconciliation_mode.check_audit_mutation_allowed("delete_branch")
    )
    if not audit_allowed:
        return {
            "success": False,
            "performed": False,
            "required_permission": "gitea.branch.delete",
            "reasons": audit_reasons,
            "audit_phase": audit_reconciliation_mode.current_phase(),
        }

    # #733: the explicitly targeted repository must agree with the immutable,
    # workspace-derived repository identity before any preflight or deletion.
    # This rejects wrong or substituted coordinates (e.g. an explicit Timesheet
    # target while the workspace is bound to Gitea-Tools) and fails closed when
    # explicit coordinates cannot be corroborated against a trusted workspace
    # identity. It is independent of the anti-stomp remote/repo guard, which by
    # the #530 contract trusts explicit caller intent; here explicit intent is
    # validated, not trusted. REMOTES defaults are never consulted as an
    # authorization scope (#530/#714).
    repo_binding_block = _delete_branch_repository_binding_block(
        remote, org=org, repo=repo,
    )
    if repo_binding_block:
        return repo_binding_block

    with _mutation_stage("preflight_purity"):
        # #733: forward the explicit org/repo through verify_preflight_purity so
        # the shared #604 anti-stomp resolution validates the *targeted*
        # repository instead of falling back to the remote-wide REMOTES default
        # (bare prgs → Timesheet), which false-positives wrong_repo and blocked
        # every explicit Scaled-Tech-Consulting/Gitea-Tools deletion.
        verify_preflight_purity(remote, task="delete_branch", org=org, repo=repo)
    with _mutation_stage("resolve_auth"):
        h, o, r = _resolve(remote, host, org, repo)
        auth = _auth(h)
    import urllib.parse
    encoded_branch = urllib.parse.quote(branch, safe="")
    url = f"{repo_api_url(h, o, r)}/branches/{encoded_branch}"
    request_metadata = {
        "branch": branch,
        "required_permission": "gitea.branch.delete",
    }
    with _mutation_stage("api_delete"), _audited(
        "delete_branch", host=h, remote=remote, org=o, repo=r,
        target_branch=branch, request_metadata=request_metadata):
        api_request("DELETE", url, auth)
    return {"success": True, "message": f"Remote branch '{branch}' deleted."}


@mcp.tool()
def gitea_cleanup_merged_pr_branch(
    pr_number: int,
    confirmation: str,
    branch: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    worktree_path: str | None = None,
) -> dict:
    """Delete a merged PR source branch through the guarded MCP path (#514)."""
    gate_reasons = _profile_operation_gate("gitea.branch.delete")
    if gate_reasons:
        return branch_cleanup_guard.cleanup_result_envelope(
            success=False,
            performed=False,
            delete_acknowledged=False,
            verified_absent=False,
            required_permission="gitea.branch.delete",
            reasons=gate_reasons,
            permission_report=_permission_block_report("gitea.branch.delete"),
        )

    profile = get_profile()
    active_role = _profile_role_kind(profile)
    # cleanup_merged_pr_branch is reconciler-owned (task_capability_map).
    # Author/reviewer/merger must not reach this path even if they somehow
    # hold gitea.branch.delete.
    if active_role != "reconciler":
        return branch_cleanup_guard.cleanup_result_envelope(
            success=False,
            performed=False,
            delete_acknowledged=False,
            verified_absent=False,
            required_permission="gitea.branch.delete",
            required_role_kind="reconciler",
            active_role_kind=active_role,
            reasons=[
                f"profile role '{active_role}' is not authorized for merged "
                "branch cleanup; required role is reconciler (fail closed)"
            ],
            permission_report=_permission_block_report("gitea.branch.delete"),
        )

    if worktree_path is None or "/branches/" not in os.path.realpath(worktree_path):
        return branch_cleanup_guard.cleanup_result_envelope(
            success=False,
            performed=False,
            delete_acknowledged=False,
            verified_absent=False,
            required_permission="gitea.branch.delete",
            reasons=[
                "merged branch cleanup requires an explicit branches/ worktree "
                "path; root checkout branch ref mutation is blocked (fail closed)"
            ],
        )

    verify_preflight_purity(
        remote,
        worktree_path=worktree_path,
        task="cleanup_merged_pr_branch",
        org=org,
        repo=repo,
    )

    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    base = repo_api_url(h, o, r)
    pr = api_request("GET", f"{base}/pulls/{pr_number}", auth)
    pr_head = pr.get("head") or {}
    head_branch = branch or pr_head.get("ref") or ""
    head_sha = pr_head.get("sha")
    target_branch = (pr.get("base") or {}).get("ref") or "master"

    if branch and branch != pr_head.get("ref"):
        return branch_cleanup_guard.cleanup_result_envelope(
            success=False,
            performed=False,
            delete_acknowledged=False,
            verified_absent=False,
            pr_number=pr_number,
            branch=branch,
            reasons=[
                f"requested branch '{branch}' does not match PR head "
                f"'{pr_head.get('ref')}'"
            ],
        )

    open_prs = api_get_all(f"{base}/pulls?state=open", auth)
    open_heads = {
        str((open_pr.get("head") or {}).get("ref"))
        for open_pr in open_prs
        if (open_pr.get("head") or {}).get("ref")
    }
    remote_exists = _remote_branch_exists(h, o, r, auth, head_branch)
    target_ref = (
        f"{remote}/{target_branch}"
        if remote in REMOTES
        else f"origin/{target_branch}"
    )
    head_on_target = merged_cleanup_reconcile.is_head_ancestor_of_ref(
        _canonical_local_git_root(),
        head_sha,
        target_ref,
    )
    assessment = branch_cleanup_guard.assess_merged_pr_branch_cleanup(
        pr_number=pr_number,
        head_branch=head_branch,
        merged=bool(pr.get("merged") or pr.get("merged_at")),
        remote_branch_exists=remote_exists,
        open_pr_heads=open_heads,
        head_on_target=head_on_target,
        delete_capability_allowed=True,
        confirmation=confirmation,
    )
    if not assessment["safe_to_delete"]:
        return branch_cleanup_guard.cleanup_result_envelope(
            success=False,
            performed=False,
            delete_acknowledged=False,
            verified_absent=False,
            pr_number=pr_number,
            branch=head_branch,
            assessment=assessment,
            reasons=assessment["block_reasons"],
        )

    # #687: active ownership protection (sessions/leases/worktree bindings).
    # Do not rely only on the caller worktree living under branches/.
    ownership_bundle = _collect_branch_ownership_records(
        remote=remote,
        host=h,
        org=o,
        repo=r,
        branch=head_branch,
        pr_number=pr_number,
        project_root=_canonical_local_git_root(),
        auth=auth,
        base_api=base,
    )
    ownership_records = ownership_bundle.get("records") or []
    if ownership_bundle.get("inventory_error"):
        # O1: control-plane / ownership inventory failure fails closed.
        ownership_records = list(ownership_records) + [
            {
                "category": branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR,
                "status": "unknown",
                "remote": remote,
                "host": h,
                "org": o,
                "repo": r,
                "branch": head_branch,
                "reclaim_allowed": False,
                "role": "inventory",
            }
        ]
    ownership = branch_cleanup_guard.assess_active_branch_ownership(
        remote=remote,
        org=o,
        repo=r,
        branch=head_branch,
        host=h,
        records=ownership_records,
    )
    if ownership.get("block"):
        return branch_cleanup_guard.cleanup_result_envelope(
            success=False,
            performed=False,
            delete_acknowledged=False,
            verified_absent=False,
            pr_number=pr_number,
            branch=head_branch,
            assessment=assessment,
            ownership={
                "block": True,
                "blocking_categories": ownership.get("blocking_categories") or [],
                "reasons": ownership.get("reasons") or [],
                "blocker_kind": ownership.get("blocker_kind"),
                "checked": True,
            },
            reasons=ownership.get("reasons")
            or ["active ownership protects the target branch"],
            blocker_kind="active_branch_ownership",
        )

    import urllib.parse

    encoded_branch = urllib.parse.quote(head_branch, safe="")
    url = f"{base}/branches/{encoded_branch}"
    request_metadata = {
        "branch": head_branch,
        "required_permission": "gitea.branch.delete",
        "cleanup_path": "gitea_cleanup_merged_pr_branch",
        "ownership_checked": True,
        "ownership_blocking_categories": [],
    }
    with _audited(
        "cleanup_merged_pr_branch",
        host=h,
        remote=remote,
        org=o,
        repo=r,
        pr_number=pr_number,
        target_branch=head_branch,
        request_metadata=request_metadata,
    ):
        api_request("DELETE", url, auth)

    # #687: authoritative post-delete readback. DELETE success alone is not
    # enough — only branch-scoped not-found proves deletion (R1).
    readback = _probe_remote_branch(h, o, r, auth, head_branch)
    readback_assessment = branch_cleanup_guard.assess_post_delete_readback(readback)
    verified_absent = bool(readback_assessment.get("verified_absent"))
    request_metadata["post_delete_readback"] = {
        "status": (readback_assessment.get("readback") or {}).get("status"),
        "verified_absent": verified_absent,
        "error_class": (readback_assessment.get("readback") or {}).get(
            "error_class"
        ),
        "not_found_scope": (readback_assessment.get("readback") or {}).get(
            "not_found_scope"
        ),
    }
    if gitea_audit.audit_enabled():
        _audit(
            "cleanup_merged_pr_branch_readback",
            host=h,
            remote=remote,
            org=o,
            repo=r,
            result=(
                gitea_audit.SUCCEEDED
                if readback_assessment.get("ok")
                else gitea_audit.FAILED
            ),
            reason="; ".join(readback_assessment.get("reasons") or []) or None,
            request_metadata=request_metadata,
            pr_number=pr_number,
            target_branch=head_branch,
        )

    if not readback_assessment.get("ok"):
        return branch_cleanup_guard.cleanup_result_envelope(
            success=False,
            performed=True,
            delete_acknowledged=True,
            verified_absent=False,
            pr_number=pr_number,
            branch=head_branch,
            assessment=assessment,
            ownership={
                "block": False,
                "blocking_categories": [],
                "checked": True,
            },
            readback=readback_assessment.get("readback"),
            reasons=readback_assessment.get("reasons")
            or ["post-delete branch readback could not verify deletion"],
            blocker_kind=readback_assessment.get("blocker_kind")
            or "post_delete_readback_failed",
            message=(
                f"DELETE accepted for '{head_branch}' but post-delete readback "
                "did not verify absence"
            ),
        )

    return branch_cleanup_guard.cleanup_result_envelope(
        success=True,
        performed=True,
        delete_acknowledged=True,
        verified_absent=True,
        pr_number=pr_number,
        branch=head_branch,
        message=(
            f"Merged PR #{pr_number} source branch '{head_branch}' deleted "
            "and verified absent via branch-scoped post-delete readback."
        ),
        assessment=assessment,
        ownership={
            "block": False,
            "blocking_categories": [],
            "checked": True,
        },
        readback=readback_assessment.get("readback"),
    )


def _probe_remote_branch(
    h: str, o: str, r: str, auth: str, branch: str
) -> dict:
    """GET a remote branch and return a secret-free structured readback.

    R1: On HTTP 404, re-probe the repository endpoint. Only when the repo is
    still reachable is the 404 treated as branch-scoped absence. Generic,
    repository-scoped, or host-level 404 never sets verified_absent.
    """
    import urllib.parse

    encoded = urllib.parse.quote(branch, safe="")
    url = f"{repo_api_url(h, o, r)}/branches/{encoded}"
    try:
        api_request("GET", url, auth)
        return branch_cleanup_guard.classify_branch_readback_http_status(200)
    except Exception as exc:
        status = branch_cleanup_guard._extract_http_status(exc)
        if status != 404:
            return branch_cleanup_guard.classify_branch_readback_exception(exc)

        # Distinguish branch vs repository/host 404 via repo reachability.
        repo_url = repo_api_url(h, o, r)
        try:
            api_request("GET", repo_url, auth)
            # Repo reachable → branch-scoped not-found.
            return branch_cleanup_guard.classify_branch_readback_http_status(
                404,
                not_found_scope=branch_cleanup_guard.NOT_FOUND_SCOPE_BRANCH,
            )
        except Exception as repo_exc:
            repo_status = branch_cleanup_guard._extract_http_status(repo_exc)
            if repo_status == 404:
                return branch_cleanup_guard.classify_branch_readback_http_status(
                    404,
                    not_found_scope=branch_cleanup_guard.NOT_FOUND_SCOPE_REPOSITORY,
                )
            if repo_status in (401, 407):
                return branch_cleanup_guard.classify_branch_readback_http_status(
                    401
                )
            if repo_status == 403:
                return branch_cleanup_guard.classify_branch_readback_http_status(
                    403
                )
            # Host/transport/unknown: not branch-verified.
            return branch_cleanup_guard.classify_branch_readback_http_status(
                404,
                not_found_scope=branch_cleanup_guard.NOT_FOUND_SCOPE_UNKNOWN,
            )


def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> bool:
    """True when the remote branch exists; False on authoritative branch 404.

    Authentication, authorization, transport, and ambiguous 404 failures are
    raised rather than treated as absence.
    """
    probe = _probe_remote_branch(h, o, r, auth, branch)
    status = probe.get("status")
    if (
        status == branch_cleanup_guard.READBACK_NOT_FOUND
        and probe.get("verified_absent")
    ):
        return False
    if status == branch_cleanup_guard.READBACK_EXISTS:
        return True
    error_class = probe.get("error_class") or "unexpected"
    raise RuntimeError(
        f"remote branch probe failed ({error_class}/{status})"
    )


def _collect_branch_ownership_records(
    *,
    remote: str,
    host: str,
    org: str,
    repo: str,
    branch: str,
    pr_number: int | None,
    project_root: str,
    auth: str | None = None,
    base_api: str | None = None,
) -> dict:
    """Gather canonical ownership records for *branch* (secret-free).

    Sources:
    - author issue locks (task-session / lease files)
    - control-plane leases (author/reviewer/merger/controller/reconciler)
    - comment-backed active reviewer leases (O3)
    - local worktree bindings checked out to the branch

    Returns ``{"records": [...], "inventory_error": bool}``. Control-plane
    inventory errors set inventory_error=True so callers fail closed (O1).
    """
    records: list[dict] = []
    inventory_error = False
    target_branch = (branch or "").strip()
    if not target_branch:
        return {"records": records, "inventory_error": False}

    host_n = branch_cleanup_guard.normalize_host(host)

    def _base_rec(**kwargs):
        rec = {
            "remote": remote,
            "host": host_n or host,
            "org": org,
            "repo": repo,
            "branch": target_branch,
        }
        rec.update(kwargs)
        return rec

    # --- Author issue locks (session + lease) ---
    try:
        for path in issue_lock_store.iter_lock_files():
            lock = issue_lock_store.read_lock_file(path)
            if not isinstance(lock, dict):
                continue
            if (
                str(lock.get("remote") or "") != str(remote)
                or str(lock.get("org") or "") != str(org)
                or str(lock.get("repo") or "") != str(repo)
            ):
                continue
            # Host identity when present on the lock
            lock_host = branch_cleanup_guard.normalize_host(
                lock.get("host") or lock.get("host_name")
            )
            if host_n and lock_host and host_n != lock_host:
                continue
            if str(lock.get("branch_name") or "").strip() != target_branch:
                continue
            freshness = issue_lock_store.assess_lock_freshness(lock)
            reclaim = issue_lock_store.assess_expired_lock_reclaim(lock)
            status = str(freshness.get("status") or "unknown")
            if freshness.get("live"):
                status = "active"
            records.append(
                _base_rec(
                    category=branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
                    status=status,
                    reclaim_allowed=bool(reclaim.get("reclaim_allowed")),
                    role="author",
                )
            )
            if freshness.get("live"):
                records.append(
                    _base_rec(
                        category=(
                            branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_SESSION
                        ),
                        status="active",
                        reclaim_allowed=False,
                        role="author",
                    )
                )
    except Exception:
        inventory_error = True
        records.append(
            _base_rec(
                category=branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
                status="unknown",
                reclaim_allowed=False,
                role="author",
            )
        )

    # --- Control-plane leases (role-tagged) ---
    try:
        db = None
        if hasattr(control_plane_db, "get_db"):
            try:
                db = control_plane_db.get_db()
            except Exception:
                db = None
        if db is None and hasattr(control_plane_db, "ControlPlaneDB"):
            try:
                db = control_plane_db.ControlPlaneDB()
            except Exception:
                db = None
                inventory_error = True
        if db is None:
            # O1: unavailable control-plane inventory fails closed.
            inventory_error = True
        else:
            listed = lease_lifecycle.list_active_leases(
                db,
                remote=remote,
                org=org,
                repo=repo,
                include_non_active=True,
                limit=200,
            )
            for lease in listed.get("leases") or []:
                work_kind = str(lease.get("work_kind") or "")
                work_number = lease.get("work_number")
                lease_branch = str(
                    lease.get("branch")
                    or lease.get("branch_name")
                    or ""
                ).strip()
                matches_branch = lease_branch == target_branch
                matches_pr = (
                    work_kind == "pr"
                    and pr_number is not None
                    and int(work_number or 0) == int(pr_number)
                )
                if not (matches_branch or matches_pr):
                    continue
                if matches_pr and not lease_branch:
                    lease_branch = target_branch
                if lease_branch != target_branch:
                    continue
                lease_host = branch_cleanup_guard.normalize_host(
                    lease.get("host") or lease.get("host_name")
                )
                if host_n and lease_host and host_n != lease_host:
                    continue
                fr = lease.get("freshness") or lease_lifecycle.classify_lease_freshness(
                    lease
                )
                freshness_status = str(
                    (fr.get("freshness") if isinstance(fr, dict) else None)
                    or lease.get("status")
                    or "unknown"
                )
                role = str(lease.get("role") or "unknown")
                category = branch_cleanup_guard.ownership_category_for_role(role)
                # O2: expired never auto-receives reclaim_allowed=True.
                if freshness_status == "active":
                    status = "active"
                    reclaim_allowed = False
                elif freshness_status in {"released", "abandoned"}:
                    status = freshness_status
                    reclaim_allowed = True
                elif freshness_status == "expired" or (
                    isinstance(fr, dict) and fr.get("expired_by_time")
                ):
                    status = "expired"
                    reclaim_allowed = False  # O2 fail closed
                else:
                    status = freshness_status
                    reclaim_allowed = False
                records.append(
                    _base_rec(
                        category=category,
                        status=status,
                        reclaim_allowed=reclaim_allowed,
                        role=role,
                        host=lease_host or host_n or host,
                    )
                )
    except Exception:
        # O1: fail closed on control-plane inventory errors.
        inventory_error = True
        records.append(
            _base_rec(
                category=branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR,
                status="unknown",
                reclaim_allowed=False,
                role="control_plane",
            )
        )

    # --- O3: comment-backed active reviewer leases ---
    if pr_number is not None and auth and base_api:
        try:
            comments = api_get_all(
                f"{base_api}/issues/{int(pr_number)}/comments", auth
            )
            active = reviewer_pr_lease.find_active_reviewer_lease(
                comments, pr_number=int(pr_number)
            )
            if active:
                records.append(
                    _base_rec(
                        category=(
                            branch_cleanup_guard.OWNERSHIP_CATEGORY_REVIEWER_LEASE
                        ),
                        status="active",
                        reclaim_allowed=False,
                        role="reviewer",
                    )
                )
        except Exception:
            inventory_error = True
            records.append(
                _base_rec(
                    category=branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR,
                    status="unknown",
                    reclaim_allowed=False,
                    role="reviewer_comment_lease",
                )
            )

    # --- Worktree bindings checked out to the target branch ---
    try:
        for entry in worktree_cleanup_audit.list_worktrees(project_root):
            wt_branch = str(entry.get("branch") or "").strip()
            if wt_branch != target_branch:
                continue
            records.append(
                _base_rec(
                    category=(
                        branch_cleanup_guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING
                    ),
                    status="active",
                    reclaim_allowed=False,
                    role="worktree",
                )
            )
    except Exception:
        inventory_error = True
        records.append(
            _base_rec(
                category=branch_cleanup_guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING,
                status="unknown",
                reclaim_allowed=False,
                role="worktree",
            )
        )

    return {"records": records, "inventory_error": inventory_error}



@mcp.tool()
def gitea_capture_branches_worktree_snapshot(
    open_pr_branches: list[str] | None = None,
    active_lock_branch: str | None = None,
    leased_paths: list[str] | None = None,
    worktree_path: str | None = None,
) -> dict:
    """Read-only: capture ``branches/`` and worktree audit snapshot (#404)."""
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    root = _canonical_local_git_root()
    if worktree_path:
        root = os.path.realpath(os.path.abspath(worktree_path))
        git_root = _get_git_root(root)
        if git_root:
            root = git_root
    snapshot = worktree_cleanup_audit.capture_branches_worktree_snapshot(
        root,
        open_pr_branches=open_pr_branches,
        active_lock_branch=active_lock_branch,
        leased_paths=leased_paths,
    )
    return {"success": True, "snapshot": snapshot}


@mcp.tool()
def gitea_assess_worktree_cleanup_integrity(
    before_snapshot: dict,
    after_snapshot: dict,
    removals: list[dict] | None = None,
    explained_missing: dict[str, str] | None = None,
) -> dict:
    """Read-only: reconcile cleanup before/after snapshots (#404)."""
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "integrity_passed": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    return worktree_cleanup_audit.assess_worktree_cleanup_integrity(
        before=before_snapshot,
        after=after_snapshot,
        removals=removals,
        explained_missing=explained_missing,
    )


@mcp.tool()
def gitea_reconcile_merged_cleanups(
    dry_run: bool = True,
    execute_confirmed: bool = False,
    limit: int = 50,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Reconcile merged PRs by reporting and optionally cleaning remote branches and local worktrees.

    Args:
        dry_run: Defaults to True. When True, only builds the reconciliation report.
        execute_confirmed: Must be True when dry_run=False.
        limit: Max number of closed PRs to inspect.
        remote: Known Gitea instance ('dadeschools' or 'prgs').
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.

    Returns:
        dict with reconciliation report and any executed cleanup actions.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "performed": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }

    if not dry_run and not execute_confirmed:
        raise ValueError(
            "execute_confirmed must be True when dry_run=False (fail closed)"
        )

    if not dry_run:
        exec_allowed, exec_reasons = (
            audit_reconciliation_mode.check_cleanup_execution_allowed()
        )
        if not exec_allowed:
            return {
                "success": False,
                "performed": False,
                "reasons": exec_reasons,
                "audit_phase": audit_reconciliation_mode.current_phase(),
            }

    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    base = repo_api_url(h, o, r)
    closed_prs = api_get_all(f"{base}/pulls?state=closed", auth, limit=limit)
    open_prs = api_get_all(f"{base}/pulls?state=open", auth)

    merged_closed: list[dict] = []
    remote_branch_exists: dict[str, bool] = {}
    head_on_master: dict[int, bool | None] = {}
    master_ref = f"{remote}/master" if remote in REMOTES else "origin/master"

    for pr in closed_prs:
        if not (pr.get("merged") or pr.get("merged_at")):
            continue
        merged_closed.append(pr)
        head_ref = (pr.get("head") or {}).get("ref") or ""
        head_sha = (pr.get("head") or {}).get("sha")
        if head_ref and head_ref not in remote_branch_exists:
            remote_branch_exists[head_ref] = _remote_branch_exists(
                h, o, r, auth, head_ref
            )
        head_on_master[int(pr["number"])] = merged_cleanup_reconcile.is_head_ancestor_of_ref(
            _canonical_local_git_root(), head_sha, master_ref
        )

    delete_capability_allowed = not _profile_operation_gate("gitea.branch.delete")

    # #534: discover reviewer scratch trees and active leases for those PRs.
    scratch_candidates = merged_cleanup_reconcile.discover_reviewer_scratch_worktrees(
        _canonical_local_git_root()
    )
    active_reviewer_leases: dict[int, bool] = {}
    pr_states: dict[int, dict] = {}
    for scratch in scratch_candidates:
        pr_num = int(scratch["pr_number"])
        if pr_num in active_reviewer_leases:
            continue
        try:
            comments = api_get_all(f"{base}/issues/{pr_num}/comments", auth)
        except Exception:
            comments = []
        lease = reviewer_pr_lease.find_active_reviewer_lease(
            comments, pr_number=pr_num
        )
        active_reviewer_leases[pr_num] = bool(lease)
        try:
            pr_live = api_request("GET", f"{base}/pulls/{pr_num}", auth)
        except Exception:
            pr_live = {}
        pr_states[pr_num] = {
            "merged": bool((pr_live or {}).get("merged") or (pr_live or {}).get("merged_at")),
            "closed": (pr_live or {}).get("state") == "closed",
        }

    report = merged_cleanup_reconcile.build_reconciliation_report(
        project_root=_canonical_local_git_root(),
        closed_prs=merged_closed,
        open_prs=open_prs,
        remote_branch_exists=remote_branch_exists,
        head_on_master=head_on_master,
        delete_capability_allowed=delete_capability_allowed,
        target_ref=master_ref,
        active_reviewer_leases=active_reviewer_leases,
        pr_states=pr_states,
    )

    if dry_run:
        report["dry_run"] = True
        report["executed"] = False
        return {"success": True, "performed": False, **report}

    verify_preflight_purity(
        remote, task="reconcile_merged_cleanups", org=org, repo=repo
    )
    actions: list[dict] = []
    for entry in report.get("entries") or []:
        head_branch = entry.get("head_branch") or ""
        remote_assessment = entry.get("remote_branch") or {}
        local_assessment = entry.get("local_worktree") or {}

        if remote_assessment.get("safe_to_delete_remote"):
            import urllib.parse

            pr_num = entry.get("pr_number")
            try:
                pr_num_int = int(pr_num) if pr_num is not None else None
            except (TypeError, ValueError):
                pr_num_int = None
            ownership_bundle = _collect_branch_ownership_records(
                remote=remote,
                host=h,
                org=o,
                repo=r,
                branch=head_branch,
                pr_number=pr_num_int,
                project_root=_canonical_local_git_root(),
                auth=auth,
                base_api=base,
            )
            ownership_records = list(ownership_bundle.get("records") or [])
            if ownership_bundle.get("inventory_error"):
                ownership_records.append(
                    {
                        "category": (
                            branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR
                        ),
                        "status": "unknown",
                        "remote": remote,
                        "host": h,
                        "org": o,
                        "repo": r,
                        "branch": head_branch,
                        "reclaim_allowed": False,
                        "role": "inventory",
                    }
                )
            ownership = branch_cleanup_guard.assess_active_branch_ownership(
                remote=remote,
                org=o,
                repo=r,
                branch=head_branch,
                host=h,
                records=ownership_records,
            )
            if ownership.get("block"):
                actions.append(
                    {
                        "action": "delete_remote_branch",
                        "branch": head_branch,
                        "success": False,
                        "performed": False,
                        "delete_acknowledged": False,
                        "verified_absent": False,
                        "blocker_kind": "active_branch_ownership",
                        "reasons": ownership.get("reasons") or [],
                        "blocking_categories": ownership.get(
                            "blocking_categories"
                        )
                        or [],
                    }
                )
                continue

            encoded = urllib.parse.quote(head_branch, safe="")
            url = f"{base}/branches/{encoded}"
            with _audited(
                "delete_branch",
                host=h,
                remote=remote,
                org=o,
                repo=r,
                target_branch=head_branch,
                request_metadata={
                    "branch": head_branch,
                    "source": "reconcile_merged_cleanups",
                    "ownership_checked": True,
                },
            ):
                api_request("DELETE", url, auth)
            readback = _probe_remote_branch(h, o, r, auth, head_branch)
            readback_assessment = branch_cleanup_guard.assess_post_delete_readback(
                readback
            )
            verified = bool(readback_assessment.get("verified_absent"))
            actions.append(
                {
                    "action": "delete_remote_branch",
                    "branch": head_branch,
                    "success": bool(readback_assessment.get("ok")),
                    "performed": True,
                    "delete_acknowledged": True,
                    "verified_absent": verified,
                    "readback": readback_assessment.get("readback"),
                    "reasons": readback_assessment.get("reasons") or [],
                }
            )

        if local_assessment.get("safe_to_remove_worktree"):
            result = merged_cleanup_reconcile.remove_local_worktree(
                _canonical_local_git_root(),
                head_branch,
                worktree_path=local_assessment.get("worktree_path"),
            )
            actions.append({"action": "remove_local_worktree", **result})

    for scratch in report.get("reviewer_scratch_entries") or []:
        if not scratch.get("safe_to_remove_worktree"):
            continue
        result = merged_cleanup_reconcile.remove_reviewer_scratch_worktree(
            _canonical_local_git_root(), scratch.get("worktree_path") or ""
        )
        actions.append({"action": "remove_reviewer_scratch_worktree", **result})

    report["dry_run"] = False
    report["executed"] = True
    report["actions"] = actions
    return {"success": True, "performed": True, **report}


@mcp.tool()
def gitea_authorize_reconciliation_cleanup_phase(
    operator_approved: bool = False,
    workflow_authorized: bool = False,
    delete_capability_proven: bool = False,
    safe_to_delete_remote: bool = False,
    safe_to_remove_worktree: bool = False,
    before_state: str = "",
    after_state: str = "",
) -> dict:
    """Authorize cleanup phase after audit-only reconciliation (#419).

    Requires operator or workflow approval, exact delete_branch capability proof,
    branch/worktree safety proof, and before/after state snapshots. Audit phase
    forbids branch deletion, worktree removal, pushes, and issue/PR mutations.
    """
    delete_gate = _profile_operation_gate("gitea.branch.delete")
    capability_ok = not bool(delete_gate)
    if delete_capability_proven and delete_gate:
        return {
            "authorized": False,
            "performed": False,
            "delete_capability_verified": False,
            "reasons": [
                "delete_capability_proven=true but active profile lacks "
                "gitea.branch.delete",
            ] + delete_gate,
            "audit_phase": audit_reconciliation_mode.current_phase(),
        }
    result = audit_reconciliation_mode.authorize_cleanup_phase(
        operator_approved=operator_approved,
        workflow_authorized=workflow_authorized,
        delete_capability_proven=delete_capability_proven and capability_ok,
        safety_proof={
            "safe_to_delete_remote": safe_to_delete_remote,
            "safe_to_remove_worktree": safe_to_remove_worktree,
        },
        before_after_snapshot={
            "before": (before_state or "").strip(),
            "after": (after_state or "").strip(),
        },
    )
    result["performed"] = bool(result.get("authorized"))
    result["delete_capability_verified"] = capability_ok
    return result


@mcp.tool()
def gitea_assess_already_landed_reconciliation(
    pr_number: int,
    target_branch: str = "master",
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Read-only: assess already-landed reconciliation for one open PR (#301).

    Fetches the PR live, refreshes the target branch, checks ancestor proof,
    and returns a capability plan for the active profile. Does not review,
    approve, request changes, merge, or mutate Gitea state.

    Args:
        pr_number: Open PR to assess.
        target_branch: Branch used for ancestry proof (default master).
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.

    Returns:
        dict with eligibility proof, capability plan, and safe next action.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "performed": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }

    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    pr = api_request("GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth)

    assessment = reconciliation_workflow.assess_open_pr_reconciliation(
        pr=pr,
        project_root=_canonical_local_git_root(),
        remote=remote,
        target_branch=target_branch,
    )

    profile = get_profile()
    capabilities = reconciliation_workflow.profile_reconciliation_capabilities(
        profile.get("allowed_operations"),
        profile.get("forbidden_operations"),
    )
    plan = reconciliation_workflow.resolve_reconciliation_plan(
        assessment=assessment,
        capabilities=capabilities,
    )

    return {
        "success": True,
        "performed": False,
        "pr_number": pr_number,
        "assessment": assessment,
        "capabilities": capabilities,
        "plan": plan,
        "workflow_source": "skills/llm-project-workflow/workflows/reconcile-landed-pr.md",
        "task_mode": "reconcile-landed-pr",
        "review_merge_allowed": False,
    }


@mcp.tool()
def gitea_scan_already_landed_open_prs(
    target_branch: str = "master",
    limit: int = 50,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Read-only: list open PRs already landed on the target branch (#301).

    Traverses open PR inventory with pagination proof, assesses each candidate,
    and returns PRs classified as ``ALREADY_LANDED_RECONCILE_REQUIRED``. Does
    not review, approve, merge, or mutate Gitea state.

    Args:
        target_branch: Branch used for ancestry proof (default master).
        limit: Max open PRs to inspect across pages.
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.

    Returns:
        dict with candidates, pagination metadata, and target branch SHA proof.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "performed": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }

    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    url = f"{repo_api_url(h, o, r)}/pulls?state=open"

    open_prs: list[dict] = []
    current_page = 1
    pages_fetched = 0
    last_meta: dict = {}
    per_page = min(50, max(1, int(limit)))
    while pages_fetched < 100 and len(open_prs) < limit:
        raw_page, last_meta = api_fetch_page(
            url, auth, page=current_page, limit=per_page
        )
        pages_fetched += 1
        remaining = limit - len(open_prs)
        open_prs.extend(raw_page[:remaining])
        if last_meta["is_final_page"] or len(open_prs) >= limit:
            break
        current_page += 1

    pagination = {
        "page": 1,
        "per_page": per_page,
        "returned_count": len(open_prs),
        "has_more": False,
        "next_page": None,
        "is_final_page": bool(last_meta.get("is_final_page")),
        "pages_fetched": pages_fetched,
        "inventory_complete": bool(last_meta.get("is_final_page")),
        "total_count": len(open_prs) if last_meta.get("is_final_page") else None,
    }

    target_fetch = reconciliation_workflow.fetch_target_branch(
        _canonical_local_git_root(), remote, target_branch
    )

    candidates: list[dict] = []
    for pr in open_prs:
        assessment = reconciliation_workflow.assess_open_pr_reconciliation(
            pr=pr,
            project_root=_canonical_local_git_root(),
            remote=remote,
            target_branch=target_branch,
            target_fetch=target_fetch,
        )
        if assessment.get("eligibility_class") == (
            reconciliation_workflow.ELIGIBILITY_ALREADY_LANDED
        ):
            candidates.append(assessment)

    return {
        "success": True,
        "performed": False,
        "target_branch": target_branch,
        "target_branch_sha": target_fetch.get("target_branch_sha"),
        "git_ref_mutations": (
            [target_fetch["git_fetch_command"]]
            if target_fetch.get("git_fetch_command")
            else []
        ),
        "candidates": candidates,
        "candidate_count": len(candidates),
        "pagination": pagination,
        "task_mode": "reconcile-landed-pr",
        "review_merge_allowed": False,
    }


@mcp.tool()
def gitea_audit_worktree_cleanup(
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    ttl_hours: float = worktree_cleanup_audit.DEFAULT_TTL_HOURS,
) -> dict:
    """Read-only: classify every session-owned worktree under ``branches/`` (#401).

    Audits the local ``branches/`` directory, classifying each worktree as
    active open PR, active issue work, dirty local, clean stale removable,
    detached review leftover, or unsafe/unknown. Open PR branch heads are
    fetched live so a worktree tied to an open PR is never marked removable;
    the active issue-lock branch is read from the local lock file and treated
    as active work. Deletes nothing and mutates no Gitea state.

    Fails closed if the live open-PR list cannot be fetched: without it,
    removability cannot be proven, so no candidates are returned.

    Args:
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.
        ttl_hours: Age (hours) after which a clean issue/conflict-fix
            worktree becomes stale-removable (default from
            GITEA_WORKTREE_TTL_HOURS).

    Returns:
        dict with per-worktree classifications, counts, removable
        candidates, and the ``git worktree list`` verification proof.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "performed": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }

    try:
        h, o, r = _resolve(remote, host, org, repo)
        auth = _auth(h)
        open_prs = api_get_all(f"{repo_api_url(h, o, r)}/pulls?state=open", auth)
    except Exception as exc:
        return {
            "success": False,
            "performed": False,
            "open_pr_state_verified": False,
            "reasons": [
                "could not fetch live open PRs; removability unverified "
                f"(fail closed): {_redact(str(exc))}"
            ],
        }

    open_pr_branches = {
        str((pr.get("head") or {}).get("ref"))
        for pr in open_prs
        if (pr.get("head") or {}).get("ref")
    }

    active_issue_branches: set[str] = set()
    lock = merged_cleanup_reconcile.read_issue_lock(ISSUE_LOCK_FILE)
    if lock and lock.get("branch_name"):
        active_issue_branches.add(str(lock["branch_name"]).strip())

    report = worktree_cleanup_audit.audit_branches_directory(
        _canonical_local_git_root(),
        open_pr_branches=open_pr_branches,
        active_issue_branches=active_issue_branches,
        now=datetime.now(timezone.utc),
        ttl_hours=ttl_hours,
    )
    return {
        "success": True,
        "performed": False,
        "open_pr_state_verified": True,
        "task_mode": "work-issue",
        **report,
    }


@mcp.tool()
def gitea_reconcile_already_landed_pr(
    pr_number: int,
    close_pr: bool = False,
    close_linked_issue: bool = False,
    post_comment: bool = False,
    comment_body: str = "",
    target_branch: str = "master",
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Reconcile an open PR whose head is already on the target branch (#310).

    Read-only assessment always runs. PR close requires ``gitea.pr.close`` and
    proof that the pinned PR head SHA is an ancestor of a freshly fetched
    target branch. Arbitrary PR closure is denied.

    Args:
        pr_number: Open PR to reconcile.
        close_pr: When True, close the PR after already-landed proof passes.
        close_linked_issue: When True, close a linked issue after live fetch
            when the issue is open and ``gitea.issue.close`` is allowed.
        post_comment: When True, post ``comment_body`` on the PR thread.
        comment_body: PR comment text when ``post_comment`` is True.
        target_branch: Target branch used for ancestry proof (default master).
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.

    Returns:
        dict with eligibility proof, optional mutations, and recovery guidance.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "performed": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }

    if close_pr:
        close_block = _profile_operation_gate("gitea.pr.close")
        if close_block:
            return {
                "success": False,
                "performed": False,
                "pr_number": pr_number,
                "required_permission": "gitea.pr.close",
                "reasons": close_block,
                "permission_report": _permission_block_report("gitea.pr.close"),
                "safe_next_action": (
                    "Launch the prgs-reconciler MCP namespace or configure a "
                    "profile with gitea.pr.close for already-landed cleanup."
                ),
            }

    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    base = repo_api_url(h, o, r)
    pr_url = f"{base}/pulls/{pr_number}"
    pr = api_request("GET", pr_url, auth)

    assessment = already_landed_reconcile.assess_already_landed_reconciliation(
        pr=pr,
        project_root=_canonical_local_git_root(),
        remote=remote,
        target_branch=target_branch,
    )

    result: dict = {
        "success": True,
        "performed": False,
        "pr_number": pr_number,
        "pr_state": assessment.get("pr_state"),
        "candidate_head_sha": assessment.get("candidate_head_sha"),
        "target_branch": assessment.get("target_branch"),
        "target_branch_sha": assessment.get("target_branch_sha"),
        "ancestor_proof": assessment.get("ancestor_proof"),
        "eligibility_class": assessment.get("eligibility_class"),
        "linked_issue": assessment.get("linked_issue"),
        "close_allowed": assessment.get("close_allowed"),
        "git_ref_mutations": assessment.get("git_ref_mutations") or [],
        "reasons": list(assessment.get("reasons") or []),
        "pr_closed": False,
        "issue_closed": False,
        "pr_comment_posted": False,
    }

    if not assessment.get("close_allowed"):
        result["success"] = False
        result["safe_next_action"] = (
            "Do not close this PR via the reconciler path until "
            "already-landed proof passes."
        )
        return result

    verify_preflight_purity(
        remote, task="reconcile_already_landed_pr", org=org, repo=repo
    )

    if post_comment and comment_body.strip():
        comment_block = _profile_operation_gate("gitea.pr.comment")
        if comment_block:
            result["success"] = False
            result["reasons"].extend(comment_block)
            result["permission_report"] = _permission_block_report(
                "gitea.pr.comment"
            )
            return result
        gate = _canonical_comment_gate(comment_body)
        if gate["blocked"]:
            result["success"] = False
            result["reasons"].extend(gate["reasons"])
            result["canonical_comment_validation"] = gate["canonical_comment_validation"]
            return result
        comment_url = f"{base}/issues/{pr_number}/comments"
        with _audited(
            "comment_pr",
            host=h,
            remote=remote,
            org=o,
            repo=r,
            pr_number=pr_number,
            request_metadata={"source": "reconcile_already_landed_pr"},
        ):
            api_request("POST", comment_url, auth, {"body": comment_body.strip()})
        result["pr_comment_posted"] = True
        result["performed"] = True

    if close_pr:
        patch_url = f"{base}/pulls/{pr_number}"
        request_metadata = {
            "source": "reconcile_already_landed_pr",
            "required_permission": "gitea.pr.close",
            "candidate_head_sha": assessment.get("candidate_head_sha"),
            "target_branch_sha": assessment.get("target_branch_sha"),
            "ancestor_proof": assessment.get("ancestor_proof"),
        }
        with _audited(
            "close_pr",
            host=h,
            remote=remote,
            org=o,
            repo=r,
            pr_number=pr_number,
            request_metadata=request_metadata,
        ):
            closed = api_request("PATCH", patch_url, auth, {"state": "closed"})
        result["pr_closed"] = True
        result["performed"] = True
        result["pr_state"] = closed.get("state", "closed")

    linked_issue = assessment.get("linked_issue")
    if close_linked_issue and linked_issue:
        issue_block = _profile_operation_gate("gitea.issue.close")
        if issue_block:
            result["reasons"].extend(issue_block)
            result["issue_close_blocked"] = True
            return result
        issue_url = f"{base}/issues/{linked_issue}"
        issue = api_request("GET", issue_url, auth)
        result["linked_issue_live_status"] = issue.get("state")
        if (issue.get("state") or "").lower() == "open":
            with _audited(
                "close_issue",
                host=h,
                remote=remote,
                org=o,
                repo=r,
                issue_number=linked_issue,
                request_metadata={"source": "reconcile_already_landed_pr"},
            ):
                api_request(
                    "PATCH",
                    issue_url,
                    auth,
                    {"state": "closed"},
                )
            result["issue_closed"] = True
            result["performed"] = True

    return result


@mcp.tool()
def gitea_reconcile_superseded_by_merged_pr(
    target_pr_number: int,
    superseding_pr_number: int,
    target_issue_number: int | None = None,
    target_independently_required: bool | None = None,
    issue_satisfied_by_superseding: bool = False,
    close_pr: bool = False,
    close_issue: bool = False,
    post_comment: bool = False,
    comment_body: str = "",
    target_branch: str = "master",
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Reconcile a PR/issue superseded by a different merged PR (#525).

    The tool always performs live-state assessment. Mutations require a
    reconciler-capable profile, a merged superseding PR whose head is on the
    freshly fetched target branch, explicit proof that the target PR has no
    independent remaining work, and a canonical close comment that cites the
    superseding PR and merge commit.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "performed": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }

    close_pr_capability = True
    close_issue_capability = True
    if close_pr:
        close_block = _profile_operation_gate("gitea.pr.close")
        close_pr_capability = not bool(close_block)
        if close_block:
            return {
                "success": False,
                "performed": False,
                "target_pr_number": target_pr_number,
                "required_permission": "gitea.pr.close",
                "reasons": close_block,
                "permission_report": _permission_block_report("gitea.pr.close"),
                "safe_next_action": (
                    "Launch the prgs-reconciler MCP namespace or configure a "
                    "profile with gitea.pr.close for supersession cleanup."
                ),
            }
    if close_issue:
        issue_block = _profile_operation_gate("gitea.issue.close")
        close_issue_capability = not bool(issue_block)
        if issue_block:
            return {
                "success": False,
                "performed": False,
                "target_issue_number": target_issue_number,
                "required_permission": "gitea.issue.close",
                "reasons": issue_block,
                "permission_report": _permission_block_report("gitea.issue.close"),
            }
    if post_comment:
        comment_block = _profile_operation_gate("gitea.pr.comment")
        if comment_block:
            return {
                "success": False,
                "performed": False,
                "target_pr_number": target_pr_number,
                "required_permission": "gitea.pr.comment",
                "reasons": comment_block,
                "permission_report": _permission_block_report("gitea.pr.comment"),
            }

    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    base = repo_api_url(h, o, r)
    target_pr = api_request("GET", f"{base}/pulls/{target_pr_number}", auth)
    superseding_pr = api_request(
        "GET", f"{base}/pulls/{superseding_pr_number}", auth
    )

    target_fetch = already_landed_reconcile.fetch_target_branch(
        _canonical_local_git_root(), remote, target_branch
    )
    superseding_head_sha = (
        (superseding_pr.get("head") or {}).get("sha")
        if isinstance(superseding_pr.get("head"), dict)
        else None
    )
    ancestor = None
    if target_fetch.get("success"):
        ancestor = already_landed_reconcile.is_head_ancestor_of_ref(
            _canonical_local_git_root(),
            superseding_head_sha,
            target_fetch.get("target_ref") or f"{remote}/{target_branch}",
        )

    issue_state = None
    if target_issue_number:
        issue = api_request("GET", f"{base}/issues/{target_issue_number}", auth)
        issue_state = issue.get("state")

    stripped_comment = comment_body.strip()
    canonical_gate = (
        _canonical_comment_gate(stripped_comment)
        if stripped_comment
        else {"blocked": True, "canonical_comment_validation": None}
    )
    merge_commit_sha = (superseding_pr.get("merge_commit_sha") or "").strip()
    comment_mentions_superseding = (
        f"#{superseding_pr_number}" in stripped_comment
        or f"PR {superseding_pr_number}" in stripped_comment
    )
    comment_mentions_merge = bool(
        merge_commit_sha and merge_commit_sha in stripped_comment
    )

    gate = review_proofs.assess_reconciler_supersession_close_gate(
        target_pr_number=target_pr_number,
        target_pr_state=target_pr.get("state"),
        target_pr_mergeable=target_pr.get("mergeable"),
        target_independently_required=target_independently_required,
        superseding_pr_number=superseding_pr_number,
        superseding_pr_state=superseding_pr.get("state"),
        superseding_pr_merged=bool(superseding_pr.get("merged")),
        superseding_merge_commit_sha=merge_commit_sha,
        superseding_head_sha=superseding_head_sha,
        target_branch=target_branch,
        target_branch_sha=target_fetch.get("target_branch_sha"),
        superseding_head_is_ancestor_of_target=ancestor,
        canonical_comment_valid=not canonical_gate["blocked"],
        canonical_comment_mentions_superseding_pr=comment_mentions_superseding,
        canonical_comment_mentions_merge_commit=comment_mentions_merge,
        close_pr_capability=close_pr_capability,
        close_issue_capability=close_issue_capability,
        target_issue_state=issue_state,
        issue_satisfied_by_superseding=issue_satisfied_by_superseding,
    )

    result: dict = {
        "success": gate["pr_close_allowed"],
        "performed": False,
        "target_pr_number": target_pr_number,
        "superseding_pr_number": superseding_pr_number,
        "target_issue_number": target_issue_number,
        "target_pr_state": target_pr.get("state"),
        "target_pr_mergeable": target_pr.get("mergeable"),
        "target_independently_required": target_independently_required,
        "superseding_pr_state": superseding_pr.get("state"),
        "superseding_pr_merged": bool(superseding_pr.get("merged")),
        "superseding_merge_commit_sha": merge_commit_sha,
        "superseding_head_sha": superseding_head_sha,
        "target_branch": target_branch,
        "target_branch_sha": target_fetch.get("target_branch_sha"),
        "superseding_head_is_ancestor_of_target": ancestor,
        "linked_issue_live_status": issue_state,
        "gate": gate,
        "reasons": list(gate.get("reasons") or []),
        "canonical_comment_validation": canonical_gate.get(
            "canonical_comment_validation"
        ),
        "pr_comment_posted": False,
        "pr_closed": False,
        "issue_closed": False,
    }

    wants_mutation = close_pr or close_issue or post_comment
    if not gate["pr_close_allowed"]:
        result["safe_next_action"] = gate.get("safe_next_action")
        return result
    if close_issue and not gate["issue_close_allowed"]:
        result["success"] = False
        result["safe_next_action"] = (
            "do not mutate; linked issue close was requested but the "
            "supersession gate did not allow issue closure"
        )
        return result
    if not wants_mutation:
        result["success"] = True
        result["safe_next_action"] = "rerun with explicit mutation flags"
        return result

    verify_preflight_purity(
        remote, task="reconcile_close_superseded_pr", org=org, repo=repo
    )

    if post_comment:
        comment_url = f"{base}/issues/{target_pr_number}/comments"
        with _audited(
            "comment_pr",
            host=h,
            remote=remote,
            org=o,
            repo=r,
            pr_number=target_pr_number,
            request_metadata={"source": "reconcile_superseded_by_merged_pr"},
        ):
            api_request("POST", comment_url, auth, {"body": stripped_comment})
        result["pr_comment_posted"] = True
        result["performed"] = True

    if close_pr:
        with _audited(
            "close_pr",
            host=h,
            remote=remote,
            org=o,
            repo=r,
            pr_number=target_pr_number,
            request_metadata={
                "source": "reconcile_superseded_by_merged_pr",
                "superseding_pr_number": superseding_pr_number,
                "superseding_merge_commit_sha": merge_commit_sha,
                "target_branch_sha": target_fetch.get("target_branch_sha"),
            },
        ):
            closed = api_request(
                "PATCH",
                f"{base}/pulls/{target_pr_number}",
                auth,
                {"state": "closed"},
            )
        result["target_pr_state"] = closed.get("state", "closed")
        result["pr_closed"] = True
        result["performed"] = True

    if close_issue and target_issue_number and gate["issue_close_allowed"]:
        with _audited(
            "close_issue",
            host=h,
            remote=remote,
            org=o,
            repo=r,
            issue_number=target_issue_number,
            request_metadata={
                "source": "reconcile_superseded_by_merged_pr",
                "superseding_pr_number": superseding_pr_number,
                "superseding_merge_commit_sha": merge_commit_sha,
            },
        ):
            api_request(
                "PATCH",
                f"{base}/issues/{target_issue_number}",
                auth,
                {"state": "closed"},
            )
        result["issue_closed"] = True
        result["performed"] = True

    return result

@mcp.tool()
def gitea_close_issue(
    issue_number: int,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    closure_report: str | None = None,
) -> dict:
    """Close an issue by setting its state to 'closed'.

    Args:
        issue_number: The issue number to close.
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.
        closure_report: Optional validation/closure summary. When it claims a
            non-zero test-suite exit is an "expected pre-existing"/baseline
            failure without pre-merge proof, the close fails closed (#529).

    Returns:
        dict with 'success' boolean and 'message'.
    """
    blocked = _profile_permission_block(
        task_capability_map.required_permission("close_issue"),
        issue_number=issue_number,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
        org_explicit=org is not None,
        repo_explicit=repo is not None,
    )
    if blocked:
        return blocked
    verify_preflight_purity(remote, task="close_issue", org=org, repo=repo)

    # #529: block closure that buries an unproven non-zero suite exit as an
    # "expected pre-existing failure". Skipped when no closure report is given.
    from controller_closure_baseline_proof import (
        assess_controller_closure_baseline_proof,
    )
    closure_gate = assess_controller_closure_baseline_proof(closure_report)
    if closure_gate["block"]:
        return {
            "success": False,
            "blocked": True,
            "message": (
                f"Issue #{issue_number} close blocked (#529): closure report "
                "claims an expected pre-existing/baseline failure without "
                "pre-merge proof."
            ),
            "reasons": closure_gate["reasons"],
            "safe_next_action": closure_gate["safe_next_action"],
        }
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    url = f"{repo_api_url(h, o, r)}/issues/{issue_number}"
    with _audited("close_issue", host=h, remote=remote, org=o, repo=r,
                  issue_number=issue_number, request_metadata={"state": "closed"}):
        api_request("PATCH", url, auth, {"state": "closed"})

    cleanup_result = release_in_progress_label([issue_number], remote, host, org, repo)

    return {
        "success": True,
        "message": f"Issue #{issue_number} closed.",
        "cleanup_status": cleanup_result
    }


@mcp.tool()
def gitea_list_issues(
    state: str = "open",
    label: str | None = None,
    limit: int = 50,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> list[dict]:
    """List issues on a Gitea repository with optional filters.

    Args:
        state: Filter by state — 'open', 'closed', or 'all'.
        label: Filter by label name (e.g. 'important').
        limit: Max number of issues to return across all pages (default: 50).
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.

    Returns:
        List of dicts with 'number', 'title', 'state', 'labels', 'assignee'.
    """
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    params = f"state={state}&type=issues"
    if label:
        params += f"&labels={label}"
    url = f"{repo_api_url(h, o, r)}/issues?{params}"
    issues = api_get_all(url, auth, limit=limit)
    return [
        {
            "number": i["number"],
            "title": i["title"],
            "state": i["state"],
            "labels": [lb["name"] for lb in i.get("labels", [])],
            "assignee": (i.get("assignee") or {}).get("login", ""),
        }
        for i in issues
    ]


@mcp.tool()
def gitea_view_issue(
    issue_number: int,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Get full details of a single issue.

    Args:
        issue_number: The issue number to view.
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.

    Returns:
        dict with 'number', 'title', 'body', 'state', 'labels', and 'assignee'
        ('url' only with the reveal opt-in).
    """
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    url = f"{repo_api_url(h, o, r)}/issues/{issue_number}"
    i = api_request("GET", url, auth)
    result = {
        "number": i["number"],
        "title": i["title"],
        "body": i.get("body", ""),
        "state": i["state"],
        "labels": [lb["name"] for lb in i.get("labels", [])],
        "assignee": (i.get("assignee") or {}).get("login", ""),
    }
    return _with_optional_url(result, i.get("html_url"))


def _permission_block_report(required_operation: str,
                             identity: str | None = None) -> dict:
    """Structured, LLM-safe explanation of a permission denial (#142).

    Built only after a gate has already refused; it adds guidance to the
    refusal and never widens any permission, performs network I/O, or
    raises (fail-soft: degrades to a minimal fail-closed report). Names
    configured profiles only — never auth references, tokens, endpoint
    URLs, or keychain IDs.
    """
    report = {
        "requested_operation": required_operation,
        "missing_permission": required_operation,
        "required_permission": required_operation,
        "active_profile": None,
        "active_identity": identity,
        "active_allowed_operations": [],
        "matching_configured_profiles": [],
        "runtime_switching_supported": False,
        "different_mcp_namespace_required": True,
        "exact_safe_next_action": (
            "Ask the operator to fix GITEA_MCP_CONFIG/GITEA_MCP_PROFILE; "
            "the active profile could not be resolved (fail closed)."),
    }
    try:
        profile = get_profile()
    except Exception:
        return report
    report["active_profile"] = profile.get("profile_name")
    if identity is None:
        report["active_identity"] = profile.get("identity")
    report["active_allowed_operations"] = (
        profile.get("allowed_operations") or [])

    matching = []
    try:
        config = gitea_config.load_config() or {}
        for name, p in (config.get("profiles") or {}).items():
            p_allowed = []
            for op in p.get("allowed_operations") or []:
                try:
                    p_allowed.append(gitea_config.normalize_operation(op))
                except Exception:
                    pass
            p_forbidden = []
            for op in p.get("forbidden_operations") or []:
                try:
                    p_forbidden.append(gitea_config.normalize_operation(op))
                except Exception:
                    pass
            ok, _ = gitea_config.check_operation(
                required_operation, p_allowed, p_forbidden)
            if ok:
                matching.append(name)
    except Exception:
        matching = []
    report["matching_configured_profiles"] = matching

    try:
        switching = gitea_config.is_runtime_switching_enabled()
    except Exception:
        switching = False
    report["runtime_switching_supported"] = switching

    role = "reviewer" if required_operation in (
        "gitea.pr.approve", "gitea.pr.merge", "gitea.pr.request_changes",
        "gitea.pr.review") else "author"
    if switching and matching:
        report["different_mcp_namespace_required"] = False
        report["exact_safe_next_action"] = (
            f"Call gitea_activate_profile with a profile that allows "
            f"{required_operation} (matching configured profiles: "
            f"{matching}).")
    else:
        report["different_mcp_namespace_required"] = True
        report["exact_safe_next_action"] = (
            f"Switch to the {role} MCP session (e.g. gitea-{role}), or ask "
            f"the operator to set GITEA_MCP_PROFILE to a profile that "
            f"allows {required_operation}.")
    return report


def _role_for_operation(op: str) -> str | None:
    # Normalize op first
    try:
        op_n = gitea_config.normalize_operation(op)
    except Exception:
        op_n = op
    if op_n in ("gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review", "gitea.pr.request_changes"):
        return "reviewer"
    if op_n in (
        "gitea.issue.create",
        "gitea.issue.comment",
        "gitea.issue.close",
        "gitea.branch.create",
        "gitea.branch.push",
        "gitea.pr.create",
        "gitea.pr.comment",
        "gitea.pr.close",
        "gitea.branch.delete",
        "gitea.repo.commit"
    ):
        return "author"
    return None


def _try_auto_switch_for_operation(op: str, host: str | None = None) -> bool:
    """#714: automatic profile substitution is removed (fail closed).

    Always returns False. Callers must use explicit ``gitea_activate_profile``
    to change roles; capability and mutation gates evaluate only the active
    profile. Parameters retained for call-site compatibility.
    """
    del op, host
    return False


def _current_master_parity() -> dict:
    """Assess this process's code against the on-disk master HEAD (#420)."""
    current_head = master_parity_gate.read_git_head(PROJECT_ROOT)
    return master_parity_gate.assess_master_parity(_STARTUP_PARITY, current_head)


def _current_runtime_mode_report(refresh: bool = False) -> dict:
    """Describe the runtime this process is serving from (#615).

    Combines the observed checkout facts at ``PROJECT_ROOT`` with the workspace
    binding this session resolved, so the report answers both "what code is the
    control plane running" and "does it agree with the task workspace".

    The immutable facts -- process root, branch, head, checkout-ness -- come from
    the import-time snapshot, for the same reason the #420 parity gate compares
    against a startup baseline: the runtime a process serves from is fixed when
    it loads its code, and editing files afterwards does not change what is
    already in memory.

    Everything mutation-sensitive is recomputed on **every** call: the dirty-file
    list, the session's workspace binding, and the alignment derived from it.
    Caching those would mean a checkout that goes dirty after the first call is
    never caught again, and that one session's binding decides alignment for the
    whole process. ``refresh`` is retained for the read-only reporting path; it
    no longer selects between a cache and live state, so a read-only call can
    neither seed nor weaken a later mutation decision.
    """
    facts = _STARTUP_RUNTIME_FACTS
    dirty_files = stable_control_runtime.observe_dirty_files(PROJECT_ROOT)
    workspace_root = None
    aligned = None
    canonical_root = None
    try:
        ctx = _resolve_namespace_mutation_context(None)
        workspace_root = ctx.get("workspace_path")
        canonical_root = ctx.get("canonical_repo_root")
        # Alignment keeps its established repository-level meaning (#615 F1):
        # does this namespace target the repository the process is installed in,
        # i.e. canonical_repo_root == process_project_root. It is deliberately
        # NOT path equality between the task workspace and the process root --
        # the global worktree rule requires task work to live in a branches/
        # worktree, so that comparison would classify every correctly bound
        # author, reviewer, and merger session as unsafe.
        aligned = ctx.get("roots_aligned")
    except Exception:
        # An unresolvable binding is reported as unknown alignment, never as
        # proof of alignment.
        aligned = None
    try:
        profile_name = get_profile()["profile_name"]
    except Exception:
        profile_name = None
    return stable_control_runtime.build_runtime_report(
        process_root=PROJECT_ROOT,
        checkout_branch=facts["checkout_branch"],
        runtime_head=facts["runtime_head"],
        is_git_checkout=facts["is_git_checkout"],
        dirty_files=dirty_files,
        active_task_workspace=workspace_root,
        canonical_repository_root=canonical_root,
        workspace_roots_aligned=aligned,
        profile=profile_name,
        declared_mode=stable_control_runtime.declared_runtime_mode(),
    )


def _runtime_mode_block(op: str) -> list[str]:
    """Fail-closed runtime-mode reasons for a mutation *op* (#615).

    Real Gitea mutations may only run on the promoted stable control runtime.
    Read-only operations are never blocked: a dev/test or unknown runtime can
    still be inspected, which is exactly how an operator diagnoses it.

    Under unit-test isolation the gate is skipped unless the explicit #683
    force-on flag requests production behaviour: the suite legitimately runs
    from ``branches/`` worktrees, which classify as ``dev-test`` by design. That
    decision is taken from how the *process* was loaded, not from a per-call
    check, so a test that simulates production for some other guard does not
    silently switch this one on. The legacy dirty/porcelain force flags are
    deliberately not honoured here -- they request dirtiness paths, not a claim
    that the runtime is promoted.
    """
    if op == "gitea.read":
        return []
    if _RUNTIME_MODE_GATE_UNDER_TEST and not (
        workflow_scope_guard.production_guards_forced()
    ):
        return []
    try:
        report = _current_runtime_mode_report()
    except Exception as exc:
        return [
            "runtime mode could not be assessed (fail closed): "
            f"{_redact(str(exc))}"
        ]
    return stable_control_runtime.runtime_block_reasons(report)


def _master_parity_block(op: str) -> list[str]:
    """Fail-closed staleness reasons for a mutation *op* (#420).

    Read-only operations (``gitea.read``) are never blocked: a stale server can
    still be inspected. Any other (mutating) operation is refused when the
    on-disk master has definitively advanced past the running code, since newly
    merged capability gates would not yet be loaded in memory.
    """
    if op == "gitea.read":
        return []
    return master_parity_gate.parity_block_reasons(_current_master_parity())


def _profile_operation_gate(op: str) -> list[str]:
    """Profile permission check for a single gated operation (#126, #216, #420).

    Issue discussion comments are gated separately from the gitea.pr.*
    review/merge family: listing requires ``gitea.read``, creating requires
    ``gitea.issue.comment``. Closing a PR requires the distinct
    ``gitea.pr.close`` (#216). Returns a list of block reasons (empty =
    allowed); an unreadable profile fails closed.

    A mutating operation is additionally refused when the server code is stale
    relative to master (#420), so a long-running process cannot bypass a
    capability gate that has since been merged, and when the runtime itself is
    not the promoted stable control runtime (#615) -- a dev/test or unknown
    runtime holds production credentials but has not been promoted.
    """
    stale_reasons = _master_parity_block(op)
    if stale_reasons:
        return stale_reasons
    runtime_reasons = _runtime_mode_block(op)
    if runtime_reasons:
        return runtime_reasons
    try:
        profile = get_profile()
    except Exception as exc:
        return [f"profile could not be resolved (fail closed): {_redact(str(exc))}"]
    op_ok, op_reason = gitea_config.check_operation(
        op, profile["allowed_operations"], profile["forbidden_operations"])
    if op_ok:
        return []

    if _try_auto_switch_for_operation(op):
        try:
            profile = get_profile()
            op_ok, op_reason = gitea_config.check_operation(
                op, profile["allowed_operations"], profile["forbidden_operations"])
            if op_ok:
                return []
        except Exception as exc:
            return [f"profile could not be resolved (fail closed): {_redact(str(exc))}"]

    if op_reason == "no-allowed-operations":
        return ["profile has no configured allowed operations (fail closed)"]
    if op_reason == "forbidden":
        return [f"profile forbids '{op}'"]
    if op_reason == "invalid-forbidden-entry":
        return ["profile has an unrecognized forbidden operation entry (fail closed)"]
    return [f"profile is not allowed to {op}"]


def _mutation_config_authority_block(required_operation: str) -> dict | None:
    """#714: mutations require config-backed profile + non-empty repository scope.

    Environment-only profiles may support read-only diagnostics but cannot
    authorize writes. Env vars may narrow operations on a configured profile
    but cannot invent mutation authority or repository scope.
    """
    if required_operation == "gitea.read":
        return None
    try:
        profile = get_profile()
    except Exception as exc:
        return {
            "success": False,
            "performed": False,
            "reasons": [
                f"profile could not be resolved (fail closed): {_redact(str(exc))}"
            ],
            "blocker_kind": "mutation_authority",
        }
    config = None
    try:
        config = gitea_config.load_config()
    except Exception as exc:
        return {
            "success": False,
            "performed": False,
            "reasons": [
                f"profiles configuration unreadable (fail closed): {_redact(str(exc))}"
            ],
            "blocker_kind": "mutation_authority",
        }
    if not config:
        return {
            "success": False,
            "performed": False,
            "reasons": [
                "mutation denied: env-only / unconfigured profiles cannot "
                "authorize mutations; provision GITEA_MCP_CONFIG with a v2 "
                "profile that declares non-empty allowed_repositories "
                "(fail closed)"
            ],
            "blocker_kind": "mutation_authority",
            "exact_next_action": (
                "BLOCKED + DIAGNOSE: configure a trusted v2 profile with "
                "allowed_repositories and relaunch; do not widen scope via env."
            ),
        }
    try:
        allowed = session_ctx.declared_allowed_repositories(profile, strict=True)
    except ValueError as exc:
        return {
            "success": False,
            "performed": False,
            "reasons": [str(exc)],
            "blocker_kind": "mutation_authority",
        }
    if not allowed:
        return {
            "success": False,
            "performed": False,
            "reasons": [
                f"mutation denied: profile '{profile.get('profile_name')}' "
                "must declare a non-empty allowed_repositories list "
                "(fail closed)"
            ],
            "blocker_kind": "mutation_authority",
            "exact_next_action": (
                "BLOCKED + DIAGNOSE: add allowed_repositories: "
                "[\"Owner/Repo\"] to the active v2 profile and restart."
            ),
        }
    return None


def _session_context_mutation_block(
    *,
    remote: str | None,
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    username: str | None = None,
    **extra_fields,
) -> dict | None:
    """#714: fail closed on session context / identity / cross-host drift."""
    try:
        profile = get_profile()
    except Exception as exc:
        return {
            "success": False,
            "performed": False,
            "reasons": [
                f"profile could not be resolved (fail closed): {_redact(str(exc))}"
            ],
            "blocker_kind": "session_context",
            **extra_fields,
        }
    profile_name = profile.get("profile_name")
    expected = (profile.get("username") or "").strip() or None
    # Infer remote from profile host when tools omit it (legacy call sites).
    if not remote:
        p_host = session_ctx.profile_host(profile)
        if p_host:
            for rname, rcfg in REMOTES.items():
                if (rcfg.get("host") or "").lower() == p_host:
                    remote = rname
                    break
    remote_config = REMOTES.get(remote or "", {}) if remote else {}
    h = host or remote_config.get("host") or session_ctx.profile_host(profile)
    # #714: repository identity comes from the verified workspace only. The
    # caller-supplied org/repo are a *request target* to be checked against the
    # binding — they must never establish, complete, or replace it.
    trusted = _trusted_session_repository(profile, remote, for_mutation=True)
    resolved_org = trusted["org"]
    resolved_repo = trusted["repository"]
    identity = username
    if identity is None and expected and h:
        try:
            identity = _authenticated_username(h)
        except Exception:
            identity = None

    config = None
    try:
        config = gitea_config.load_config()
    except Exception:
        config = None
    contexts = (config or {}).get("contexts") if config else None
    remote_gate = session_ctx.profile_allowed_for_remote(
        profile, remote, REMOTES, contexts=contexts
    )
    reasons: list[str] = list(remote_gate.get("reasons") or [])

    id_gate = session_ctx.assess_identity_match(
        authenticated=identity, expected_username=expected
    )
    reasons.extend(id_gate.get("reasons") or [])
    reasons.extend(trusted.get("reasons") or [])

    if trusted.get("repository") and trusted.get("org"):
        _seed_session_context(
            profile=profile,
            remote=remote,
            host=h,
            identity=identity,
            source="mutation-gate-seed",
        )
    ctx_gate = session_ctx.assess_session_context(
        profile_name=profile_name,
        remote=remote,
        host=h,
        identity=identity,
        repository=resolved_repo,
        org=resolved_org,
        expected_username=expected,
        require_bound=True,
        require_complete=True,
    )
    reasons.extend(ctx_gate.get("reasons") or [])

    # Provenance: only explicit caller org/repo may override the binding.
    # Tools must pass org_explicit/repo_explicit (True when the caller supplied
    # the parameter). When flags are absent (legacy), strip pure REMOTES
    # defaults that disagree with the binding so omitted targets use it.
    bound_ctx = session_ctx.get_session_context() or {}
    bound_org = bound_ctx.get("org") or resolved_org
    bound_repo = bound_ctx.get("repository") or resolved_repo
    org_explicit = extra_fields.get("org_explicit")
    repo_explicit = extra_fields.get("repo_explicit")
    req_org = (org or "").strip() or None
    req_repo = (repo or "").strip() or None
    remotes_entry = REMOTES.get(remote or "", {}) if remote else {}
    default_org = (remotes_entry.get("org") or "").strip() or None
    default_repo = (remotes_entry.get("repo") or "").strip() or None
    if org_explicit is False:
        req_org = None
    elif org_explicit is None and req_org and default_org and req_org.lower() == default_org.lower() and bound_org and req_org.lower() != bound_org.lower():
        req_org = None
    if repo_explicit is False:
        req_repo = None
    elif repo_explicit is None and req_repo and default_repo and req_repo.lower() == default_repo.lower() and bound_repo and req_repo.lower() != bound_repo.lower():
        req_repo = None
    override_gate = session_ctx.assess_repository_override(
        requested_org=req_org,
        requested_repo=req_repo,
        bound_org=bound_org,
        bound_repo=bound_repo,
    )
    reasons.extend(override_gate.get("reasons") or [])
    # De-dupe while preserving order
    seen: set[str] = set()
    uniq: list[str] = []
    for r in reasons:
        if r not in seen:
            seen.add(r)
            uniq.append(r)
    if not uniq:
        return None
    blocked = {
        "success": False,
        "performed": False,
        "reasons": uniq,
        "blocker_kind": "session_context",
        "session_context_audit": session_ctx.mutation_context_audit_fields(),
        "exact_next_action": (
            "BLOCKED + DIAGNOSE: session profile/remote/host/identity context "
            "is inconsistent or unauthorized for this mutation. Re-pin the "
            "correct profile with gitea_activate_profile, re-verify with "
            "gitea_whoami for the intended remote, and do not use cross-host "
            "fallback profiles."
        ),
    }
    blocked.update(extra_fields)
    return blocked


def _profile_permission_block(required_operation: str, **extra_fields) -> dict | None:
    """Structured permission denial for gated tools (#69, #142).

    Returns a block dict when the active profile forbids *required_operation*,
    or ``None`` when the gate passes. Never performs network I/O.
    """
    req_role = "reviewer" if any(required_operation.startswith(p) for p in (
        "gitea.pr.approve", "gitea.pr.merge", "gitea.pr.request_changes", "gitea.pr.review"
    )) else "author"
    # #714: evaluate active profile only — never auto-switch.
    _ensure_matching_profile(required_operation, req_role, extra_fields.get("remote"))

    reasons = _profile_operation_gate(required_operation)
    if reasons:
        blocked = {
            "success": False,
            "performed": False,
            "reasons": reasons,
            "permission_report": _permission_block_report(required_operation),
            "session_context_audit": session_ctx.mutation_context_audit_fields(),
        }
        blocked.update(extra_fields)
        return blocked

    auth_block = _mutation_config_authority_block(required_operation)
    if auth_block is not None:
        for k in ("number", "issue_number", "pr_number", "remote", "host", "org", "repo"):
            if k in extra_fields:
                auth_block[k] = extra_fields[k]
        return auth_block

    return _session_context_mutation_block(
        remote=extra_fields.get("remote"),
        host=extra_fields.get("host"),
        org=extra_fields.get("org"),
        repo=extra_fields.get("repo"),
        number=extra_fields.get("number"),
        issue_number=extra_fields.get("issue_number"),
        pr_number=extra_fields.get("pr_number"),
        org_explicit=extra_fields.get("org_explicit"),
        repo_explicit=extra_fields.get("repo_explicit"),
    )


def _namespace_mutation_block(mutation_task: str, **extra_fields) -> dict | None:
    """Reviewer/author namespace alignment gate (#209)."""
    required_permission = task_capability_map.required_permission(mutation_task)
    required_role = task_capability_map.required_role(mutation_task)
    # #714: evaluate active profile only — never auto-switch.
    _ensure_matching_profile(required_permission, required_role, extra_fields.get("remote"))

    try:
        profile = get_profile()
    except Exception as exc:
        return {
            "success": False,
            "performed": False,
            "reasons": [
                f"profile could not be resolved (fail closed): {_redact(str(exc))}"],
            **extra_fields,
        }
    ok, reasons = role_namespace_gate.check_author_mutation_namespace(
        mutation_task, profile)
    if ok:
        return None
    blocked = {
        "success": False,
        "performed": False,
        "reasons": reasons,
        "namespace_block": True,
        "mcp_namespace": role_namespace_gate.infer_mcp_namespace(
            profile.get("profile_name")),
        **extra_fields,
    }
    _audit(
        mutation_task,
        host=None,
        remote=extra_fields.get("remote"),
        result=gitea_audit.BLOCKED,
        repo=extra_fields.get("repository"),
        reason="; ".join(reasons),
        mutation_task=mutation_task,
    )
    return blocked


def _fetch_pr_comments(
    pr_number: int,
    *,
    remote: str,
    host: str | None,
    org: str | None,
    repo: str | None,
) -> list[dict]:
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
    return api_request("GET", api, auth) or []


def _reviewer_pr_lease_gate(
    *,
    pr_number: int,
    remote: str,
    host: str | None,
    org: str | None,
    repo: str | None,
    mutation: str,
    live_head_sha: str | None,
    pinned_head_sha: str | None,
) -> list[str]:
    """Return block reasons when the session lacks an owned PR reviewer lease."""
    session = reviewer_pr_lease.get_session_lease()
    session_id = (session or {}).get("session_id")
    # Resolve host first: _authenticated_username expects a host, not remote name.
    h, _, _ = _resolve(remote, host, org, repo)
    identity = _authenticated_username(h) or ""
    try:
        comments = _fetch_pr_comments(
            pr_number, remote=remote, host=host, org=org, repo=repo)
    except Exception as exc:
        return [f"cannot fetch PR comments for lease gate: {_redact(str(exc))}"]
    assessment = reviewer_pr_lease.assess_mutation_lease_gate(
        pr_number=pr_number,
        comments=comments,
        reviewer_identity=identity,
        session_id=session_id,
        mutation=mutation,
        live_head_sha=live_head_sha,
        pinned_head_sha=pinned_head_sha,
    )
    return list(assessment.get("reasons") or []) if assessment.get("block") else []


@mcp.tool()
def gitea_acquire_reviewer_pr_lease(
    pr_number: int,
    worktree: str,
    candidate_head: str | None = None,
    target_branch: str = "master",
    target_branch_sha: str | None = None,
    issue_number: int | None = None,
    session_id: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Acquire a per-PR reviewer lease before review/merge mutations (#407)."""
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "acquired": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    comment_block = _profile_operation_gate("gitea.pr.comment")
    if comment_block:
        return {
            "success": False,
            "acquired": False,
            "reasons": comment_block,
            "permission_report": _permission_block_report("gitea.pr.comment"),
        }

    # task=acquire_reviewer_pr_lease so verify_preflight_purity runs shared #604
    # anti-stomp for the declared lease-acquire mutation inventory entry.
    # Forward explicit org/repo so anti-stomp does not default bare remote=prgs
    # to Timesheet when the local git remote is Gitea-Tools (#604 wrong_repo).
    _verify_role_mutation_workspace(
        remote,
        worktree=worktree,
        task="acquire_reviewer_pr_lease",
        org=org,
        repo=repo,
    )
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    profile = get_profile()
    # Host (not remote name) — remote aliases like "prgs" do not resolve identity.
    identity = _authenticated_username(h) or profile.get("username") or ""
    sid = (session_id or "").strip() or reviewer_pr_lease.new_session_id()
    repo_label = f"{o}/{r}"

    comments = _fetch_pr_comments(
        pr_number, remote=remote, host=host, org=org, repo=repo)
    # Refuse merge-oriented lease acquisition/adoption on an already-merged or
    # closed PR: the lease is moot and adopting it for merge work is unsafe (#515).
    pr_live = api_request(
        "GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {}
    pr_merged_or_closed = bool(
        pr_live.get("merged") or pr_live.get("merged_at")
    ) or (str(pr_live.get("state") or "").strip().lower() == "closed")
    assessment = reviewer_pr_lease.assess_acquire_lease(
        comments,
        pr_number=pr_number,
        reviewer_identity=identity,
        profile=profile.get("profile_name") or "unknown",
        session_id=sid,
        repo=repo_label,
        issue_number=issue_number,
        worktree=worktree,
        candidate_head=candidate_head,
        target_branch=target_branch,
        target_branch_sha=target_branch_sha,
        pr_merged_or_closed=pr_merged_or_closed,
    )
    if not assessment.get("acquire_allowed"):
        return {
            "success": False,
            "acquired": False,
            "reasons": assessment.get("reasons") or [],
            "existing_lease": assessment.get("existing_lease"),
            "post_merge_moot": assessment.get("post_merge_moot", False),
        }

    body = assessment["lease_body"]
    comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
    with _audited(
        "comment_pr",
        host=h,
        remote=remote,
        org=o,
        repo=r,
        pr_number=pr_number,
        request_metadata={"source": "acquire_reviewer_pr_lease"},
    ):
        posted = api_request("POST", comment_url, auth, {"body": body})

    session_lease = reviewer_pr_lease.record_session_lease({
        "pr_number": pr_number,
        "issue_number": issue_number,
        "session_id": sid,
        "reviewer_identity": identity,
        "profile": profile.get("profile_name"),
        "worktree": worktree,
        "phase": "claimed",
        "candidate_head": candidate_head,
        "target_branch": target_branch,
        "target_branch_sha": target_branch_sha,
        "repo": repo_label,
        "comment_id": posted.get("id"),
    }, lease_provenance=merger_lease_adoption.build_lease_provenance(
        source=merger_lease_adoption.SOURCE_ACQUIRE,
        comment_id=posted.get("id"),
    ))
    return {
        "success": True,
        "acquired": True,
        "pr_number": pr_number,
        "session_id": sid,
        "comment_id": posted.get("id"),
        "session_lease": session_lease,
        "reasons": [],
    }


@mcp.tool()
def gitea_acquire_merger_pr_lease(
    pr_number: int,
    worktree: str,
    candidate_head: str | None = None,
    target_branch: str = "master",
    target_branch_sha: str | None = None,
    issue_number: int | None = None,
    session_id: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Acquire a per-PR lease for a merger session when no reviewer lease exists (#718).

    Merger sessions should normally adopt an active reviewer lease using
    gitea_adopt_merger_pr_lease. However, if no active reviewer lease exists
    or it has expired, a merger can acquire one directly using this tool.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "acquired": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    comment_block = _profile_operation_gate("gitea.pr.comment")
    if comment_block:
        return {
            "success": False,
            "acquired": False,
            "reasons": comment_block,
            "permission_report": _permission_block_report("gitea.pr.comment"),
        }
    merge_block = _profile_operation_gate("gitea.pr.merge")
    if merge_block:
        return {
            "success": False,
            "acquired": False,
            "reasons": merge_block,
            "permission_report": _permission_block_report("gitea.pr.merge"),
        }

    head_pin = (candidate_head or "").strip()
    if not head_pin:
        return {
            "success": False,
            "acquired": False,
            "reasons": [
                "candidate_head is required for merger lease acquisition "
                "(exact-head scoping; fail closed)"
            ],
        }

    try:
        _verify_role_mutation_workspace(
            remote,
            worktree=worktree,
            task="acquire_merger_pr_lease",
            org=org,
            repo=repo,
        )
    except RuntimeError as e:
        return {
            "success": False,
            "acquired": False,
            "reasons": [str(e)],
        }

    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    profile = get_profile()
    identity = _authenticated_username(h) or profile.get("username") or ""
    sid = (session_id or "").strip() or reviewer_pr_lease.new_session_id()
    repo_label = f"{o}/{r}"

    comments = _fetch_pr_comments(
        pr_number, remote=remote, host=host, org=org, repo=repo)

    pr_live = api_request(
        "GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {}
    live_head = (
        (pr_live.get("head") or {}).get("sha")
        or pr_live.get("head_sha")
        or ""
    )
    live_head = str(live_head).strip()
    # Fail closed when live PR head cannot be resolved — never proceed with
    # an unpinned/unknown head even if candidate_head was supplied (#718).
    if not live_head:
        return {
            "success": False,
            "acquired": False,
            "reasons": [
                "live PR head could not be resolved from GET /pulls response "
                "(exact-head scoping; fail closed)"
            ],
            "live_head": None,
            "candidate_head": head_pin,
        }
    if live_head != head_pin:
        return {
            "success": False,
            "acquired": False,
            "reasons": [
                f"candidate_head {head_pin[:12]}… does not match live PR head "
                f"{live_head[:12]}… (exact-head scoping; fail closed)"
            ],
            "live_head": live_head,
            "candidate_head": head_pin,
        }

    pr_merged_or_closed = bool(
        pr_live.get("merged") or pr_live.get("merged_at")
    ) or (str(pr_live.get("state") or "").strip().lower() == "closed")

    assessment = reviewer_pr_lease.assess_acquire_lease(
        comments,
        pr_number=pr_number,
        reviewer_identity=identity,
        profile=profile.get("profile_name") or "unknown",
        session_id=sid,
        repo=repo_label,
        issue_number=issue_number,
        worktree=worktree,
        candidate_head=head_pin,
        target_branch=target_branch,
        target_branch_sha=target_branch_sha,
        pr_merged_or_closed=pr_merged_or_closed,
    )
    if not assessment.get("acquire_allowed"):
        return {
            "success": False,
            "acquired": False,
            "reasons": assessment.get("reasons") or [],
            "existing_lease": assessment.get("existing_lease"),
            "post_merge_moot": assessment.get("post_merge_moot", False),
        }

    body = assessment["lease_body"]
    comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
    with _audited(
        "comment_pr",
        host=h,
        remote=remote,
        org=o,
        repo=r,
        pr_number=pr_number,
        request_metadata={"source": "acquire_merger_pr_lease"},
    ):
        posted = api_request("POST", comment_url, auth, {"body": body})

    if not isinstance(posted, dict) or not posted.get("id"):
        return {
            "success": False,
            "acquired": False,
            "reasons": [
                "lease comment POST failed or returned no comment id "
                "(no partial session lease recorded)"
            ],
        }

    session_lease = reviewer_pr_lease.record_session_lease({
        "pr_number": pr_number,
        "issue_number": issue_number,
        "session_id": sid,
        "reviewer_identity": identity,
        "profile": profile.get("profile_name"),
        "worktree": worktree,
        "phase": "claimed",
        "candidate_head": head_pin,
        "target_branch": target_branch,
        "target_branch_sha": target_branch_sha,
        "repo": repo_label,
        "comment_id": posted.get("id"),
    }, lease_provenance=merger_lease_adoption.build_lease_provenance(
        source=merger_lease_adoption.SOURCE_ACQUIRE_MERGER,
        comment_id=posted.get("id"),
        # #742: bind the lease to this native runtime so a later owner-session
        # finalization can prove it is the same daemon that acquired it.
        native_token_fingerprint=(
            mcp_daemon_guard.native_runtime_status().get("token_fingerprint")
        ),
    ))
    return {
        "success": True,
        "acquired": True,
        "pr_number": pr_number,
        "session_id": sid,
        "comment_id": posted.get("id"),
        "session_lease": session_lease,
        "candidate_head": head_pin,
        "repo": repo_label,
        "reasons": [],
    }


@mcp.tool()
def gitea_adopt_merger_pr_lease(
    pr_number: int,
    worktree: str,
    expected_head_sha: str,
    issue_number: int | None = None,
    session_id: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Adopt an active reviewer PR lease for a merger session (#536).

    Merger sessions must not manually seed ``reviewer_pr_lease._SESSION_LEASE``.
    This tool posts durable adoption proof on the PR thread and records
    sanctioned in-session lease provenance so ``gitea_merge_pr`` can proceed
    after a separate reviewer session held the lease.

    Args:
        pr_number: Open PR to adopt.
        worktree: Merger workspace under ``branches/``.
        expected_head_sha: Approved head the merger will pin during merge.
        issue_number: Optional linked issue for the adoption record.
        session_id: Optional merger session id (generated when omitted).
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.

    Returns:
        dict with adoption proof, session lease, and block reasons when the
        guarded preconditions are not met.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "adopted": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    comment_block = _profile_operation_gate("gitea.pr.comment")
    if comment_block:
        return {
            "success": False,
            "adopted": False,
            "reasons": comment_block,
            "permission_report": _permission_block_report("gitea.pr.comment"),
        }
    merge_block = _profile_operation_gate("gitea.pr.merge")
    if merge_block:
        return {
            "success": False,
            "adopted": False,
            "reasons": merge_block,
            "permission_report": _permission_block_report("gitea.pr.merge"),
        }

    # task=adopt_merger_pr_lease so verify_preflight_purity runs shared #604
    # anti-stomp.  A role/workspace rejection is an expected guarded outcome,
    # not an opaque MCP internal_error (#723).
    try:
        _verify_role_mutation_workspace(
            remote,
            worktree=worktree,
            task="adopt_merger_pr_lease",
            org=org,
            repo=repo,
        )
    except RuntimeError as exc:
        return {
            "success": False,
            "adopted": False,
            "pr_number": pr_number,
            "blocker_kind": "workspace_role_binding",
            "reasons": [
                "workspace/role binding failed (fail closed, #723): "
                f"{_redact(str(exc))}"
            ],
            "active_lease": None,
            "expected_head_sha": expected_head_sha,
            "live_head_sha": None,
        }
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    profile = get_profile()
    profile_name = profile.get("profile_name") or "unknown"
    # Host (not remote name) — remote aliases like "prgs" do not resolve identity.
    identity = _authenticated_username(h) or profile.get("username") or ""
    sid = (session_id or "").strip() or reviewer_pr_lease.new_session_id()
    repo_label = f"{o}/{r}"

    pr_live = api_request(
        "GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {}
    pr_state = (pr_live.get("state") or "").strip().lower()
    pr_open = pr_state == "open" and not pr_live.get("merged")
    live_head = (pr_live.get("head", {}) or {}).get("sha") or pr_live.get(
        "head_sha"
    )

    feedback = gitea_get_pr_review_feedback(
        pr_number=pr_number,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
    )
    approval_at_head = bool(feedback.get("approval_at_current_head"))

    comments = _fetch_pr_comments(
        pr_number, remote=remote, host=host, org=org, repo=repo)
    assessment = merger_lease_adoption.assess_adopt_merger_lease(
        comments,
        pr_number=pr_number,
        adopter_identity=identity,
        adopter_profile=profile_name,
        adopter_session_id=sid,
        repo=repo_label,
        issue_number=issue_number,
        worktree=worktree,
        expected_head_sha=expected_head_sha,
        live_head_sha=live_head,
        approval_at_head=approval_at_head,
        pr_open=pr_open,
    )
    if not assessment.get("adopt_allowed"):
        return {
            "success": False,
            "adopted": False,
            "pr_number": pr_number,
            "reasons": assessment.get("reasons") or [],
            "active_lease": assessment.get("active_lease"),
            "expected_head_sha": expected_head_sha,
            "live_head_sha": live_head,
        }

    active = assessment.get("active_lease") or {}
    body = assessment["adoption_body"]
    comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
    with _audited(
        "comment_pr",
        host=h,
        remote=remote,
        org=o,
        repo=r,
        pr_number=pr_number,
        request_metadata={"source": "adopt_merger_pr_lease"},
    ):
        posted = api_request("POST", comment_url, auth, {"body": body})

    provenance = merger_lease_adoption.build_lease_provenance(
        source=merger_lease_adoption.SOURCE_ADOPT,
        comment_id=posted.get("id"),
        adopted_from_session_id=active.get("session_id"),
        adopted_from_profile=active.get("profile"),
        adopted_from_reviewer_identity=active.get("reviewer_identity"),
        adoption_reason=merger_lease_adoption.DEFAULT_ADOPTION_REASON,
    )
    session_lease = reviewer_pr_lease.record_session_lease({
        "pr_number": pr_number,
        "issue_number": issue_number or active.get("issue_number"),
        "session_id": sid,
        "reviewer_identity": identity,
        "profile": profile_name,
        "worktree": worktree,
        "phase": "adopted",
        "candidate_head": live_head,
        "target_branch": active.get("target_branch") or "master",
        "target_branch_sha": active.get("target_branch_sha"),
        "repo": repo_label,
        "comment_id": posted.get("id"),
    }, lease_provenance=provenance)

    return {
        "success": True,
        "adopted": True,
        "pr_number": pr_number,
        "session_id": sid,
        "adoption_comment_id": posted.get("id"),
        "adopted_from_session_id": active.get("session_id"),
        "adopted_from_profile": active.get("profile"),
        "adopted_from_reviewer_identity": active.get("reviewer_identity"),
        "adoption_reason": merger_lease_adoption.DEFAULT_ADOPTION_REASON,
        "expected_head_sha": expected_head_sha,
        "live_head_sha": live_head,
        "session_lease": session_lease,
        "lease_provenance": provenance,
        "reasons": [],
    }


@mcp.tool()
def gitea_release_merger_pr_lease(
    pr_number: int,
    worktree: str,
    candidate_head: str,
    outcome: str = merger_lease_adoption.OUTCOME_RELEASED,
    reason: str | None = None,
    issue_number: int | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Terminally release or abandon this merger session's own PR lease (#742).

    Sanctioned owner-session finalization for a comment-backed merger lease when
    the merge does **not** occur (non-retryable merge failure, aborted merge, or
    a blocker that ends the attempt). Without it a failed merger lease can only
    expire, blocking the queue for the remainder of its TTL.

    Merger-only and owner-only. The caller must hold the exact in-session lease
    it is finalizing; foreign-session release, reviewer profiles, and mismatched
    repository / PR / candidate head / identity / profile / native runtime token
    fingerprint all fail closed. Reviewer sessions use
    ``gitea_release_reviewer_pr_lease`` instead — that tool is never a merger
    path.

    Finalization is append-only: it posts a terminal lease marker and never
    edits or deletes prior ledger comments. It is idempotent — an already
    terminal lease returns ``already_terminal`` and posts nothing. The PR, its
    approval, the review decision lock, the branch, and the worktree are all
    preserved; only the lease is ended.

    Args:
        pr_number: PR whose merger lease to finalize.
        outcome: 'released' (clean end) or 'abandoned' (ended under a blocker).
        reason: Short finalization reason recorded in the marker.
        candidate_head: Pinned head SHA the lease was acquired against.

    Returns:
        dict reporting finalization status, reasons, and the marker comment id.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "finalized": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    comment_block = _profile_operation_gate("gitea.pr.comment")
    if comment_block:
        return {
            "success": False,
            "finalized": False,
            "reasons": comment_block,
            "permission_report": _permission_block_report("gitea.pr.comment"),
        }
    # Merger-role proof: reviewer profiles hold gitea.pr.comment but never
    # gitea.pr.merge, so this gate keeps the tool merger-only (#742).
    merge_block = _profile_operation_gate("gitea.pr.merge")
    if merge_block:
        return {
            "success": False,
            "finalized": False,
            "reasons": merge_block,
            "permission_report": _permission_block_report("gitea.pr.merge"),
        }

    try:
        _verify_role_mutation_workspace(
            remote,
            worktree=worktree,
            task="release_merger_pr_lease",
            org=org,
            repo=repo,
        )
    except RuntimeError as e:
        return {
            "success": False,
            "finalized": False,
            "reasons": [str(e)],
        }

    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    profile = get_profile()
    profile_name = profile.get("profile_name") or "unknown"
    identity = _authenticated_username(h) or profile.get("username") or ""
    repo_label = f"{o}/{r}"

    session = reviewer_pr_lease.get_session_lease()
    comments = _fetch_pr_comments(
        pr_number, remote=remote, host=host, org=org, repo=repo)

    pr_live = api_request(
        "GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {}
    live_head = str(
        (pr_live.get("head") or {}).get("sha") or pr_live.get("head_sha") or ""
    ).strip()

    assessment = merger_lease_adoption.assess_merger_lease_finalization(
        comments,
        pr_number=pr_number,
        session=session,
        actor_identity=identity,
        actor_profile=profile_name,
        actor_session_id=(session or {}).get("session_id"),
        repo=repo_label,
        worktree=worktree,
        candidate_head=candidate_head,
        live_head_sha=live_head or None,
        outcome=outcome,
        reason=reason,
        runtime_token_fingerprint=(
            mcp_daemon_guard.native_runtime_status().get("token_fingerprint")
        ),
        issue_number=issue_number,
    )

    if assessment.get("already_terminal"):
        # Idempotent: the lease is already terminal on the ledger. Clear the
        # in-session mirror so no stale proof survives, and post nothing.
        reviewer_pr_lease.clear_session_lease()
        return {
            "success": True,
            "finalized": False,
            "already_terminal": True,
            "pr_number": pr_number,
            "outcome": assessment.get("outcome"),
            "reasons": [],
        }

    if not assessment.get("finalize_allowed"):
        return {
            "success": False,
            "finalized": False,
            "already_terminal": False,
            "pr_number": pr_number,
            "reasons": assessment.get("reasons") or [],
        }

    comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
    with _audited(
        "comment_pr",
        host=h,
        remote=remote,
        org=o,
        repo=r,
        pr_number=pr_number,
        request_metadata={"source": "release_merger_pr_lease"},
    ):
        posted = api_request(
            "POST", comment_url, auth, {"body": assessment["finalization_body"]}
        )

    if not isinstance(posted, dict) or not posted.get("id"):
        # The lease is still live on the ledger: keep the in-session mirror so
        # the owner can retry finalization rather than losing its own proof.
        return {
            "success": False,
            "finalized": False,
            "reasons": [
                "terminal lease marker POST failed or returned no comment id "
                "(lease left intact; retry finalization)"
            ],
        }

    reviewer_pr_lease.clear_session_lease()

    return {
        "success": True,
        "finalized": True,
        "already_terminal": False,
        "pr_number": pr_number,
        "outcome": assessment.get("outcome"),
        "finalization_reason": assessment.get("finalization_reason"),
        "finalization_comment_id": posted.get("id"),
        "finalized_lease_comment_id": assessment.get("lease_comment_id"),
        "candidate_head": assessment.get("candidate_head"),
        "repo": repo_label,
        "reasons": [],
    }


@mcp.tool()
def gitea_heartbeat_reviewer_pr_lease(
    pr_number: int,
    phase: str,
    worktree: str | None = None,
    candidate_head: str | None = None,
    target_branch_sha: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Post a reviewer lease heartbeat / phase update on the PR thread (#407)."""
    comment_block = _profile_operation_gate("gitea.pr.comment")
    if comment_block:
        return {
            "success": False,
            "posted": False,
            "reasons": comment_block,
            "permission_report": _permission_block_report("gitea.pr.comment"),
        }
    session = reviewer_pr_lease.get_session_lease()
    if not session or session.get("pr_number") != pr_number:
        return {
            "success": False,
            "posted": False,
            "reasons": [
                f"no in-session lease for PR #{pr_number}; acquire first "
                "(fail closed)"
            ],
        }

    verify_preflight_purity(remote, task="review_pr")
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    # Slide the sliding TTL forward (#747): the heartbeat is the liveness proof,
    # so renewal is stated explicitly rather than inherited from the acquisition
    # default.
    beat_at = datetime.now(timezone.utc)
    renewed_expiry = beat_at + timedelta(
        minutes=reviewer_pr_lease.LEASE_RENEWAL_MINUTES
    )
    body = reviewer_pr_lease.format_lease_body(
        repo=f"{o}/{r}",
        pr_number=pr_number,
        issue_number=session.get("issue_number"),
        reviewer_identity=session.get("reviewer_identity") or "",
        profile=session.get("profile") or "unknown",
        session_id=session.get("session_id") or reviewer_pr_lease.new_session_id(),
        worktree=worktree or session.get("worktree") or "",
        phase=phase,
        candidate_head=candidate_head or session.get("candidate_head"),
        target_branch=session.get("target_branch") or "master",
        target_branch_sha=target_branch_sha or session.get("target_branch_sha"),
        last_activity=beat_at,
        ttl_minutes=reviewer_pr_lease.LEASE_RENEWAL_MINUTES,
    )
    comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
    with _audited(
        "comment_pr",
        host=h,
        remote=remote,
        org=o,
        repo=r,
        pr_number=pr_number,
        request_metadata={"source": "heartbeat_reviewer_pr_lease", "phase": phase},
    ):
        posted = api_request("POST", comment_url, auth, {"body": body})

    prior_provenance = (session.get("lease_provenance") or {}).copy()
    heartbeat_provenance = merger_lease_adoption.build_lease_provenance(
        source=merger_lease_adoption.SOURCE_HEARTBEAT,
        comment_id=posted.get("id"),
        adopted_from_session_id=prior_provenance.get("adopted_from_session_id"),
        adopted_from_profile=prior_provenance.get("adopted_from_profile"),
        adopted_from_reviewer_identity=prior_provenance.get(
            "adopted_from_reviewer_identity"
        ),
        adoption_reason=prior_provenance.get("adoption_reason"),
    )
    updated = reviewer_pr_lease.record_session_lease({
        **session,
        "phase": phase,
        "worktree": worktree or session.get("worktree"),
        "candidate_head": candidate_head or session.get("candidate_head"),
        "target_branch_sha": target_branch_sha or session.get("target_branch_sha"),
        "last_comment_id": posted.get("id"),
        "comment_id": posted.get("id"),
    }, lease_provenance=heartbeat_provenance)
    return {
        "success": True,
        "posted": True,
        "pr_number": pr_number,
        "phase": phase,
        "comment_id": posted.get("id"),
        "session_lease": updated,
        # Report the renewed window so an operator can tell "held and live"
        # from "held and dying" (#747).
        "ttl_minutes": reviewer_pr_lease.LEASE_RENEWAL_MINUTES,
        "expires_at": renewed_expiry.replace(microsecond=0)
        .isoformat()
        .replace("+00:00", "Z"),
        "seconds_remaining": reviewer_pr_lease.LEASE_RENEWAL_MINUTES * 60,
        "reasons": [],
    }


@mcp.tool()
def gitea_assess_reviewer_pr_lease(
    pr_number: int,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Read-only: assess active reviewer lease state for a PR (#407)."""
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    comments = _fetch_pr_comments(
        pr_number, remote=remote, host=host, org=org, repo=repo)
    active = reviewer_pr_lease.find_active_reviewer_lease(
        comments, pr_number=pr_number)
    return {
        "success": True,
        "pr_number": pr_number,
        "active_lease": active,
        "session_lease": reviewer_pr_lease.get_session_lease(),
        "reasons": [],
    }


@mcp.tool()
def gitea_diagnose_reviewer_pr_lease_handoff(
    pr_number: int,
    proposed_worktree: str | None = None,
    instructed_session_id: str | None = None,
    instructed_comment_id: int | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Read-only: diagnose open-PR reviewer lease handoff and emit next action (#599, #691).

    Classifies no-lease / own / foreign / superseded-head / expired / orphan /
    instructed-lease-missing-with-replacement and worktree binding mismatch.
    Returns a canonical ``next_action`` (including guarded obsolete-lease cleanup)
    without mutating Gitea state. Never steals foreign leases.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    comments = _fetch_pr_comments(
        pr_number, remote=remote, host=host, org=org, repo=repo)
    h, o, r = _resolve(remote, host, org, repo)
    try:
        username = _authenticated_username(h)
    except Exception:
        username = None
    session_lease = reviewer_pr_lease.get_session_lease() or {}
    env_wt = (
        (os.environ.get("GITEA_REVIEWER_WORKTREE") or "").strip()
        or (os.environ.get("GITEA_ACTIVE_WORKTREE") or "").strip()
        or None
    )
    # Prefer in-session lease id when present; otherwise diagnose as a fresh
    # caller without inventing ownership of an on-thread lease.
    session_id = (session_lease.get("session_id") or "").strip() or None

    # Live PR head + formal reviews for #691 superseded/completed classification.
    current_head_sha = None
    formal_reviews: list[dict] = []
    try:
        auth = _auth(h)
        pr_live = api_request(
            "GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth
        ) or {}
        head = pr_live.get("head") or {}
        current_head_sha = head.get("sha") or pr_live.get("head_commit_sha")
        try:
            reviews = api_request(
                "GET",
                f"{repo_api_url(h, o, r)}/pulls/{pr_number}/reviews",
                auth,
            ) or []
            for rev in reviews if isinstance(reviews, list) else []:
                formal_reviews.append({
                    "verdict": rev.get("state") or rev.get("verdict"),
                    "reviewed_head_sha": rev.get("commit_id") or rev.get(
                        "reviewed_head_sha"
                    ),
                    "dismissed": bool(rev.get("dismissed")),
                    "reviewer": ((rev.get("user") or {}).get("login")),
                    "submitted_at": rev.get("submitted_at"),
                })
        except Exception:
            formal_reviews = []
    except Exception:
        current_head_sha = None

    lease_wt = None
    active = reviewer_pr_lease.find_newest_nonterminal_lease(
        comments, pr_number=pr_number, include_expired=True
    )
    if active:
        lease_wt = (active.get("worktree") or "").strip() or None
    worktree_exists = None
    worktree_clean = None
    if lease_wt:
        worktree_exists = os.path.isdir(lease_wt)
        if worktree_exists:
            try:
                import subprocess

                st = subprocess.run(
                    ["git", "-C", lease_wt, "status", "--porcelain"],
                    capture_output=True,
                    text=True,
                    timeout=30,
                    check=False,
                )
                if st.returncode == 0:
                    worktree_clean = not bool((st.stdout or "").strip())
            except Exception:
                worktree_clean = None

    # #702: derive owner-process evidence from the durable session-lease
    # shadow left behind by a crashed daemon. Only a provably dead recorded
    # owner pid yields False; PID liveness alone never proves ownership.
    orphan_evidence = reviewer_pr_lease.assess_crashed_session_lease_orphan(
        reviewer_pr_lease.load_session_lease_shadow(
            session_id=(active or {}).get("session_id")
        ),
        lease=active,
    )

    diagnosis = reviewer_pr_lease.diagnose_reviewer_pr_lease_handoff(
        comments,
        pr_number=pr_number,
        current_session_id=session_id,
        current_reviewer_identity=username,
        proposed_worktree=proposed_worktree,
        env_bound_worktree=env_wt,
        instructed_session_id=instructed_session_id,
        instructed_comment_id=instructed_comment_id,
        current_head_sha=current_head_sha,
        formal_reviews=formal_reviews,
        worktree_exists=worktree_exists,
        worktree_clean=worktree_clean,
        owner_process_alive=orphan_evidence.get("owner_process_alive"),
    )
    diagnosis["crashed_session_lease_evidence"] = orphan_evidence
    diagnosis["success"] = True
    diagnosis["remote"] = remote
    diagnosis["authenticated_user"] = username
    return diagnosis


@mcp.tool()
def gitea_cleanup_post_merge_moot_lease(
    pr_number: int,
    apply: bool = False,
    expected_session_id: str | None = None,
    expected_candidate_head: str | None = None,
    expected_lease_comment_id: int | None = None,
    worktree_path: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Safely resolve a moot reviewer lease left on an ALREADY-MERGED/closed PR (#515).

    Read-first and fail-safe. This tool never merges and never adopts a lease.
    It only acts when the live PR state is merged/closed, and it never steals or
    force-cleans an active *foreign* lease while the PR is still open. When
    ``apply`` is true and a lease is still active on a merged/closed PR, it posts
    a terminal ``phase: released`` lease marker (``blocker: post-merge-moot``) —
    an append-only comment that neutralises the moot lease without deleting any
    other session's comment.

    Role binding (#745). The two modes are gated differently, on purpose:

    * ``apply=false`` (assessment) stays reachable under ``gitea.read`` for
      **any** role, matching ``gitea_cleanup_stale_review_decision_lock`` and
      ``gitea_cleanup_obsolete_reviewer_comment_lease``, so an operator can
      diagnose a stuck lease from whichever namespace is attached. It performs
      no mutation and records append-only dry-run evidence in-session.
    * ``apply=true`` (mutation) additionally requires, in order: the session to
      have resolved exactly ``cleanup_post_merge_moot_lease``; the
      ``reconciler`` role; ``gitea.pr.comment``; a validated repository /
      canonical-root / workspace binding; and matching dry-run evidence proving
      ``lease_moot`` and ``cleanup_allowed`` for the same PR, lease session,
      candidate head and lease marker. Author, reviewer and merger fail closed
      here even though their profiles carry ``gitea.pr.comment``.

    Args:
        pr_number: The PR whose lingering lease to assess/clean.
        apply: When false (default) report only (read-only). When true, post the
            terminal released marker if — and only if — cleanup is authorized.
        expected_session_id: Optional lease session the caller expects; a
            mismatch against the live lease fails closed.
        expected_candidate_head: Optional leased head the caller expects; a
            mismatch against the live lease fails closed.
        expected_lease_comment_id: Optional lease marker id the caller expects;
            a mismatch against the live lease fails closed.
        worktree_path: Reconciler worktree to bind the apply-path preflight to.
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization. Validated against the canonical
            repository identity; it can never redirect the mutation.
        repo: Override the repository name. Validated as above.

    Returns:
        dict reporting PR merged/closed state, merge_commit_sha, linked-issue
        closure state, whether the lease is moot, whether cleanup was performed
        or skipped (and why), and ``no_merge_or_adoption`` True — this path never
        merges or adopts.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "cleanup_performed": False,
            "no_merge_or_adoption": True,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    pr_live = api_request(
        "GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {}
    comments = _fetch_pr_comments(
        pr_number, remote=remote, host=host, org=org, repo=repo)
    assessment = reviewer_pr_lease.assess_post_merge_moot_lease(
        comments,
        pr_number=pr_number,
        pr_merged=bool(pr_live.get("merged") or pr_live.get("merged_at")),
        pr_state=pr_live.get("state"),
        merge_commit_sha=pr_live.get("merge_commit_sha"),
    )

    # Best-effort linked-issue closure state for the report.
    active = assessment.get("active_lease") or {}
    issue_no = active.get("issue_number")
    linked_issue_state = None
    if issue_no:
        try:
            issue = api_request(
                "GET", f"{repo_api_url(h, o, r)}/issues/{issue_no}", auth) or {}
            linked_issue_state = issue.get("state")
        except Exception:
            linked_issue_state = None

    report = {
        "success": True,
        "pr_number": pr_number,
        "pr_state": assessment.get("pr_state"),
        "pr_merged_or_closed": assessment.get("pr_merged_or_closed"),
        "merge_commit_sha": assessment.get("merge_commit_sha"),
        "linked_issue_number": issue_no,
        "linked_issue_state": linked_issue_state,
        "lease_moot": assessment.get("is_moot"),
        "active_lease": assessment.get("active_lease"),
        "cleanup_allowed": assessment.get("cleanup_allowed"),
        "cleanup_performed": False,
        "no_merge_or_adoption": True,
        "mode": "apply" if apply else "read_only",
        "reasons": assessment.get("reasons") or [],
    }

    # The canonical repository identity comes from the session binding only —
    # never from the org/repo request parameters (#745 requirement 10).
    repository_slug = _bound_repository_slug(remote)
    report["repository_slug"] = repository_slug
    report["required_task"] = post_merge_moot_lease_gate.CLEANUP_TASK
    report["required_role_kind"] = post_merge_moot_lease_gate.REQUIRED_ROLE

    if not apply:
        # Append-only dry-run evidence. Recorded for every assessment, allowed
        # or not, so a later apply can prove the lease it saw is the lease that
        # is still there. Prior entries are never rewritten.
        evidence = post_merge_moot_lease_gate.record_dry_run(
            pr_number=pr_number,
            repository_slug=repository_slug,
            lease_moot=bool(assessment.get("is_moot")),
            cleanup_allowed=bool(assessment.get("cleanup_allowed")),
            session_id=active.get("session_id"),
            candidate_head=active.get("candidate_head"),
            lease_comment_id=active.get("comment_id"),
        )
        report["dry_run_evidence"] = evidence
        return report

    # ---------------------------------------------------------------- apply --
    # Preserve the existing dry-run safety assessment: a lease that is not moot
    # (open PR, already-terminal, absent) is refused here exactly as before, and
    # no mutation is attempted.
    if not assessment.get("cleanup_allowed"):
        report["cleanup_skipped_reason"] = (
            assessment.get("reasons") or ["cleanup not allowed"]
        )
        return report

    # 1 + 2 + 6: exact resolved cleanup task, reconciler role, and matching
    # append-only dry-run evidence. Permission alone is deliberately not enough.
    authorization = post_merge_moot_lease_gate.assess_apply_authorization(
        pr_number=pr_number,
        repository_slug=repository_slug,
        resolved_task=_preflight_resolved_task,
        active_role_kind=_profile_role_kind(get_profile()),
        assessment=assessment,
        evidence=post_merge_moot_lease_gate.latest_dry_run(
            pr_number=pr_number, repository_slug=repository_slug
        ),
        expected_session_id=expected_session_id,
        expected_candidate_head=expected_candidate_head,
        expected_lease_comment_id=expected_lease_comment_id,
    )
    report["authorization"] = authorization
    if not authorization["allowed"]:
        report["success"] = False
        report["reasons"] = authorization["reasons"]
        report["blocker_kind"] = authorization["blocker_kind"]
        return report

    # 3: the dedicated mutation permission.
    comment_block = _profile_operation_gate("gitea.pr.comment")
    if comment_block:
        report["success"] = False
        report["reasons"] = comment_block
        report["permission_report"] = _permission_block_report("gitea.pr.comment")
        return report

    # 4: explicit target must agree with the canonical repository identity
    # before any preflight or mutation (#733/#739).
    repo_binding_block = _repository_binding_block(
        remote, org=org, repo=repo,
        required_permission="gitea.pr.comment",
    )
    if repo_binding_block:
        report["success"] = False
        report["reasons"] = repo_binding_block["reasons"]
        report["blocker_kind"] = repo_binding_block["blocker_kind"]
        return report

    # 4 (cont.): canonical root, workspace binding and the resolved-task match
    # are enforced by the shared preflight, with the explicit target forwarded
    # so the #604 anti-stomp resolution validates the targeted repository.
    verify_preflight_purity(
        remote,
        worktree_path=worktree_path,
        task=post_merge_moot_lease_gate.CLEANUP_TASK,
        org=org,
        repo=repo,
    )
    body = assessment["release_body"]
    comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
    with _audited(
        "comment_pr",
        host=h,
        remote=remote,
        org=o,
        repo=r,
        pr_number=pr_number,
        request_metadata={"source": "cleanup_post_merge_moot_lease"},
    ):
        posted = api_request("POST", comment_url, auth, {"body": body})

    report["cleanup_performed"] = True
    report["released_comment_id"] = posted.get("id")
    return report


@mcp.tool()
def gitea_cleanup_obsolete_reviewer_comment_lease(
    pr_number: int,
    confirmation: str = "",
    apply: bool = False,
    controller_recovery_authorized: bool = False,
    expected_lease_comment_id: int | None = None,
    expected_session_id: str | None = None,
    expected_leased_head: str | None = None,
    owner_process_alive: bool | None = None,
    current_head_review_in_progress: bool = False,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Guarded cleanup for obsolete comment-backed reviewer leases on open PRs (#691).

    Neutralises an expired and/or superseded-head foreign lease by posting a
    terminal ``phase: released`` lease marker (append-only audit). Never deletes
    comments, never repoints the lease to the new head, never transfers
    validation/decision/workflow state, never uses PID equality as ownership,
    and never steals a genuinely active foreign lease on the current head.

    Read-first when ``apply`` is false. Apply requires:

    - ``controller_recovery_authorized=True`` (explicit operator/controller
      recovery capability — not implied by profile alone)
    - ``confirmation`` exactly ``CLEANUP OBSOLETE REVIEWER LEASE <pr_number>``
    - full eligibility evidence from
      ``reviewer_pr_lease.assess_obsolete_reviewer_comment_lease_cleanup``

    Comment-backed leases need no control-plane DB ``lease_id``.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "cleanup_performed": False,
            "no_steal_or_adoption": True,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }

    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    expected_repo = f"{o}/{r}"
    comments = _fetch_pr_comments(
        pr_number, remote=remote, host=host, org=org, repo=repo
    )
    pr_live = api_request(
        "GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth
    ) or {}
    head = pr_live.get("head") or {}
    current_head_sha = head.get("sha") or pr_live.get("head_commit_sha")

    formal_reviews: list[dict] = []
    try:
        reviews = api_request(
            "GET",
            f"{repo_api_url(h, o, r)}/pulls/{pr_number}/reviews",
            auth,
        ) or []
        for rev in reviews if isinstance(reviews, list) else []:
            formal_reviews.append({
                "verdict": rev.get("state") or rev.get("verdict"),
                "reviewed_head_sha": rev.get("commit_id")
                or rev.get("reviewed_head_sha"),
                "dismissed": bool(rev.get("dismissed")),
                "reviewer": ((rev.get("user") or {}).get("login")),
                "submitted_at": rev.get("submitted_at"),
            })
    except Exception:
        formal_reviews = []

    lease = reviewer_pr_lease.find_newest_nonterminal_lease(
        comments, pr_number=pr_number, include_expired=True
    )
    lease_wt = ((lease or {}).get("worktree") or "").strip() or None
    worktree_exists = None
    worktree_clean = None
    worktree_has_unpreserved_work = None
    if lease_wt:
        worktree_exists = os.path.isdir(lease_wt)
        if worktree_exists:
            try:
                import subprocess

                st = subprocess.run(
                    ["git", "-C", lease_wt, "status", "--porcelain"],
                    capture_output=True,
                    text=True,
                    timeout=30,
                    check=False,
                )
                if st.returncode == 0:
                    dirty = bool((st.stdout or "").strip())
                    worktree_clean = not dirty
                    worktree_has_unpreserved_work = dirty
            except Exception:
                worktree_clean = None

    session = reviewer_pr_lease.get_session_lease() or {}
    # #702: when the caller supplies no owner evidence, consult the durable
    # session-lease shadow; an explicitly passed value always wins.
    if owner_process_alive is None:
        owner_process_alive = reviewer_pr_lease.assess_crashed_session_lease_orphan(
            reviewer_pr_lease.load_session_lease_shadow(
                session_id=(lease or {}).get("session_id")
            ),
            lease=lease,
        ).get("owner_process_alive")
    assessment = reviewer_pr_lease.assess_obsolete_reviewer_comment_lease_cleanup(
        comments,
        pr_number=pr_number,
        current_head_sha=current_head_sha,
        formal_reviews=formal_reviews,
        repo=expected_repo,
        expected_repo=expected_repo,
        requesting_session_id=(session.get("session_id") or "").strip() or None,
        controller_recovery_authorized=controller_recovery_authorized,
        worktree_exists=worktree_exists,
        worktree_clean=worktree_clean,
        worktree_has_unpreserved_work=worktree_has_unpreserved_work,
        owner_process_alive=owner_process_alive,
        owner_pid_observed=None,
        requesting_pid=os.getpid(),
        current_head_review_in_progress=current_head_review_in_progress,
        expected_lease_comment_id=expected_lease_comment_id,
        expected_session_id=expected_session_id,
        expected_leased_head=expected_leased_head,
        confirmation=confirmation,
        apply=apply,
    )

    report = {
        "success": True,
        "pr_number": pr_number,
        "pr_state": pr_live.get("state"),
        "cleanup_allowed": assessment.get("cleanup_allowed"),
        "cleanup_performed": False,
        "classification": assessment.get("classification"),
        "blocker_kind": assessment.get("blocker_kind"),
        "exact_next_action": assessment.get("exact_next_action"),
        "mutation_eligibility": assessment.get("mutation_eligibility"),
        "leased_head": assessment.get("leased_head"),
        "current_head": assessment.get("current_head"),
        "expires_at": assessment.get("expires_at"),
        "terminal_review": assessment.get("terminal_review"),
        "worktree_state": assessment.get("worktree_state"),
        "owner_session_evidence": assessment.get("owner_session_evidence"),
        "cleanup_tool": assessment.get("cleanup_tool"),
        "required_confirmation": assessment.get("required_confirmation"),
        "active_lease": assessment.get("active_lease"),
        "no_steal_or_adoption": True,
        "no_repoint": True,
        "no_validation_transfer": True,
        "mode": "apply" if apply else "read_only",
        "reasons": assessment.get("reasons") or [],
        "forbidden": assessment.get("forbidden") or [],
    }

    if not apply:
        return report
    if not assessment.get("cleanup_allowed"):
        report["success"] = False
        report["cleanup_skipped_reason"] = (
            assessment.get("fail_closed_reasons")
            or assessment.get("reasons")
            or ["cleanup not allowed"]
        )
        return report

    comment_block = _profile_operation_gate("gitea.pr.comment")
    if comment_block:
        report["success"] = False
        report["reasons"] = comment_block
        report["permission_report"] = _permission_block_report("gitea.pr.comment")
        return report

    verify_preflight_purity(remote)
    # Terminal lease marker (preserves history; does not delete comment 10749-style
    # entries — newest released marker neutralises the ledger).
    release_body = assessment.get("release_body") or ""
    audit_body = assessment.get("audit_comment_body") or ""
    comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
    with _audited(
        "comment_pr",
        host=h,
        remote=remote,
        org=o,
        repo=r,
        pr_number=pr_number,
        request_metadata={
            "source": "cleanup_obsolete_reviewer_comment_lease",
            "classification": assessment.get("classification"),
        },
    ):
        posted_release = api_request(
            "POST", comment_url, auth, {"body": release_body}
        )
        posted_audit = None
        if audit_body.strip():
            posted_audit = api_request(
                "POST", comment_url, auth, {"body": audit_body}
            )

    # Never seed session lease from cleaned foreign lease.
    report["cleanup_performed"] = True
    report["released_comment_id"] = (posted_release or {}).get("id")
    report["audit_comment_id"] = (posted_audit or {}).get("id")
    report["session_lease_seeded"] = False
    return report


@mcp.tool()
def gitea_release_reviewer_pr_lease(
    pr_number: int,
    worktree: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Safely release an active reviewer lease owned by the current session on an open PR.

    This provides a canonical way to clean up reviewer leases when a review fails
    before submission.

    Args:
        pr_number: The PR number whose reviewer lease to release.
        worktree: Optional worktree path (resolves automatically if not supplied).
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.

    Returns:
        dict reporting release status.
    """
    _verify_role_mutation_workspace(
        remote,
        worktree=worktree,
        task="review_pr",
        org=org,
        repo=repo,
    )
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)

    comments = _fetch_pr_comments(
        pr_number, remote=remote, host=host, org=org, repo=repo
    )
    active = reviewer_pr_lease.find_active_reviewer_lease(
        comments, pr_number=pr_number
    )

    if not active:
        return {
            "success": True,
            "released": False,
            "reasons": ["no active reviewer lease found on PR"],
        }

    # Host (not remote name). Passing "prgs"/"dadeschools" yields empty identity
    # and incorrectly blocks same-identity release across sessions.
    identity = _authenticated_username(h) or ""
    session = reviewer_pr_lease.get_session_lease() or {}
    session_id = session.get("session_id")

    owner_identity = active.get("reviewer_identity")
    owner_session_id = active.get("session_id")

    # Authorize release: identity matches, session_id matches, or lease is expired/reclaimable
    authorized = False
    reasons = []
    if owner_identity and identity and owner_identity == identity:
        authorized = True
    elif owner_session_id and session_id and owner_session_id == session_id:
        authorized = True
    else:
        freshness = reviewer_pr_lease.classify_lease_freshness(active)
        if freshness in {"expired", "reclaimable"}:
            authorized = True

    if not authorized:
        return {
            "success": False,
            "released": False,
            "reasons": [
                f"unauthorized to release active lease: owned by {owner_identity} "
                f"(session {owner_session_id})"
            ],
        }

    # Format and post release comment
    body = reviewer_pr_lease.format_lease_body(
        repo=active.get("repo") or f"{o}/{r}",
        pr_number=pr_number,
        issue_number=active.get("issue_number"),
        reviewer_identity=owner_identity or identity,
        profile=active.get("profile") or "reviewer",
        session_id=owner_session_id or session_id or "unknown",
        worktree=active.get("worktree") or "",
        phase="released",
        candidate_head=active.get("candidate_head"),
        target_branch=active.get("target_branch") or "master",
        target_branch_sha=active.get("target_branch_sha"),
        blocker="manual-release",
    )

    comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
    with _audited(
        "comment_pr",
        host=h,
        remote=remote,
        org=o,
        repo=r,
        pr_number=pr_number,
        request_metadata={"source": "release_reviewer_pr_lease"},
    ):
        posted = api_request("POST", comment_url, auth, {"body": body})

    reviewer_pr_lease.clear_session_lease()

    return {
        "success": True,
        "released": True,
        "comment_id": posted.get("id"),
        "reasons": [],
    }


@mcp.tool()
def gitea_list_issue_comments(
    issue_number: int,
    limit: int = 50,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """List discussion comments on a Gitea issue.

    Read-only. Issue discussion comments are distinct from PR reviews: this
    reads the issue comment thread and never touches review endpoints. The
    profile must allow ``gitea.read`` (fail closed otherwise).

    Normal output is LLM-safe: no endpoint URLs. Set
    GITEA_MCP_REVEAL_ENDPOINTS=1 (admin/debug opt-in) to include each
    comment's web link.

    Args:
        issue_number: The issue number whose comments to list (required).
        limit: Max number of comments to return (default: 50).
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.

    Returns:
        dict with 'success', 'issue_number', and 'comments' (each with 'id',
        'author', 'created_at', 'updated_at', 'body'); on a permission block,
        'success' False, 'reasons', and a structured 'permission_report'
        (#142) with no API call made.
    """
    reasons = _profile_operation_gate("gitea.read")
    if reasons:
        return {"success": False, "issue_number": issue_number,
                "reasons": reasons,
                "permission_report": _permission_block_report("gitea.read")}
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"
    comments = api_request("GET", api, auth)
    # Fail safe to no comments when the API returns a non-list payload (e.g. an
    # error object such as an HTTP 401 body) so listing never crashes on a
    # malformed response (#485).
    if not isinstance(comments, list):
        comments = []
    reveal = _reveal_endpoints()
    out = []
    for c in comments[:limit]:
        entry = {
            "id": c["id"],
            "author": (c.get("user") or {}).get("login", ""),
            "created_at": c.get("created_at"),
            "updated_at": c.get("updated_at"),
            "body": c.get("body", ""),
        }
        if reveal:
            entry["url"] = c.get("html_url")
        out.append(entry)
    return {"success": True, "issue_number": issue_number, "comments": out}


def _two_comment_workflow_comment_gate(body: str) -> list[str]:
    """Fail closed on invalid tagged workflow comments (#507)."""
    text = body or ""
    reasons: list[str] = []
    if thread_state_ledger_validator.has_controller_handoff_marker(text):
        result = thread_state_ledger_validator.assess_controller_handoff_comment(
            text
        )
        reasons.extend(result.get("reasons") or [])
    if thread_state_ledger_validator.has_thread_state_ledger_marker(text):
        result = thread_state_ledger_validator.assess_thread_state_ledger_comment(
            text
        )
        reasons.extend(result.get("reasons") or [])
    return reasons


@mcp.tool()
def gitea_create_issue_comment(
    issue_number: int,
    body: str,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    worktree_path: str | None = None,
) -> dict:
    """Post a markdown comment to a Gitea issue's discussion thread.

    Issue discussion comments are distinct from PR reviews: this posts to the
    issue comment thread only and never submits review verdicts. The profile
    must allow ``gitea.issue.comment`` — gated separately from the gitea.pr.*
    review/merge operations (fail closed otherwise). The target issue is
    always explicit; there is no inference beyond the standard remote
    defaults used by every tool.

    Normal output is LLM-safe: comment id + issue number, no endpoint URLs.
    Set GITEA_MCP_REVEAL_ENDPOINTS=1 (admin/debug opt-in) to include the
    comment's web link. Errors are redacted before being raised.

    Args:
        issue_number: The issue number to comment on (required).
        body: Markdown comment body (required, non-empty).
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.
        worktree_path: Optional path to verify branches-only guard.

    Returns:
        dict with 'success', 'comment_id', and 'issue_number' ('url' only
        with the reveal opt-in); on a permission block or empty body,
        'success'/'performed' False and 'reasons' with no API call made
        (permission blocks also carry a structured 'permission_report',
        #142). On production-guard blocks (#683): 'blocker_kind' and
        'exact_next_action' with no API side effect.
    """
    try:
        # Do not pass target_issue_number: comments on other issues remain
        # allowed while an author holds a different implementation lock.
        # Scope ownership for source edits is enforced via branch/worktree
        # binding + root diagnostic checks (#683).
        # #735: forward explicit org/repo into shared anti-stomp preflight.
        verify_preflight_purity(
            remote,
            worktree_path=worktree_path,
            task="comment_issue",
            org=org,
            repo=repo,
        )
    except Exception as exc:
        typed = _production_guard_block_from_exc(
            exc, issue_number=issue_number
        )
        if typed is not None:
            return typed
        raise
    gate_reasons = _profile_operation_gate("gitea.issue.comment")
    reasons = list(gate_reasons)
    if not (body or "").strip():
        reasons.append("comment body must be a non-empty string")
    if not reasons:
        reasons.extend(_two_comment_workflow_comment_gate(body))
    if reasons:
        blocked = {"success": False, "performed": False,
                   "issue_number": issue_number, "reasons": reasons}
        if gate_reasons:
            # Permission denial (not a mere input error): explain it (#142).
            blocked["permission_report"] = _permission_block_report(
                "gitea.issue.comment")
        return blocked
    canon_gate = _canonical_comment_gate(body, context="issue_comment")
    if canon_gate["blocked"]:
        return {
            "success": False,
            "performed": False,
            "issue_number": issue_number,
            "reasons": canon_gate["reasons"],
            "canonical_comment_validation": canon_gate["canonical_comment_validation"],
        }
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"
    try:
        with _audited("create_issue_comment", host=h, remote=remote, org=o,
                      repo=r, issue_number=issue_number,
                      request_metadata={"body_chars": len(body)}):
            data = api_request("POST", api, auth, {"body": body})
    except Exception as exc:
        raise RuntimeError(_redact(str(exc))) from None
    result = {"success": True, "performed": True,
              "comment_id": data["id"], "issue_number": issue_number}
    if _reveal_endpoints():
        result["url"] = data.get("html_url")
    return result


# ── Operator guide / project skills (#128) ───────────────────────────────────
# Read-only capability discovery for new LLM sessions: what the active
# profile may do, how identity is verified, the non-negotiable safety rules,
# and which project workflows ("skills") exist. Nothing here mutates state or
# widens any permission — the guide describes the gates, it never bypasses
# them.

def _role_kind(allowed, forbidden) -> str:
    """Classify the active profile from its normalized operations.

    'reconciler' can close already-landed PRs without review/author powers;
    'author' can create PRs / push branches; 'reviewer' can approve;
    'merger' can merge PRs;
    'mixed' can do multiple (a config smell); 'limited' can do neither.
    """
    if reconciler_profile.is_reconciler_profile(allowed, forbidden):
        return "reconciler"
    def can(op):
        return gitea_config.check_operation(op, allowed, forbidden)[0]
    can_merge = can("gitea.pr.merge")
    can_approve = can("gitea.pr.approve")
    author = can("gitea.pr.create") or can("gitea.branch.push")
    reconciler = (
        can("gitea.pr.close")
        and not can_approve
        and not can_merge
        and not author
    )
    if (can_approve or can_merge) and author:
        return "mixed"
    if can_merge:
        return "merger"
    if can_approve:
        return "reviewer"
    if reconciler:
        return "reconciler"
    if author:
        return "author"
    return "limited"


_HARD_STOPS = [
    "No self-review and no self-merge: the authenticated user must differ "
    "from the PR author for review/approve/merge.",
    "No tags or releases without an explicit operator instruction.",
    "Never print token values, keychain IDs, passwords, or raw service "
    "URLs in normal output.",
    "No production access or production mutations.",
    "No force-merge and no bypassing of merge gates; Gitea's own "
    "mergeable signal is honoured, never overridden.",
    "Do not rewrite the live profiles config; config changes are "
    "operator-owned.",
    "Do not hardcode Gitea identities or assume profile roles from prompts. "
    "Always call gitea_whoami to verify the active identity and profile, and "
    "call gitea_resolve_task_capability to check required task permissions.",
    "Use the correct namespace/profile: PR review and merge require a reviewer "
    "namespace (e.g. gitea-reviewer), and branch/PR creation requires an author "
    "namespace (e.g. gitea-author).",
    "Issue comments require explicit gitea.issue.comment permission. PR "
    "comments (gitea.pr.comment) do not imply or satisfy issue comment requirements.",
]

_GUIDE_RULES = {
    "hard_stops": _HARD_STOPS,
    "fail_closed": (
        "Every gate fails closed: unknown/disabled profiles, unknown "
        "operations, empty allowed-operation lists, unresolved identity, "
        "and unparseable config all deny the action instead of guessing. "
        "Disabled profiles/services are never silently substituted."),
    "head_sha_pinning": (
        "Before reviewing or merging, record the PR head SHA and pass it as "
        "expected_head_sha; if the head moves between review and merge, the "
        "tool refuses and the review must be redone."),
    "merge_confirmation": (
        "gitea_merge_pr requires confirmation to equal 'MERGE PR <n>' "
        "exactly (e.g. 'MERGE PR 42'); without it no API call is made. "
        "Merge only when the operator has explicitly authorized it."),
    "redaction": [
        "Normal output contains no endpoint URLs, keychain IDs, or token "
        "values; auth is reported as type/status only.",
        "GITEA_MCP_REVEAL_ENDPOINTS=1 is the local admin/debug opt-in for "
        "web links and endpoint names; it never reveals token values.",
        "Errors are redacted before being surfaced.",
    ],
    "separation": (
        "Author, reviewer, and merger are separate identities. An author "
        "profile creates branches/commits/PRs and must never "
        "review/approve/merge its own work; a reviewer/merger profile "
        "reviews and merges and must never create PRs or push branches "
        "(reviewer-deadlock invariant). Issue discussion comments "
        "(gitea.issue.comment) are gated separately from PR reviews "
        "(gitea.pr.*)."),
    "profile_switching": [
        "The active profile is selected via GITEA_MCP_PROFILE (with GITEA_MCP_CONFIG pointing at the config path).",
        "By default, static-profile mode is active: changing local environment variables does not dynamically update the already-connected MCP server process.",
        "If runtime profile switching is explicitly enabled in the config (allow_runtime_switching: true), use gitea_activate_profile to switch profiles in-place.",
        "If switching is disabled, you must change the launcher configuration/environment (dual-namespace) and reconnect/restart the MCP session.",
        "After any profile switch or startup, you must verify the new runtime identity with a fresh gitea_whoami call before taking action."
    ],
    "identity_verification": [
        "Call gitea_whoami with an explicit remote first; confirm the "
        "authenticated username and profile match the role you were asked "
        "to perform.",
        "If the username, profile, or allowed operations do not match the "
        "task, STOP and report instead of proceeding.",
        "Runtime identity (whoami) is the source of truth; config-declared "
        "roles are metadata only.",
        "Do not hardcode Gitea identities; always dynamically verify your "
        "identity and resolve task capability via gitea_resolve_task_capability.",
    ],
    "live_state_reconciliation": [
        "Never rely on prior handoffs, chat history, or cached reports for PR queue state.",
        "Before review or merge decisions: always call gitea_list_prs + gitea_view_pr (for the specific PR), re-fetch remotes, pull master --ff-only, and compare live fields (state, mergeable, head SHA, updated_at, merged_at, merge_commit_sha) against any prior claims.",
        "Conflicting or stale PR state (e.g. prior said merged but live open, or head/updated mismatch) is a hard blocker: report it explicitly and stop until live state is unambiguous.",
        "After merge, re-list PRs and re-view the PR (plus verify master contains the merge) before claiming completion.",
    ],
    "work_selection": (
        "Before any issue or PR work, acquire or verify a work lease. Do not "
        "code, review, branch, commit, push, comment, or open a PR until you "
        "prove the target is not already being worked. Required checks: list "
        "open PRs; search PRs linked to the issue; search local/remote "
        "branches for the issue number; search worktrees for the issue "
        "branch; check dirty worktrees; check active leases or recent "
        "handoffs; check whether a merged PR already completed the issue. If "
        "another session owns the lease, stop (continue as owner, review the "
        "existing PR, hand off, request takeover after expiry, or report "
        "'work already claimed'). Never create a parallel branch/PR unless "
        "the old branch is proven abandoned and takeover is recorded. "
        "gitea_lock_issue is the fail-closed author lease gate."),
    "global_worktree": (
        "Main checkout is a stable control checkout on master/main/dev only. "
        "All LLM task work happens under branches/. Before any mutation, prove "
        "project root, cwd, branch, main-checkout stable branch, and "
        "session-owned branches/ worktree path. If cwd is not under "
        "branches/, stop — no edit/create/delete/format/test-write/commit/"
        "merge/rebase/checkout/cleanup, with no exceptions (docs, tests, "
        "small fixes, review fixes, conflicts, emergencies). Main checkout: "
        "read-only inspect, fetch, create worktrees, post-merge stable "
        "update, explicit repair only."),
    "shell_spawn_hard_stop": (
        "A shell result of exit_code: -1 with empty stdout/stderr is an "
        "executor spawn failure, not a command failure. Probe once (echo/pwd); "
        "if the probe fails, mark shell unavailable for the session. After "
        "two consecutive spawn failures, hard-stop all shell use — never "
        "retry the same failing spawn — and emit a recovery report: restart "
        "the session, kill hung background terminals, prefer MCP-native "
        "paths (e.g. gitea_commit_files) for remaining mutations. Shell "
        "unavailability never authorizes WebFetch/browser/manual-encoding "
        "fallbacks (#258)."),
    "subagent_tool_budget": (
        "General-purpose subagents on deterministic MCP tasks must stay within "
        "budget: single-step mutations (commit_files, create_pr, lock_issue) "
        "max 15 tool calls / 5 min; review/merge queue inspection max 40 / "
        "15 min. The main session with gitea.repo.commit must call "
        "gitea_commit_files directly — no subagent delegation (#260). After "
        "shell spawn failure (#258), attempt native MCP once; never authorize "
        "WebFetch/Playwright/manual base64 when gitea_commit_files is "
        "available. Never resume a failed subagent into a larger retry loop "
        "(#259)."),
    "subagent_delegation": (
        "Deterministic write workflows (issue claim, branch creation, code "
        "edits, commits, PR creation, review, merge, cleanup) run inline in "
        "the parent session — subagents are blocked for them unless "
        "explicitly allowed with a recorded justification. An authorized "
        "write subagent must inherit the full gate context: issue lock, "
        "branch/worktree path, identity/profile, allowed tool class, "
        "command deny list, validation ledger requirement, and final report "
        "schema. Subagent output is accepted only with the same proof "
        "fields as the parent workflow; read-only delegation (search, "
        "inventory, summarize) needs no explicit authorization. Enforced "
        "fail closed via subagent_gate.assess_subagent_delegation and "
        "validate_subagent_report (#266)."),
    "review_merge_state_machine": (
        "PR review/merge must follow the enforced state machine: PRECHECK → "
        "INVENTORY → SELECT_PR → PIN_HEAD_SHA → CREATE_BRANCHES_WORKTREE → "
        "VALIDATE → REVIEW_DECISION → APPROVE_OR_REQUEST_CHANGES → "
        "PRE_MERGE_RECHECK → MERGE → POST_MERGE_REPORT. Failed upstream "
        "states forbid downstream approve/merge. infra_stop and capability "
        "blocks stop immediately. Blocked recovery handoffs must restart the "
        "full workflow — never replay approve/merge commands (#290)."),
    "native_mcp_preference": (
        "Gitea mutations must use native MCP tools first. When MCP is "
        "available, block shell scripts, direct API calls, WebFetch, "
        "Playwright, helper encoders, and profile-secret reads unless "
        "explicit recovery-mode proof is complete. If MCP transport is "
        "broken or the shell circuit breaker trips, emit a terminal recovery "
        "report instead of improvising unsafe fallback. Reviewers must not "
        "touch or restart MCP server files/processes (#270)."),
}

_COMMON_WORKFLOWS = [
    "task routing: resolve task capability via gitea_resolve_task_capability "
    "to check required permission/role kind and identify the safe next action.",
    "work selection: acquire or verify a work lease before any issue/PR work "
    "(open PRs, linked PRs, branches, worktrees, dirty worktrees, leases, "
    "merged completion); stop if another session owns the lease.",
    "global worktree: main checkout on master/main/dev only; mutate only "
    "from a branches/ worktree after proving root, cwd, branch, stable "
    "main-checkout branch, and session worktree path.",
    "issue authoring: verify identity, create/claim the issue, keep scope "
    "explicit (remote/org/repo).",
    "implementation: claim issue, branch from fresh master, implement only "
    "the issue scope, test, open a PR referencing the issue, stop.",
    "PR review: verify reviewer identity (must be a reviewer profile/namespace), "
    "reconcile live state first (list_prs + view_pr selected PR immediately; "
    "verify state/mergeable/head SHA/updated_at/merged info vs any prior reports; "
    "git fetch --prune and pull --ff-only master; check linked issue state); "
    "pin the head SHA; treat conflicting/stale reports as blocker and stop; "
    "validate independently, post a verdict; never review your own work. "
    "After merge, re-list and re-view.",
    "PR merge: reviewer identity (must be a reviewer profile/namespace) + "
    "eligibility check + pinned head + explicit 'MERGE PR <n>' confirmation, "
    "only with operator authorization. Reconcile live state before deciding.",
]

# Skill registry (#128). status: 'available' = backed by tools in this
# server; 'external-mcp' = implemented in another MCP server that must be
# registered separately; 'designed-not-implemented' = design exists but no MCP
# tools yet; 'operator-only' = never performed by an LLM session.
_PROJECT_SKILLS = {
    "gitea-issue-authoring": {
        "description": "Create, view, label, claim, and close Gitea issues.",
        "when_to_use": "Filing new work, updating issue state, claiming an "
                       "issue before implementation (gitea_mark_issue).",
        "required_operations": ["gitea.read"],
        "status": "available",
        "notes": "Always pass remote/org/repo explicitly. Resolve task "
                 "capability first to confirm author profile and "
                 "gitea.issue.* permissions.",
        "steps": [
            "Resolve task first: gitea_resolve_task_capability to confirm "
            "author namespace and required permissions (e.g. for "
            "create_issue or comment_issue).",
            "Verify identity with gitea_whoami (explicit remote).",
            "Check the issue queue with gitea_list_issues.",
            "Create with gitea_create_issue or claim with gitea_mark_issue "
            "action='start'.",
            "Keep issue bodies free of secrets, tokens, and raw service "
            "URLs.",
            "Release the claim (action='done') or close when finished.",
        ],
    },
    "gitea-pr-creation": {
        "description": "Author a feature branch and open a pull request.",
        "when_to_use": "After implementing a claimed issue on a feature "
                       "branch; author profiles only.",
        "required_operations": ["gitea.pr.create"],
        "status": "available",
        "steps": [
            "Resolve task first: gitea_resolve_task_capability(task='create_pr') "
            "to confirm author profile.",
            "Branch from fresh master (git pull first).",
            "Implement only the claimed issue's scope; stage files "
            "explicitly.",
            "Run targeted tests, the full suite, py_compile, git diff "
            "--check, and a secret sweep before committing.",
            "Open the PR with gitea_create_pr referencing the issue "
            "('Closes #<n>').",
            "Stop: do not review or merge your own PR.",
        ],
    },
    "gitea-pr-review": {
        "description": "Independently review a pull request and post a "
                       "verdict.",
        "when_to_use": "Reviewer profile asked to evaluate someone else's "
                       "PR.",
        "required_operations": ["gitea.pr.review"],
        "status": "available",
        "steps": [
            "Resolve task first: gitea_resolve_task_capability(task='review_pr') "
            "to confirm reviewer namespace and avoid author-profile blocks.",
            "Load canonical workflow proof with gitea_load_review_workflow "
            "before any review/merge mutation (#389).",
            "Verify reviewer identity with gitea_whoami; the PR author "
            "must be a different user.",
            "Reconcile live queue state FIRST (do not trust prior handoffs): "
            "call gitea_list_prs, then immediately gitea_view_pr on the target; "
            "verify state, mergeable, head vs current, updated_at/merged info, "
            "linked issue; git fetch --prune; git checkout master; git pull <remote> master --ff-only. "
            "If prior report conflicts with live (e.g. 'merged' but live open, or head/updated mismatch), "
            "report inconsistency and STOP before review/merge.",
            "Pin the reviewed head SHA (gitea_view_pr) before validating.",
            "Validate independently: scope vs the linked issue, tests, "
            "diff check, secret sweep.",
            "Post the verdict with gitea_review_pr / "
            "gitea_submit_pr_review.",
            "After any merge, re-list open PRs and re-view the PR to confirm landed state.",
            "Do not merge unless the operator explicitly authorizes it.",
        ],
    },
    "gitea-reconcile-landed-pr": {
        "description": "Reconcile open PRs whose heads are already on the "
                       "target branch without review or merge.",
        "when_to_use": "An open PR blocks the review queue but its head SHA "
                       "is already an ancestor of master; use the dedicated "
                       "reconciliation path instead of review/merge.",
        "required_operations": ["gitea.read"],
        "status": "available",
        "notes": "Load skills/llm-project-workflow/workflows/reconcile-landed-pr.md "
                 "first. PR close requires exact gitea.pr.close; review/merge "
                 "capabilities must not be used on this path.",
        "steps": [
            "Resolve task: gitea_resolve_task_capability(task='reconcile_landed_pr').",
            "Load canonical workflow via mcp_get_skill_guide or "
            "skills/llm-project-workflow/workflows/reconcile-landed-pr.md.",
            "Scan queue: gitea_scan_already_landed_open_prs (pagination proof).",
            "Assess one PR: gitea_assess_already_landed_reconciliation.",
            "Follow the plan outcome: full close only with gitea.pr.close, "
            "comment-then-stop when close is missing, recovery handoff otherwise.",
            "Never approve, request changes, or merge on this path.",
        ],
    },
    "gitea-pr-merge": {
        "description": "Gated merge of an approved pull request.",
        "when_to_use": "Reviewer/merger profile with explicit operator "
                       "authorization to merge.",
        "required_operations": ["gitea.pr.merge"],
        "status": "available",
        "steps": [
            "Resolve task first: gitea_resolve_task_capability(task='merge_pr') "
            "to confirm reviewer namespace.",
            "Confirm operator authorization for this specific merge.",
            "Run gitea_check_pr_eligibility action='merge' and resolve "
            "every reason it reports.",
            "Call gitea_merge_pr with expected_head_sha pinned and "
            "confirmation 'MERGE PR <n>'.",
            "Confirm linked issues auto-closed and the in-progress label "
            "was released.",
        ],
    },
    "gitea-issue-comments": {
        "description": "Read and post issue discussion comments (distinct "
                       "from PR reviews).",
        "when_to_use": "Design discussions, review notes on issues, "
                       "decision records.",
        "required_operations": ["gitea.issue.comment"],
        "status": "available",
        "notes": "Listing needs only gitea.read; posting needs "
                 "gitea.issue.comment, gated separately from gitea.pr.*. "
                 "Resolve with gitea_resolve_task_capability first.",
        "steps": [
            "Resolve task first: gitea_resolve_task_capability(task='comment_issue') "
            "to confirm gitea.issue.comment and correct profile/namespace.",
            "List with gitea_list_issue_comments (explicit issue number).",
            "Post with gitea_create_issue_comment; markdown body, no "
            "secrets or raw URLs.",
            "Never use PR review tools for issue discussion or vice "
            "versa.",
        ],
    },
    "gitea-resolve-task-capability": {
        "description": "Determine the required profile, permission, role kind, "
                       "and safe next action (namespace switch or relaunch) "
                       "for a Gitea task before acting.",
        "when_to_use": "Before any mutating action, review, or comment; "
                       "to select correct author/reviewer namespace and "
                       "avoid permission failures.",
        "required_operations": ["gitea.read"],
        "status": "available",
        "steps": [
            "Call gitea_resolve_task_capability with the intended task "
            "(e.g. 'create_issue', 'review_pr', 'comment_issue').",
            "Inspect 'allowed_in_current_session', 'required_role_kind', "
            "'matching_configured_profile', 'different_mcp_namespace_required', "
            "and 'exact_safe_next_action'.",
            "If not allowed, follow the exact safe next action (do not "
            "hardcode identity or guess).",
            "Verify the active profile matches the required role before "
            "proceeding.",
        ],
    },
    "profile-switching": {
        "description": "Change the active MCP identity between author and "
                       "reviewer profiles.",
        "when_to_use": "A task requires a role the current profile "
                       "forbids (e.g. moving from authoring to review).",
        "required_operations": [],
        "status": "available",
        "steps": [
            "Check profile mode using gitea_get_runtime_context.",
            "If dynamic-profile mode is enabled (allow_runtime_switching: true), "
            "call gitea_activate_profile to switch in-place.",
            "If static-profile mode (default), ask the operator to update GITEA_MCP_PROFILE "
            "in the launcher environment and reconnect/restart the MCP server.",
            "Immediately verify the new identity with a fresh gitea_whoami call before taking action.",
            "If identity does not match the expected role, STOP.",
        ],
    },
    "redaction-security-review": {
        "description": "Sweep changes and tool output for secrets, "
                       "keychain IDs, token values, and raw service URLs.",
        "when_to_use": "Before every commit/PR and when reviewing any "
                       "diff.",
        "required_operations": ["gitea.read"],
        "status": "available",
        "steps": [
            "Run git diff --check for whitespace damage.",
            "Grep the diff for token/password/secret/keychain patterns; "
            "only synthetic fixtures may match.",
            "Confirm normal tool output contains no endpoint URLs or "
            "keychain IDs (reveal opt-in excepted).",
            "Confirm no live config or private machine paths are being "
            "committed.",
        ],
    },
    "jenkins-mcp": {
        "description": "Read-only Jenkins CI inspection (jobs, builds, "
                       "logs).",
        "when_to_use": "Checking CI state through a separately registered "
                       "jenkins-mcp server.",
        "required_operations": ["jenkins.read"],
        "status": "external-mcp",
        "notes": "Server code lives in mcp-control-plane as the separate "
                 "jenkins-mcp server. Register that server under the exact "
                 "jenkins-mcp name in the MCP client, reconnect/reload the "
                 "client, then verify Jenkins tools are visible before use. "
                 "Report SKIPPED if the server is absent or exposes no usable "
                 "tools. Do not substitute shell/API. Trigger requires a "
                 "dedicated write profile and confirmation (see #56).",
        "steps": [
            "Confirm a Jenkins MCP server is connected under the exact "
            "jenkins-mcp name.",
            "Verify tool discoverability: jenkins_whoami, "
            "jenkins_list_jobs, jenkins_latest_build, jenkins_build_status, "
            "and jenkins_get_build must be visible before claiming Jenkins "
            "readiness.",
            "If the server is enabled but exposes no usable tools, report "
            "SKIPPED and stop.",
            "Use read-only operations only; never trigger unless using a "
            "dedicated write profile plus exact confirmation.",
        ],
    },
    "glitchtip-mcp": {
        "description": "Read-only GlitchTip error/event inspection.",
        "when_to_use": "Investigating reported errors through a separately "
                       "registered glitchtip-mcp server.",
        "required_operations": ["glitchtip.read"],
        "status": "external-mcp",
        "notes": "Server code lives in mcp-control-plane as the separate "
                 "glitchtip-mcp server. Register that server under the exact "
                 "glitchtip-mcp name in the MCP client, reconnect/reload the "
                 "client, then verify GlitchTip tools are visible before use. "
                 "Report SKIPPED if the server is absent or exposes no usable "
                 "tools. Filing to Gitea is a separate orchestrator tracked "
                 "outside this server (see #57).",
        "steps": [
            "Confirm a GlitchTip MCP server is connected under the exact "
            "glitchtip-mcp name.",
            "Verify tool discoverability: glitchtip_whoami, "
            "glitchtip_list_projects, glitchtip_list_unresolved, "
            "glitchtip_get_issue, glitchtip_recent_events, and "
            "glitchtip_search must be visible before claiming GlitchTip "
            "readiness.",
            "If the server is enabled but exposes no usable tools, report "
            "SKIPPED and stop.",
            "Use read-only operations only; never mutate issues or settings. "
            "Filing is a separate orchestrator tracked in #57.",
        ],
    },
    "release-operator": {
        "description": "Release, tag, and version workflows.",
        "when_to_use": "Never by an LLM session on its own: releases and "
                       "tags are operator-owned.",
        "required_operations": [],
        "status": "operator-only",
        "notes": "See the release SOP docs. Hard stop: no tags or "
                 "releases without an explicit operator instruction.",
        "steps": [
            "Stop and hand off to the operator; do not create tags or "
            "releases.",
        ],
    },
}

# Canonical workflow router skill names for Claude/Codex/Gemini parity (#551).
_PROJECT_SKILLS.update(workflow_skill.workflow_skill_registry_entries())


@mcp.tool()
def mcp_get_control_plane_guide(
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Structured operator guide for the current Gitea MCP session (#128).

    Read-only. Call this first in a new session: it reports the active
    profile, the authenticated identity (fail-soft), what this profile may
    and may not do, and the non-negotiable workflow rules (hard stops,
    fail-closed behavior, head-SHA pinning, merge confirmation, redaction,
    author/reviewer separation, profile switching). Guidance is
    profile-aware; if identity cannot be resolved it instructs the session
    to STOP.

    Always resolve task capability (gitea_resolve_task_capability) before
    acting to determine required role/namespace. Issue comments require
    gitea.issue.comment (distinct from gitea.pr.comment); review/merge
    require reviewer namespace.

    Args:
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Optional owner/org override (explicit; recommended when the bare
            remote default differs from the local git remote).
        repo: Optional repository name override (use with org).

    Returns:
        dict with 'read_only', 'remote', 'profile' (name, operations,
        role_kind), 'identity' (username/status/instruction; 'server' only
        with the reveal opt-in), 'resolved_repository' (org/repo actually
        used after guard/local-context alignment), 'guidance'
        (profile-specific), 'rules', 'workflows', and 'skills_tool'.
        Never secrets.
    """
    h, o, r = _resolve_control_plane_guide_target(remote, host, org, repo)
    profile = get_profile()
    allowed = profile["allowed_operations"]
    forbidden = profile["forbidden_operations"]
    role = _role_kind(allowed, forbidden)
    username = _authenticated_username(h)

    identity = {
        "authenticated_username": username,
        "remote": remote,
        "status": "verified" if username else "unresolved",
        "instruction": (
            "Identity verified — confirm it matches the role this task "
            "requires before acting. If mismatched or unclear, call "
            "gitea_resolve_task_capability to check the correct profile or namespace."
            if username else
            "STOP: the authenticated identity could not be resolved. Do "
            "not perform any mutating operation; call gitea_resolve_task_capability "
            "to find the required profile, report to the operator, and wait."),
    }
    if _reveal_endpoints():
        identity["server"] = h

    guidance = [
        "ALWAYS resolve the task capability first: call "
        "gitea_resolve_task_capability (task=...) before any action. "
        "Do not hardcode identity or guess the launcher profile. "
        "Verify the active identity/profile matches the required role "
        "for the task. Use the correct namespace (author vs reviewer) "
        "as reported.",
        "When the bare remote default repo mismatches the local git remote "
        "(e.g. prgs→Timesheet vs Gitea-Tools), pass explicit "
        "org=Scaled-Tech-Consulting repo=Gitea-Tools to "
        "mcp_get_control_plane_guide and other tools that accept org/repo.",
    ]
    if not username:
        guidance.append(
            "STOP: identity unresolved — no mutating operations until the "
            "operator fixes credentials/profile. Call gitea_resolve_task_capability "
            "to check required profile/permissions, and verify via gitea_whoami.")
    if role == "author":
        guidance.append(
            "Author profile: creating branches, commits, issues, and PRs "
            "is allowed; review, approve, and merge are forbidden for this "
            "profile — never attempt them, and never review or merge your "
            "own PR from any profile. Issue comments require "
            "gitea.issue.comment (separate from gitea.pr.comment).")
    elif role == "reviewer":
        guidance.append(
            "Reviewer profile: review/approve may proceed ONLY after "
            "gitea_check_pr_eligibility passes and the PR head SHA is "
            "pinned (expected_head_sha); the PR author must be a different "
            "user. Reviewer profiles cannot merge PRs. "
            "PR comments do not imply issue comments. "
            "Review and merge are separate workflow roles. A reviewer approval is not merge authorization.")
    elif role == "merger":
        guidance.append(
            "Merger profile: merge may proceed ONLY after formal approval at "
            "current head, gitea_check_pr_eligibility passes, and the PR head "
            "SHA is pinned (expected_head_sha); the PR author must be a different "
            "user, and merging requires explicit operator authorization plus the "
            "'MERGE PR <n>' confirmation. "
            "Review and merge are separate workflow roles. A reviewer approval is not merge authorization.")
    elif role == "mixed":
        guidance.append(
            "WARNING: this profile allows both authoring and "
            "review/merge, which defeats two-party review. Treat it as "
            "misconfigured: STOP and report to the operator before any "
            "review or merge.")
    else:
        guidance.append(
            "Limited profile: no authoring and no review/merge "
            "operations are allowed. Read-only work only; anything else "
            "fails closed.")

    return {
        "read_only": True,
        "remote": remote,
        "resolved_repository": {
            "org": o,
            "repo": r,
        },
        "profile": {
            "name": profile["profile_name"],
            "allowed_operations": allowed,
            "forbidden_operations": forbidden,
            "role_kind": role,
        },
        "identity": identity,
        "guidance": guidance,
        "rules": _GUIDE_RULES,
        "workflows": _COMMON_WORKFLOWS,
        "skills_tool": "mcp_list_project_skills",
    }


@mcp.tool()
def mcp_list_project_skills() -> dict:
    """List the project's workflow skills and when to use them (#128, #551).

    Read-only; makes no API calls. Each skill reports its name,
    description, when-to-use guidance, required operations, status
    ('available', 'external-mcp', 'designed-not-implemented', or
    'operator-only'), and whether the current profile's operations permit it. Use
    mcp_get_skill_guide(name) for the step-by-step guide.

    Includes the canonical workflow router under gitea-workflow,
    llm-project-workflow, and git-pr-workflows (#551).

    Returns:
        dict with 'read_only', 'count', and 'skills' (list). Never secrets.
    """
    profile = get_profile()
    allowed = profile["allowed_operations"]
    forbidden = profile["forbidden_operations"]
    skills = []
    for name, meta in _PROJECT_SKILLS.items():
        ops = meta["required_operations"]
        available = all(
            gitea_config.check_operation(op, allowed, forbidden)[0]
            for op in ops
        ) if ops else True
        entry = {
            "name": name,
            "description": meta["description"],
            "when_to_use": meta["when_to_use"],
            "required_operations": ops,
            "status": meta["status"],
            "available_to_current_profile": (
                available and meta["status"] == "available"),
        }
        if meta.get("notes"):
            entry["notes"] = meta["notes"]
        if meta.get("repo_path"):
            entry["repo_path"] = meta["repo_path"]
        if meta.get("canonical"):
            entry["canonical"] = True
        skills.append(entry)
    return {"read_only": True, "count": len(skills), "skills": skills}


@mcp.tool()
def mcp_check_workflow_skill_preflight(
    project_root: str | None = None,
) -> dict:
    """Fail-closed preflight for the canonical workflow skill wall (#551).

    Verifies skills/llm-project-workflow/SKILL.md exists in the project tree
    and reports whether Codex has a matching mount under ~/.codex/skills.
    Call before git/Gitea mutations when the controller requires gitea-workflow.

    Args:
        project_root: Optional override; defaults to this MCP process root.

    Returns:
        dict with workflow_skill_ready, blocked, canonical_names, codex mount
        status, and safe_next_action. Never secrets.
    """
    root = (project_root or PROJECT_ROOT).strip()
    result = workflow_skill.assess_workflow_skill_presence(root)
    result["success"] = not result.get("blocked")
    result["project_root"] = root
    result["read_only"] = True
    return result


@mcp.tool()
def mcp_get_skill_guide(skill_name: str) -> dict:
    """Step-by-step guide for one named project skill (#128).

    Read-only; makes no API calls. Unknown skill names fail closed with
    the list of valid names instead of guessing.

    Args:
        skill_name: A name from mcp_list_project_skills (case-insensitive).

    Returns:
        dict with 'success', 'skill' metadata, and 'steps'; on an unknown
        name, 'success' False with 'reasons' and 'valid_skills'.
    """
    key = (skill_name or "").strip().lower()
    meta = _PROJECT_SKILLS.get(key)
    if meta is None:
        return {
            "success": False,
            "reasons": [f"unknown skill '{skill_name}' (fail closed)"],
            "valid_skills": sorted(_PROJECT_SKILLS),
        }
    skill = {
        "name": key,
        "description": meta["description"],
        "when_to_use": meta["when_to_use"],
        "required_operations": meta["required_operations"],
        "status": meta["status"],
    }
    if meta.get("notes"):
        skill["notes"] = meta["notes"]
    return {"success": True, "skill": skill, "steps": list(meta["steps"])}


@mcp.tool()
def gitea_whoami(
    remote: str = "dadeschools",
    host: str | None = None,
) -> dict:
    """Look up the Gitea account the MCP server is authenticated as.

    Read-only. Calls Gitea's authenticated-user endpoint (GET /api/v1/user)
    with the configured token and returns safe identity metadata only. Use
    this to prove which account a mutating workflow (e.g. review/merge) would
    act as, so self-review/self-merge can be detected before acting.

    Never returns the token, Authorization header, password, or any other
    secret material. Fails closed with a clear error if the identity cannot
    be determined.

    Args:
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.

    Returns:
        dict with 'authenticated', 'username', 'display_name', 'user_id',
        'email', 'server', 'remote', and 'profile' (safe runtime profile
        metadata: profile_name + allowed_operations; never the token).
    """
    record_preflight_check("whoami")
    remote = _effective_remote(remote)
    if remote not in REMOTES:
        raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")
    h = host or REMOTES[remote]["host"]
    auth = _auth(h)
    url = gitea_url(h, "/api/v1/user")
    data = api_request("GET", url, auth)
    if not data or not data.get("login"):
        # Fail closed: never assume an identity we could not verify.
        raise RuntimeError(
            f"Could not determine the authenticated Gitea identity for {h}. "
            "Verify the configured token is valid for this instance."
        )
    # Runtime profile metadata is non-secret (name + allowed op categories).
    # The token is resolved separately and is never included here. Endpoint
    # URLs stay out of normal LLM-facing output (#120): the logical remote
    # name is the addressing surface; 'server' appears only under the
    # GITEA_MCP_REVEAL_ENDPOINTS admin opt-in.
    profile = get_profile()
    identity = data.get("login")
    expected_username = (profile.get("username") or "").strip() or None
    # #714: seed immutable session context so later tools share one pin.
    _seed_session_context(
        profile=profile,
        remote=remote,
        host=h,
        identity=identity,
        source="gitea_whoami",
    )
    id_match = session_ctx.assess_identity_match(
        authenticated=identity, expected_username=expected_username
    )
    result = {
        "authenticated": True,
        "username": identity,
        "display_name": data.get("full_name") or None,
        "user_id": data.get("id"),
        "email": data.get("email") or None,
        "remote": remote,
        "profile": {
            "profile_name": profile["profile_name"],
            "allowed_operations": profile["allowed_operations"],
            "forbidden_operations": profile["forbidden_operations"],
            "environment": profile.get("environment"),
            "service": profile.get("service"),
            "identity": profile.get("identity"),
            "role": profile.get("role"),
            "profile_address": profile.get("profile_path"),
            "execution_profile": profile.get("execution_profile"),
            "audit_label": profile.get("audit_label"),
            "auth_source_type": profile.get("auth_source_type"),
            "expected_username": expected_username,
        },
        "session_context_audit": session_ctx.mutation_context_audit_fields(),
        "identity_match": not id_match.get("block"),
        "identity_match_reasons": id_match.get("reasons") or [],
    }
    if id_match.get("block"):
        _invalidate_preflight_identity_state()
    if _reveal_endpoints():
        result["server"] = gitea_url(h, "").rstrip("/")
    return result



@mcp.tool()
def gitea_get_authenticated_user(
    remote: str = "dadeschools",
    host: str | None = None,
) -> dict:
    """Alias for gitea_whoami. Look up the authenticated Gitea account."""
    return gitea_whoami(remote=remote, host=host)

@mcp.tool()
def gitea_get_current_user(
    remote: str = "dadeschools",
    host: str | None = None,
) -> dict:
    """Alias for gitea_whoami. Look up the authenticated Gitea account."""
    return gitea_whoami(remote=remote, host=host)


@mcp.tool()
def gitea_get_profile(
    remote: str = "dadeschools",
    host: str | None = None,
    resolve_identity: bool = True,
) -> dict:
    """Describe the active Gitea MCP execution profile for this runtime.

    Read-only. Reports the non-secret configuration of the running MCP
    process (profile name, allowed/forbidden operation categories, audit
    label, auth *status*). Endpoint URLs and token source names are hidden
    from normal output (#120) and appear only under the
    GITEA_MCP_REVEAL_ENDPOINTS admin opt-in. Optionally resolves the
    authenticated username via ``gitea_whoami``'s endpoint so an LLM can see
    who this runtime acts as.

    This tool never mutates Gitea and never approves, merges, comments, or
    creates anything. It never returns the token value, Authorization header,
    password, raw environment, or credential file paths. Identity resolution
    fails soft: if it cannot be determined, ``authenticated_username`` is null
    and ``identity_status`` marks it, but the profile config is still returned.

    Args:
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        resolve_identity: If True, attempt a read-only identity lookup.

    Returns:
        dict of safe profile metadata. ``identity_status`` is one of
        'verified', 'unknown', 'unavailable', or 'not_resolved'.
    """
    profile = get_profile()
    reveal = _reveal_endpoints()
    result = {
        "profile_name": profile["profile_name"],
        "allowed_operations": profile["allowed_operations"],
        "forbidden_operations": profile["forbidden_operations"],
        "audit_label": profile["audit_label"],
        "environment": profile.get("environment"),
        "service": profile.get("service"),
        "identity": profile.get("identity"),
        "role": profile.get("role"),
        "profile_address": profile.get("profile_path"),
        "execution_profile": profile.get("execution_profile"),
        "auth_source_type": profile.get("auth_source_type"),
        # Auth is reported as a status only (#120): the token source *name*
        "auth_status": ("configured" if profile["token_source_name"]
                        else "unconfigured"),
        "remote": remote if remote in REMOTES else None,
        "authenticated_username": None,
        "identity_status": "not_resolved",
    }
    if reveal:
        result["token_source_name"] = profile["token_source_name"]
        result["base_url"] = profile["base_url"]
        result["server"] = None

    remote = _effective_remote(remote)
    if remote not in REMOTES:
        # Mark ambiguity rather than raising: the tool stays inspectable.
        result["identity_status"] = "unknown"
        result["remote_error"] = f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}"
        return result

    h = host or REMOTES[remote]["host"]
    if reveal:
        result["server"] = gitea_url(h, "").rstrip("/")

    if resolve_identity:
        try:
            auth = _auth(h)
            data = api_request("GET", gitea_url(h, "/api/v1/user"), auth)
            login = (data or {}).get("login")
            if login:
                result["authenticated_username"] = login
                result["identity_status"] = "verified"
            else:
                result["identity_status"] = "unknown"
        except Exception:
            # Fail soft for the identity field only. Never surface the error
            # detail or any credential material — just mark it unavailable.
            result["identity_status"] = "unavailable"

    return result


_RUNTIME_CAPABILITY_TASKS = (
    "create_issue",
    "comment_issue",
    "create_pr",
    "work_issue",
    "review_pr",
    "merge_pr",
    "close_issue",
    "reconcile_already_landed_pr",
)


def _matching_configured_profiles(
    config: dict | None,
    required_permission: str,
    remote: str | None = None,
    required_role_kind: str | None = None,
) -> list[str]:
    """Profile names that allow *required_permission* (redacted metadata only).

    #714: when *remote* is provided, only profiles bound to that remote's host
    are returned — a dadeschools request never lists prgs profiles.  For a
    role-exclusive task, a profile that declares a role must also declare the
    required role (#723); undeclared legacy profiles retain permission-only
    compatibility.
    """
    if not config or "profiles" not in config:
        return []
    contexts = config.get("contexts") or {}
    remote_ok_names: set[str] | None = None
    if remote:
        remote_ok_names = set(
            session_ctx.filter_profiles_for_remote(config, remote, REMOTES)
        )
    matches: list[str] = []
    for p_name, p_data in config["profiles"].items():
        if not p_data.get("enabled", True):
            continue
        if remote_ok_names is not None and p_name not in remote_ok_names:
            continue
        # Also require host/context match when remote given (defensive).
        if remote and not session_ctx.profile_matches_remote(
            p_data, remote, REMOTES, contexts=contexts
        ):
            continue
        p_allowed = p_data.get("allowed_operations") or []
        p_forbidden = p_data.get("forbidden_operations") or []
        p_allowed_n = []
        for op in p_allowed:
            try:
                p_allowed_n.append(gitea_config.normalize_operation(op))
            except Exception:
                pass
        p_forbidden_n = []
        for op in p_forbidden:
            try:
                p_forbidden_n.append(gitea_config.normalize_operation(op))
            except Exception:
                pass
        ok, _ = gitea_config.check_operation(
            required_permission, p_allowed_n, p_forbidden_n
        )
        declared_role = (
            p_data.get("role") or p_data.get("role_kind") or ""
        ).strip()
        role_allowed = (
            required_role_kind is None
            or not declared_role
            or declared_role == required_role_kind
        )
        if ok and role_allowed:
            matches.append(p_name)
    return sorted(matches)


def _build_runtime_task_capabilities(
    allowed: list[str],
    forbidden: list[str],
    config: dict | None,
    remote: str | None = None,
    active_role_kind: str | None = None,
) -> dict:
    """Per-task capability summary for role-aware runtime context.

    A declared active role produces a ``role_filtered`` view consistent with
    the resolver.  Callers that deliberately omit the role receive the legacy
    ``permission_only`` view, labeled as such rather than implying stronger
    authority (#723).
    """
    task_entries = []
    flags: dict[str, bool] = {}
    flag_keys = {
        "create_issue": "can_create_issues",
        "comment_issue": "can_comment_on_issues",
        "create_pr": "can_author_prs",
        "work_issue": "can_work_issues",
        "review_pr": "can_review_prs",
        "merge_pr": "can_merge_prs",
        "close_issue": "can_close_issues",
        "reconcile_already_landed_pr": "can_reconcile_already_landed_prs",
    }
    role_filter_applied = active_role_kind is not None
    for task in _RUNTIME_CAPABILITY_TASKS:
        permission = task_capability_map.required_permission(task)
        required_role_kind = task_capability_map.required_role(task)
        role_exclusive = task in task_capability_map.ROLE_EXCLUSIVE_TASKS
        permission_allowed, _ = gitea_config.check_operation(
            permission, allowed, forbidden
        )
        role_allowed = (
            not role_exclusive
            or not role_filter_applied
            or active_role_kind == required_role_kind
        )
        allowed_here = permission_allowed and role_allowed
        entry = {
            "task": task,
            "required_permission": permission,
            "required_role_kind": required_role_kind,
            "role_exclusive": role_exclusive,
            "capability_view": (
                "role_filtered" if role_filter_applied else "permission_only"
            ),
            "allowed_in_current_session": allowed_here,
            "matching_configured_profiles": _matching_configured_profiles(
                config,
                permission,
                remote=remote,
                required_role_kind=(
                    required_role_kind if role_exclusive else None
                ),
            ),
        }
        task_entries.append(entry)
        flag_name = flag_keys.get(task)
        if flag_name:
            flags[flag_name] = allowed_here
    return {
        **flags,
        "issue_comment_not_implied_by_pr_comment": True,
        "task_capabilities": task_entries,
    }


@mcp.tool()
def gitea_assess_review_merge_state_machine(
    target_state: str | None = None,
    state_completion: dict[str, bool] | None = None,
    pre_merge_gates: dict[str, bool] | None = None,
    infra_stop: bool = False,
    capability_blocked: bool = False,
    live_namespace_broken: bool = False,
    recovery_handoff_text: str | None = None,
    final_report_text: str | None = None,
) -> dict:
    """Read-only: assess enforced PR review/merge workflow state (#290).

    ``live_namespace_broken`` fails review/merge closed when the live MCP
    namespace call path is unusable even though the tool is registered in
    FastMCP (#543 AC5). Supply the ``blocks_merge_workflow`` verdict from
    ``gitea_assess_mcp_namespace_health``.
    """
    completion = state_completion or {}
    blocker_kwargs = {
        "infra_stop": infra_stop,
        "capability_blocked": capability_blocked,
        "live_namespace_broken": live_namespace_broken,
    }
    blockers = review_merge_state_machine.assess_workflow_blockers(**blocker_kwargs)
    result = {
        "workflow": review_merge_state_machine.workflow_status(
            completion,
            **blocker_kwargs,
        ),
        "blockers": blockers,
        "approve": review_merge_state_machine.can_approve(
            completion,
            **blocker_kwargs,
        ),
        "merge": review_merge_state_machine.can_merge(
            completion,
            pre_merge_gates=pre_merge_gates,
            **blocker_kwargs,
        ),
    }
    if target_state:
        result["advancement"] = review_merge_state_machine.assess_state_advancement(
            completion,
            target_state=target_state,
            **blocker_kwargs,
        )
    if recovery_handoff_text is not None:
        result["recovery_handoff"] = (
            review_merge_state_machine.assess_blocked_recovery_handoff(
                recovery_handoff_text
            )
        )
    if final_report_text is not None:
        result["final_report_claims"] = (
            review_merge_state_machine.assess_final_report_state_claims(
                final_report_text,
                state_completion=completion,
                approve_completed=bool(completion.get("APPROVE_OR_REQUEST_CHANGES")),
                merge_completed=bool(completion.get("MERGE")),
            )
        )
    return result


@mcp.tool()
def gitea_assess_gitea_operation_path(
    task: str,
    path_kind: str | None = None,
    command_or_detail: str | None = None,
    mcp_available: bool = True,
    mcp_tool_visible: bool = True,
    recovery_mode: bool = False,
    recovery_proof_complete: bool = False,
    remote: str = "dadeschools",
    host: str | None = None,
) -> dict:
    """Read-only: assess whether a proposed Gitea mutation path is allowed (#270).

    Native MCP must be preferred when available. Shell scripts, direct API
    calls, WebFetch, Playwright, and helper encoders are blocked unless
    explicit recovery-mode proof is supplied.
    """
    profile = get_profile()
    role = _role_kind(
        profile.get("allowed_operations") or [],
        profile.get("forbidden_operations") or [],
    )
    return native_mcp_preference.assess_gitea_operation_path(
        task=task,
        path_kind=path_kind,
        command_or_detail=command_or_detail,
        mcp_available=mcp_available,
        mcp_tool_visible=mcp_tool_visible,
        recovery_mode=recovery_mode,
        recovery_proof_complete=recovery_proof_complete,
        role=role,
        active_profile=profile.get("profile_name"),
    )


@mcp.tool()
def gitea_record_shell_spawn_outcome(
    exit_code: int | None = None,
    stdout: str = "",
    stderr: str = "",
    spawn_failure: bool | None = None,
    probe_attempted: bool = False,
    probe_succeeded: bool | None = None,
) -> dict:
    """Record shell spawn outcome and update the session circuit breaker (#270)."""
    return native_mcp_preference.record_shell_spawn_outcome(
        exit_code=exit_code,
        stdout=stdout,
        stderr=stderr,
        spawn_failure=spawn_failure,
        probe_attempted=probe_attempted,
        probe_succeeded=probe_succeeded,
    )


@mcp.tool()
def gitea_get_shell_health() -> dict:
    """Read-only: return shell spawn circuit-breaker state for this MCP session (#270)."""
    return native_mcp_preference.shell_health_status()


@mcp.tool()
def gitea_diagnose_terminal(
    cwd: str | None = None,
    command: list[str] | None = None,
) -> dict:
    """Read-only: Run active diagnostics on terminal launcher spawn ability (#556).

    Args:
        cwd: Active worktree or current directory to test (defaults to GITEA_ACTIVE_WORKTREE).
        command: Test command to attempt (defaults to ['git', '--version'] or ['echo', 'ok']).
    """
    resolved_cwd = cwd or os.environ.get("GITEA_ACTIVE_WORKTREE")
    if resolved_cwd:
        resolved_cwd = os.path.realpath(resolved_cwd)

    probe_cmd = command or native_mcp_preference.default_terminal_probe_command()

    diag = native_mcp_preference.diagnose_terminal_failure(resolved_cwd, probe_cmd)
    probe_res = native_mcp_preference.probe_terminal_spawn(resolved_cwd, probe_cmd)

    return {
        "healthy": probe_res["healthy"],
        "error_type": (
            None
            if probe_res["healthy"]
            else probe_res["error_type"] or diag["error_type"]
        ),
        "error_msg": (
            ""
            if probe_res["healthy"]
            else probe_res["error_msg"] or diag["error_msg"]
        ),
        "cwd": resolved_cwd,
        "command": probe_cmd,
        "diagnostics": diag,
    }


@mcp.tool()
def gitea_record_stable_branch_push_attempt(
    command: str | None = None,
    remote: str = "dadeschools",
    ref: str | None = None,
    session_id: str | None = None,
    mark: bool = True,
    current_branch: str | None = None,
    head_sha: str | None = None,
    remote_master_sha: str | None = None,
    is_under_branches: bool | None = None,
    ahead_count: int | None = None,
) -> dict:
    """Classify a proposed command for direct stable-branch push intent (#671).

    Worker sessions must never publish stable branches (``master``/``main``/
    ``dev``/...) directly. This tool detects ``git push <remote> master``
    equivalents (refspecs, ``HEAD:master``, ``--force``, ``--dry-run`` no-op
    intent, ``:master`` delete) and — separately — a root/control-checkout
    local commit not carried by an issue feature branch.

    When ``mark`` is true and contamination is detected, a durable
    ``stable_branch_contamination`` marker is written for the active profile
    identity. Subsequent review/merge/close/completion mutations then fail
    closed (via the pre-flight gate) until a reconciler audits and clears it.
    The stored command summary is redacted; secrets never persist.

    Read-only when nothing is detected (or ``mark`` is false). Returns the
    classification, any root-checkout assessment, and the marker state.
    """
    classification = stable_branch_push_guard.classify_push_command(command)

    root_checkout = None
    if any(v is not None for v in (current_branch, head_sha, remote_master_sha,
                                   is_under_branches, ahead_count)):
        root_checkout = stable_branch_push_guard.assess_root_checkout_local_commit(
            current_branch=current_branch,
            head_sha=head_sha,
            remote_master_sha=remote_master_sha,
            is_under_branches=bool(is_under_branches),
            ahead_count=ahead_count,
        )

    push_contam = bool(classification.get("contamination"))
    root_contam = bool(root_checkout and root_checkout.get("contamination"))
    contaminated = push_contam or root_contam

    marker = None
    marked = False
    if contaminated and mark:
        if push_contam:
            reason_class = "stable_branch_push"
            command_redacted = classification.get("redacted_command")
            detail = "; ".join(classification.get("reasons") or [])
            resolved_ref = ref or (
                (classification.get("stable_refs") or [None])[0]
            )
        else:
            reason_class = "root_checkout_commit"
            command_redacted = None
            detail = "; ".join(root_checkout.get("reasons") or [])
            resolved_ref = ref or root_checkout.get("current_branch")
        record = stable_branch_push_guard.build_contamination_record(
            reason_class=reason_class,
            command_redacted=command_redacted,
            session_id=session_id,
            remote=remote,
            ref=resolved_ref,
            role=_actual_profile_role(),
            detail=detail,
        )
        marker = _save_stable_contamination_marker(record, remote=remote)
        marked = marker is not None

    return {
        "classification": classification,
        "root_checkout": root_checkout,
        "contaminated": contaminated,
        "marked": marked,
        "marker": marker,
        "profile_identity": _stable_contamination_profile_identity(),
        "remediation": stable_branch_push_guard.REMEDIATION if contaminated else None,
    }


@mcp.tool()
def gitea_audit_stable_branch_contamination(
    action: str = "inspect",
    remote: str = "dadeschools",
    profile_identity: str | None = None,
) -> dict:
    """Reconciler audit of a stable-branch contamination marker (#671).

    ``action='inspect'`` (default, read-only) loads the durable marker for the
    given ``profile_identity`` (or the active session's) and reports it.

    ``action='clear'`` removes the marker so the contaminated session may
    resume gated mutations. Clearing is the reconciler audit path and requires
    an active reconciler profile — a worker session must never self-clear
    (#671 security requirement). ``profile_identity`` targets the contaminated
    worker's marker (a reconciler runs under its own identity).
    """
    act = (action or "inspect").strip().lower()
    target_identity = (profile_identity or "").strip() or _stable_contamination_profile_identity()

    marker = mcp_session_state.load_state(
        kind=mcp_session_state.KIND_STABLE_BRANCH_CONTAMINATION,
        remote=remote,
        profile_identity=target_identity,
    )

    if act == "inspect":
        return {
            "action": "inspect",
            "profile_identity": target_identity,
            "contaminated": marker is not None,
            "marker": marker,
            "read_only": True,
        }

    if act == "clear":
        role = _actual_profile_role()
        if role != "reconciler":
            return {
                "action": "clear",
                "success": False,
                "performed": False,
                "profile_identity": target_identity,
                "reasons": [
                    "stable-branch contamination may only be cleared by a "
                    f"reconciler audit; active role is '{role}' (fail closed). "
                    "The contaminated worker session must not self-clear."
                ],
            }
        _clear_stable_contamination_marker(
            remote=remote,
            profile_identity=target_identity,
        )
        return {
            "action": "clear",
            "success": True,
            "performed": True,
            "profile_identity": target_identity,
            "was_contaminated": marker is not None,
        }

    return {
        "action": act,
        "success": False,
        "performed": False,
        "reasons": [f"unknown action '{act}'; use 'inspect' or 'clear'"],
    }


@mcp.tool()
def gitea_validate_review_final_report(
    report_text: str,
    review_decision_lock: dict | None = None,
    linked_issue_lock: dict | None = None,
    validation_report: dict | None = None,
    action_log: list[dict] | None = None,
    mutations_observed: bool = False,
    local_edits: bool = False,
    validation_session: dict | None = None,
) -> dict:
    """Read-only: machine-validate reviewer final report schema before output (#391).

    Composes the composable final-report validator with review-specific schema
    gates (legacy fields, mutation categories, merge/issue claims, blocked
    handoff replay, and narrative/handoff consistency).
    """
    from review_final_report_schema import assess_review_final_report_schema

    return assess_review_final_report_schema(
        report_text,
        review_decision_lock=review_decision_lock,
        linked_issue_lock=linked_issue_lock,
        validation_report=validation_report,
        action_log=action_log,
        mutations_observed=mutations_observed,
        local_edits=local_edits,
        validation_session=validation_session,
    )


@mcp.tool()
def gitea_get_runtime_context(
    remote: str = "dadeschools",
    host: str | None = None,
    worktree_path: str | None = None,
) -> dict:
    """Read-only: explicit visibility into active profile, configuration model, and eligibility.

    Reports config model shape, profile mode (static vs dynamic), and detailed blocks.

    Args:
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
    """
    # #739 F1: normalize the remote *before* any host lookup, identity
    # resolution, or session seeding. ``remote`` defaults to 'dadeschools'
    # across the whole tool surface, so a caller that omits it on a prgs-hosted
    # namespace would otherwise pin the argument default: the identity lookup
    # below would authenticate against the wrong host, and because first-bind is
    # first-write-wins a later, correct ``gitea_whoami(remote="prgs")`` could not
    # repair the binding. ``gitea_whoami`` has always normalized here; this is
    # the same call, not a new policy. Explicit non-default remotes pass through
    # untouched, and a dadeschools-hosted profile still resolves to dadeschools.
    remote = _effective_remote(remote)
    profile = get_profile()
    config = gitea_config.load_config()
    reveal = _reveal_endpoints()

    # Determine config model/version
    if config is None:
        config_model = "env-only"
    elif config.get("version") == 1:
        config_model = "v1"
    elif config.get("version") == 2:
        if "contexts" in config or config.get("shape") == "contexts":
            config_model = "v2-contexts"
        else:
            config_model = "v2-environments"
    else:
        config_model = f"unknown-version-{config.get('version')}"

    # Determine profile source
    if os.environ.get("GITEA_PROFILE_NAME"):
        profile_source = "env var"
    elif gitea_config.selected_profile_name():
        profile_source = "config file profile"
    else:
        profile_source = "default"

    h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
    username = _authenticated_username(h) if h else None

    switching = gitea_config.is_runtime_switching_enabled()
    profile_mode = "dynamic-profile" if switching else "static-profile"

    # Evaluate review/merge eligibility
    allowed = profile["allowed_operations"] or []
    forbidden = profile["forbidden_operations"] or []

    # Check approve
    approve_ok, _ = gitea_config.check_operation("approve", allowed, forbidden)
    # Check merge
    merge_ok, _ = gitea_config.check_operation("merge", allowed, forbidden)

    review_merge_allowed = False
    blocked_reasons = []
    suggested_fix = "none"

    if not username:
        blocked_reasons.append("Authenticated identity is unresolved. Gitea credentials are missing or invalid.")
        suggested_fix = "operator action"
    elif not (approve_ok or merge_ok):
        blocked_reasons.append(
            f"Active profile '{profile['profile_name']}' does not permit review or merge operations."
        )
        if switching:
            suggested_fix = "profile switch"
        else:
            suggested_fix = "reviewer namespace"
    else:
        review_merge_allowed = True

    # Note that self-review and self-merge are blocked regardless of profile roles
    blocked_reasons.append(
        "Note: self-review and self-merge are always blocked at runtime for any PR authored by the active user."
    )

    safe_next_action = "None; ready for operations."
    if suggested_fix == "operator action":
        safe_next_action = f"Ask the operator to configure valid credentials/token for profile '{profile['profile_name']}'."
    elif suggested_fix == "profile switch":
        safe_next_action = "Switch to a reviewer profile by calling gitea_activate_profile with a reviewer profile."
    elif suggested_fix == "reviewer namespace":
        safe_next_action = (
            "Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, "
            "or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."
        )

    # #714: filter matching profiles by requested remote when building capability summary
    session_capabilities = _build_runtime_task_capabilities(
        allowed,
        forbidden,
        config,
        remote=remote if remote in REMOTES else None,
        active_role_kind=_profile_role_kind(profile),
    )

    preflight = assess_preflight_status(worktree_path)
    if not preflight["preflight_ready"]:
        safe_next_action = (
            "Complete pre-flight verification before mutating: call gitea_whoami, then "
            "gitea_resolve_task_capability for the intended task. "
            f"Blocked: {'; '.join(preflight['preflight_block_reasons'])}"
        )

    expected_username = (profile.get("username") or "").strip() or None
    h_rt = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
    _seed_session_context(
        profile=profile,
        remote=remote if remote in REMOTES else None,
        host=h_rt,
        identity=username,
        source="gitea_get_runtime_context",
    )

    result = {
        "active_profile": profile["profile_name"],
        "authenticated_username": username,
        "remote": remote if remote in REMOTES else None,
        "config_model": config_model,
        "profile_source": profile_source,
        "allowed_operations": allowed,
        "forbidden_operations": forbidden,
        "runtime_switching_supported": switching,
        "profile_mode": profile_mode,
        "session_context_audit": session_ctx.mutation_context_audit_fields(),
        "auto_profile_substitution": False,
        "review_merge_allowed": review_merge_allowed,
        "review_merge_blocked_reasons": blocked_reasons,
        "suggested_fix": suggested_fix,
        "safe_next_action": safe_next_action,
        "preflight_ready": preflight["preflight_ready"],
        "preflight_block_reasons": preflight["preflight_block_reasons"],
        "preflight_workspace": preflight.get("preflight_workspace"),
        "session_capabilities": session_capabilities,
        "reconciler_profile_assessment": reconciler_profile.assess_reconciler_profile(
            allowed, forbidden
        ),
        "role_kind": _role_kind(allowed, forbidden),
        "shell_health": native_mcp_preference.shell_health_status(),
        "workflow_load_proof": review_workflow_load.workflow_load_status(
            PROJECT_ROOT),
    }

    # #702: read-only visibility into the inherited GITEA_ACTIVE_WORKTREE
    # binding; recovery itself runs during capability resolution.
    try:
        stale_binding = _assess_stale_active_binding(auto_recover=False)
        if stale_binding.get("classification") != (
            stale_binding_recovery.CLASSIFICATION_UNBOUND
        ):
            result["stale_binding_recovery"] = stale_binding
    except Exception:
        pass

    # #615: runtime mode + SHA reporting, so a session can tell whether it is
    # talking to the promoted stable control runtime before it mutates.
    try:
        runtime_mode_report = _current_runtime_mode_report(refresh=True)
        result["stable_control_runtime"] = runtime_mode_report
        result["stable_control_runtime"]["summary"] = (
            stable_control_runtime.format_runtime_mode(runtime_mode_report)
        )
        # The advisory only replaces safe_next_action when the gate is live for
        # this process; under test isolation the report stays informational.
        gate_live = not _RUNTIME_MODE_GATE_UNDER_TEST or (
            workflow_scope_guard.production_guards_forced()
        )
        if gate_live and not runtime_mode_report["real_mutations_allowed"]:
            result["safe_next_action"] = (
                "Runtime is not the promoted stable control runtime "
                f"({stable_control_runtime.format_runtime_mode(runtime_mode_report)}); "
                "real mutations are blocked. Operator promotion is required — "
                "see docs/stable-runtime-promotion-runbook.md."
            )
    except Exception:
        pass

    parity = _current_master_parity()
    result["master_parity"] = {
        "in_parity": parity["in_parity"],
        "stale": parity["stale"],
        "restart_required": parity["restart_required"],
        "startup_head": parity["startup_head"],
        "current_head": parity["current_head"],
        "summary": master_parity_gate.format_parity(parity),
        "mutation_gate_enforced": not master_parity_gate.gate_disabled(),
    }
    if parity["stale"] and not master_parity_gate.gate_disabled():
        safe_next_action = (
            "Server code is stale relative to master; restart the Gitea MCP "
            "server to load current capability gates before mutating. "
            f"({master_parity_gate.format_parity(parity)})"
        )
        result["safe_next_action"] = safe_next_action

    if reveal and h:
        result["server"] = gitea_url(h, "").rstrip("/")

    return result


@mcp.tool()
def gitea_assess_master_parity(
    remote: str = "dadeschools",
    host: str | None = None,
) -> dict:
    """Read-only: is the running server code in parity with the on-disk master?

    The MCP server loads its capability-gate code into memory at startup;
    ``master`` advancing (e.g. a newly merged security gate) does not take
    effect until the process restarts. This tool compares the commit the
    process started at against the current workspace ``HEAD`` and reports
    whether a restart is required before mutations are safe again (#420).

    Never mutates and makes no network calls. Read-only operations are never
    blocked by staleness; only mutating operations fail closed while stale.

    The top-level ``startup_head``/``current_head``/``in_parity`` fields keep
    their original meaning: the *Gitea-Tools server implementation* only. #739
    F3 adds two separately labelled dimensions so a cross-repository namespace
    can tell them apart — ``server_implementation`` (this installation, restated
    under an explicit name) and ``target_repository`` (the configured canonical
    target checkout and its last-known remote master). Only the server dimension
    gates mutations.

    Returns:
        dict with 'in_parity', 'stale', 'restart_required', 'startup_head',
        'current_head', 'mutation_gate_enforced', 'summary', 'reasons',
        'server_implementation', 'target_repository', and a 'report' recovery
        payload when stale.
    """
    parity = _current_master_parity()
    enforced = not master_parity_gate.gate_disabled()
    canonical_root, canonical_source = _configured_canonical_root()
    out = {
        "in_parity": parity["in_parity"],
        "stale": parity["stale"],
        "restart_required": parity["restart_required"],
        "determinable": parity["determinable"],
        "startup_head": parity["startup_head"],
        "current_head": parity["current_head"],
        "mutation_gate_enforced": enforced,
        "summary": master_parity_gate.format_parity(parity),
        "reasons": parity["reasons"],
        "process_root": PROJECT_ROOT,
        "server_implementation": {
            "installation_root": PROJECT_ROOT,
            "startup_head": parity["startup_head"],
            "current_head": parity["current_head"],
            "stale": parity["stale"],
            "determinable": parity["determinable"],
        },
        "target_repository": master_parity_gate.assess_target_repository_parity(
            canonical_root=canonical_root,
            source=canonical_source,
        ),
    }
    if parity["stale"] and enforced:
        out["report"] = master_parity_gate.parity_report(parity)
    return out
def gitea_record_pre_review_command(
    command: str,
    cwd: str | None = None,
    classification: str | None = None,
) -> dict:
    """Classify and record a command executed before workflow load (#403).

    Read-only with respect to Gitea API. Pre-review inventory/diagnostic commands
    may be recorded as allowed; boundary violations block reviewer mutations.
    """
    recorded = review_workflow_boundary.record_pre_review_command(
        command,
        cwd=cwd,
        project_root=PROJECT_ROOT,
        classification=classification,
    )
    boundary_state = review_workflow_boundary.assess_boundary_status(PROJECT_ROOT)
    return {
        "success": True,
        "recorded": recorded,
        "boundary_status": boundary_state.get("boundary_status"),
        "boundary_clean": boundary_state.get("boundary_clean"),
        "reasons": list(boundary_state.get("reasons") or []),
    }


@mcp.tool()
def gitea_load_review_workflow(
    prompt_text: str | None = None,
) -> dict:
    """Load and record canonical review-merge workflow proof for this session (#389, #403).

    Read-only with respect to Gitea API; records in-process workflow source/hash
    proof and session boundary state required before reviewer review or merge
    mutations.
    """
    try:
        recorded = review_workflow_load.record_review_workflow_load(
            PROJECT_ROOT, prompt_text=prompt_text)
    except OSError as exc:
        return {
            "success": False,
            "loaded": False,
            "reasons": [str(exc)],
            "recovery_handoff": review_workflow_load.recovery_handoff_without_replay(),
        }
    boundary_reasons = review_workflow_boundary.boundary_blockers(PROJECT_ROOT)
    helper = review_workflow_boundary.workflow_load_helper_result(
        recorded, PROJECT_ROOT)
    return {
        "success": not boundary_reasons,
        "loaded": True,
        "workflow_source": recorded["workflow_source"],
        "task_mode": recorded["task_mode"],
        "workflow_hash": recorded["workflow_hash"],
        "workflow_version": recorded["workflow_version"],
        "final_report_schema_path": recorded["final_report_schema_path"],
        "final_report_schema_hash": recorded["final_report_schema_hash"],
        "prompt_conflicts_with_workflow": recorded[
            "prompt_conflicts_with_workflow"],
        "prompt_conflict_reasons": recorded.get("prompt_conflict_reasons") or [],
        "workflow_load_proof_present": True,
        "boundary_status": recorded.get("boundary_status"),
        "boundary_clean": recorded.get("boundary_clean"),
        "workflow_load_helper_result": helper,
        "reasons": boundary_reasons,
        "recovery_handoff": (
            review_workflow_load.recovery_handoff_without_replay()
            if boundary_reasons else []
        ),
    }


@mcp.tool()
def gitea_list_profiles() -> dict:
    """Read-only: list all Gitea MCP profiles with redacted metadata.

    Exposes names, role kinds, enabled state, allowed/forbidden operations, and active status.
    Token values, keychain IDs, and raw URLs are hidden unless GITEA_MCP_REVEAL_ENDPOINTS=1 is enabled.
    """
    config = gitea_config.load_config()
    reveal = _reveal_endpoints()
    active_name = gitea_config.selected_profile_name() or get_profile()["profile_name"]

    profiles_out = []

    if config is None:
        # Env-only: return the active profile
        p = get_profile()
        role = _role_kind(p["allowed_operations"], p["forbidden_operations"])
        h = REMOTES.get("dadeschools", {}).get("host")  # default host
        username = _authenticated_username(h) if h else None

        prof = {
            "name": p["profile_name"],
            "role_kind": role,
            "allowed_operations": p["allowed_operations"],
            "forbidden_operations": p["forbidden_operations"],
            "identity_status": "verified" if username else "unresolved",
            "is_active": True,
            "enabled": True,
        }
        if reveal:
            prof["token_source_name"] = p["token_source_name"]
            prof["base_url"] = p["base_url"]
        profiles_out.append(prof)
    else:
        # Load from config profiles
        profiles_dict = config.get("profiles") or {}
        unavailable_dict = config.get("unavailable") or {}
        audit_only_profiles = config.get("audit_only_profiles") or {}

        # First, process available profiles
        for name, p in profiles_dict.items():
            role = _role_kind(p.get("allowed_operations", []), p.get("forbidden_operations", []))
            is_active = (name == active_name)

            # Identity status lookup
            if is_active:
                h = REMOTES.get("dadeschools", {}).get("host")  # default host
                username = _authenticated_username(h) if h else None
                identity_status = "verified" if username else "unresolved"
            else:
                # Safely resolve if credentials are present without networking
                try:
                    tok = gitea_config.resolve_token(p)
                    identity_status = "credentials present" if tok else "missing credentials"
                except Exception:
                    identity_status = "missing credentials"

            prof = {
                "name": name,
                "role_kind": role,
                "allowed_operations": p.get("allowed_operations", []),
                "forbidden_operations": p.get("forbidden_operations", []),
                "identity_status": identity_status,
                "is_active": is_active,
                "enabled": p.get("enabled", True),
            }
            if reveal:
                if isinstance(p.get("auth"), dict):
                    prof["auth"] = p["auth"]
                prof["base_url"] = p.get("base_url")
            else:
                if isinstance(p.get("auth"), dict):
                    prof["auth"] = {k: ("<redacted>" if k != "type" else v) for k, v in p["auth"].items()}
                prof["base_url"] = "<redacted>"
            profiles_out.append(prof)

        # Process unavailable/disabled/audit-only profiles
        for name, p in audit_only_profiles.items():
            role = _role_kind(p.get("allowed_operations", []), p.get("forbidden_operations", []))
            is_active = (name == active_name)
            prof = {
                "name": name,
                "role_kind": role,
                "allowed_operations": p.get("allowed_operations", []),
                "forbidden_operations": p.get("forbidden_operations", []),
                "identity_status": "unavailable (disabled)",
                "is_active": is_active,
                "enabled": p.get("enabled", True),
                "unavailable_reason": p.get("_unavailable_reason"),
            }
            if reveal:
                if isinstance(p.get("auth"), dict):
                    prof["auth"] = p["auth"]
                prof["base_url"] = p.get("base_url")
            else:
                if isinstance(p.get("auth"), dict):
                    prof["auth"] = {k: ("<redacted>" if k != "type" else v) for k, v in p["auth"].items()}
                prof["base_url"] = "<redacted>"
            profiles_out.append(prof)

        # Handle unavailable mappings that are not in audit_only
        for name, reason in unavailable_dict.items():
            if not any(x["name"] == name for x in profiles_out):
                profiles_out.append({
                    "name": name,
                    "role_kind": "limited",
                    "allowed_operations": [],
                    "forbidden_operations": [],
                    "identity_status": "unavailable",
                    "is_active": (name == active_name),
                    "enabled": False,
                    "unavailable_reason": reason,
                })

    return {"profiles": profiles_out}


@mcp.tool()
def gitea_assess_mcp_namespace_health(
    namespace: str,
    required_tool: str | None = None,
    registered_tools: list[str] | None = None,
    probe_result: dict | None = None,
    process: dict | None = None,
    config_path: str | None = None,
    profile: str | None = None,
    configured: bool = True,
    probe_source: str | None = None,
) -> dict:
    """Classify MCP namespace health for required Gitea tools (#543).

    Static FastMCP registration is not enough to prove a namespace works: IDE
    clients can keep a registered tool list while live calls fail with
    ``client is closing: EOF``. Pass live IDE invocation evidence with
    ``probe_source='client_namespace'``. Offline subprocess probes
    (``test_mcp_conn.py``) must use ``probe_source='offline_spawn'`` and never
    count as IDE proof.

    Assessments are recorded in the session so live
    ``gitea_submit_pr_review`` / ``gitea_merge_pr`` can fail closed when a
    client-namespace probe reported unhealthy.
    """
    result = mcp_namespace_health.classify_namespace_probe(
        namespace,
        required_tool=required_tool,
        registered_tools=registered_tools,
        probe_result=probe_result,
        process=process,
        config_path=config_path,
        profile=profile,
        configured=configured,
        probe_source=probe_source,
    )
    _record_live_namespace_health(result)
    # #606: namespace-health watchdog check-in (best-effort, fail open).
    sentry_observability.monitor_checkin(
        "namespace_health",
        "ok" if result.get("healthy", result.get("callable", True)) else "error",
    )
    return result


@mcp.tool()
def gitea_activate_profile(
    profile_name: str,
    remote: str = "dadeschools",
    host: str | None = None,
) -> dict:
    """Gated profile activation. Switch the active profile in-place for this runtime session.

    Only allowed if "allow_runtime_switching": true is explicitly configured.

    Args:
        profile_name: Profile to activate.
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
    """
    if not gitea_config.is_runtime_switching_enabled():
        return {
            "success": False,
            "message": (
                "Runtime profile switching is disabled in this configuration. Static profile mode is active. "
                "To enable switching, set 'allow_runtime_switching': true in the config rules."
            )
        }

    config = gitea_config.load_config()
    if not config:
        return {
            "success": False,
            "message": "No profiles configuration file is loaded. Switching is not supported in env-only mode."
        }

    profiles_dict = config.get("profiles") or {}
    if profile_name not in profiles_dict:
        return {
            "success": False,
            "message": f"Profile '{profile_name}' not found in loaded config."
        }

    h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)

    # 1. Record before state
    before_profile = get_profile()["profile_name"]
    before_identity = _authenticated_username(h) if h else None

    # 2. Perform switch
    gitea_config._active_profile_override = profile_name

    # 3. Clear identity cache to force a fresh verification
    if h:
        _IDENTITY_CACHE.pop(h, None)
        _ACTOR_IDENTITY_CACHE.pop(h, None)

    # 4. Resolve fresh identity
    after_profile_data = get_profile()
    after_profile = after_profile_data["profile_name"]
    after_identity = _authenticated_username(h) if h else None
    expected_username = (after_profile_data.get("username") or "").strip() or None

    # 4.5 Record the authorized pivot in the in-process mutation authority
    # and keep the session profile lock in sync — this is the ONLY path that
    # may authorize an author→reviewer role pivot.
    if _MUTATION_AUTHORITY is not None:
        _MUTATION_AUTHORITY["current_profile"] = after_profile
        _MUTATION_AUTHORITY["current_identity"] = after_identity
        _MUTATION_AUTHORITY["role_pivot_authorized"] = True
        _MUTATION_AUTHORITY["role_pivot_record"] = {
            "from_profile": before_profile,
            "to_profile": after_profile,
            "from_identity": before_identity,
            "to_identity": after_identity,
        }
        _MUTATION_AUTHORITY["remote"] = remote
    if os.environ.get(SESSION_PROFILE_LOCK_ENV) and after_profile:
        os.environ[SESSION_PROFILE_LOCK_ENV] = after_profile

    # 4.6 #714: re-bind immutable session context after explicit activation.
    # Activation is the sanctioned logical-session boundary, so it establishes
    # the complete binding — including the workspace-verified repository. An
    # unauthorized workspace fails closed here rather than at first mutation.
    activated_trusted = _trusted_session_repository(after_profile_data, remote, for_mutation=True)
    if activated_trusted.get("reasons"):
        return {
            "success": False,
            "performed": False,
            "reasons": list(activated_trusted["reasons"]),
            "blocker_kind": "repository_scope",
            "exact_next_action": (
                "BLOCKED + DIAGNOSE: the verified workspace repository is not "
                "authorized by this profile's allowed_repositories. Run from a "
                "workspace whose git remote matches an authorized "
                "owner/repository, or have the operator provision the profile "
                "scope. Do not pass org/repo to bypass this gate."
            ),
        }
    session_ctx.bind_session_context(
        profile_name=after_profile or profile_name,
        remote=remote,
        host=h,
        identity=after_identity,
        repository=activated_trusted["repository"],
        org=activated_trusted["org"],
        role_kind=_profile_role_kind(after_profile_data),
        expected_username=expected_username,
        source="gitea_activate_profile",
    )

    # 5. Audit the switch if auditing is on
    _audit(
        "activate_profile",
        host=h,
        remote=remote,
        result={
            "success": True,
            "before": before_profile,
            "after": after_profile,
            "session_context": session_ctx.mutation_context_audit_fields(),
        },
        username=after_identity,
    )

    return {
        "success": True,
        "message": f"Successfully activated profile '{profile_name}' (fresh identity verification complete).",
        "before_profile": before_profile,
        "before_identity": before_identity,
        "after_profile": after_profile,
        "after_identity": after_identity,
        "session_context_audit": session_ctx.mutation_context_audit_fields(),
        "auto_profile_substitution": False,
    }


@mcp.tool()
def gitea_audit_config() -> dict:
    """Audit the configured profiles/services: enabled state, no secrets.

    Read-only and local-only: loads the canonical profiles.json named by
    GITEA_MCP_CONFIG and reports profile/service names, contexts, enabled
    state, capabilities, auth *status*, and one-line service summaries (e.g.
    ``PRGS Jenkins: enabled, read-only, authenticated``). Disabled entries
    are listed so they can be audited, but the server refuses to act with
    them and never falls back to another profile or service.

    Never includes endpoint URLs, keychain ids, token source names, or token
    values. Endpoint-revealing diagnostics exist only in the local admin CLI
    (``python3 gitea_config.py audit --reveal-endpoints``), never over MCP.
    """
    config = gitea_config.load_config()
    if config is None:
        return {
            "configured": False,
            "message": "No GITEA_MCP_CONFIG configured; env-only mode.",
        }
    report = gitea_config.audit_config(config)
    report["configured"] = True
    report["summaries"] = gitea_config.service_summaries(config)
    return report


def _canonical_comment_gate(
    body: str,
    *,
    context: str | None = None,
    force_workflow: bool = False,
) -> dict:
    """Fail-closed canonical state validation before comment/review POST (#496)."""
    assessment = ccv.assess_canonical_comment(
        body,
        context=context,
        force_workflow=force_workflow,
    )
    if assessment.get("allowed"):
        return {
            "blocked": False,
            "canonical_comment_validation": assessment,
            "reasons": [],
        }
    correction = (assessment.get("correction_message") or "").strip()
    return {
        "blocked": True,
        "canonical_comment_validation": assessment,
        "reasons": [
            correction or "canonical comment validation failed (fail closed before posting)"
        ],
    }


def _post_structured_issue_comment(
    *,
    issue_number: int,
    body: str,
    remote: str,
    host: str | None,
    org: str | None,
    repo: str | None,
    audit_op: str = "create_issue_comment",
    comment_context: str | None = None,
) -> dict:
    """Post an issue-thread comment after permission gates already passed."""
    gate = _canonical_comment_gate(body, context=comment_context)
    if gate["blocked"]:
        return {
            "success": False,
            "performed": False,
            "issue_number": issue_number,
            "reasons": gate["reasons"],
            "canonical_comment_validation": gate["canonical_comment_validation"],
        }
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"
    with _audited(
        audit_op,
        host=h,
        remote=remote,
        org=o,
        repo=r,
        issue_number=issue_number,
        request_metadata={"body_chars": len(body)},
    ):
        data = api_request("POST", api, auth, {"body": body})
    result = {
        "success": True,
        "performed": True,
        "comment_id": data["id"],
        "issue_number": issue_number,
    }
    if _reveal_endpoints():
        result["url"] = data.get("html_url")
    return result


@mcp.tool()
def gitea_mark_issue(
    issue_number: int,
    action: str,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    worktree_path: str | None = None,
    branch_name: str | None = None,
    profile: str | None = None,
) -> dict:
    """Claim or release an issue via the status:in-progress label.

    This is the cross-agent lock mechanism. Check before starting work.

    Args:
        issue_number: The issue number.
        action: 'start' to claim (add label) or 'done' to release (remove label).
        remote: Known instance — 'dadeschools' or 'prgs'.
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.
        worktree_path: Active task worktree to inspect for pre-flight purity.

    Returns:
        dict with 'success' boolean and 'message'.
    """
    if action not in ("start", "done"):
        raise ValueError(f"action must be 'start' or 'done', got '{action}'")

    blocked = _profile_permission_block(
        task_capability_map.required_permission("mark_issue"),
        issue_number=issue_number,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
        org_explicit=org is not None,
        repo_explicit=repo is not None,
    )
    if blocked:
        return blocked
    # #735: forward explicit org/repo into shared anti-stomp preflight.
    verify_preflight_purity(
        remote,
        worktree_path=worktree_path,
        task="mark_issue",
        org=org,
        repo=repo,
    )
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    base = repo_api_url(h, o, r)

    label_ids_by_name = _repo_label_id_map(base, auth)
    if "status:in-progress" not in label_ids_by_name:
        raise RuntimeError(
            "Label 'status:in-progress' not found. "
            "Run manage_labels.py to create it first."
        )

    if action == "start":
        with _audited("label_issue", host=h, remote=remote, org=o, repo=r,
                      issue_number=issue_number, request_metadata={
                          "op": "replace-status",
                          "label": "status:in-progress",
                      }):
            transition = _transition_issue_status(
                base=base,
                auth=auth,
                issue_number=issue_number,
                status="status:in-progress",
                label_ids_by_name=label_ids_by_name,
            )
            if not transition.get("success"):
                raise RuntimeError("; ".join(transition.get("reasons") or []))
        active_profile = (profile or get_profile().get("profile_name") or "unknown")
        branch = (branch_name or "pending").strip() or "pending"
        heartbeat_body = issue_claim_heartbeat.format_heartbeat_body(
            kind="claim",
            issue_number=issue_number,
            branch=branch,
            phase="claimed",
            profile=active_profile,
            next_action="create worktree and begin implementation",
        )
        heartbeat = _post_structured_issue_comment(
            issue_number=issue_number,
            body=heartbeat_body,
            remote=remote,
            host=host,
            org=org,
            repo=repo,
            audit_op="claim_heartbeat",
        )
        return {
            "success": True,
            "message": f"Issue #{issue_number} claimed.",
            "heartbeat_posted": heartbeat.get("success", False),
            "heartbeat_comment_id": heartbeat.get("comment_id"),
            "status_transition": transition,
        }
    else:
        label_id = label_ids_by_name["status:in-progress"]
        with _audited("unlabel_issue", host=h, remote=remote, org=o, repo=r,
                      issue_number=issue_number,
                      request_metadata={"op": "remove", "label": "status:in-progress"}):
            api_request("DELETE",
                         f"{base}/issues/{issue_number}/labels/{label_id}", auth)
        return {"success": True, "message": f"Issue #{issue_number} released."}


@mcp.tool()
def gitea_post_heartbeat(
    issue_number: int,
    branch: str,
    phase: str,
    pr: str = "none",
    next_action: str = "none",
    blocker: str = "none",
    profile: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Post a structured progress heartbeat on a claimed issue (#268)."""
    blocked = _profile_permission_block(
        task_capability_map.required_permission("post_heartbeat"),
        issue_number=issue_number,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
        org_explicit=org is not None,
        repo_explicit=repo is not None,
    )
    if blocked:
        return blocked
    verify_preflight_purity(remote, task="post_heartbeat", org=org, repo=repo)
    active_profile = profile or get_profile().get("profile_name")
    body = issue_claim_heartbeat.format_heartbeat_body(
        kind="progress",
        issue_number=issue_number,
        branch=branch,
        phase=phase,
        profile=active_profile,
        pr=pr,
        next_action=next_action,
        blocker=blocker,
    )
    return _post_structured_issue_comment(
        issue_number=issue_number,
        body=body,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
        audit_op="progress_heartbeat",
    )


@mcp.tool()
def gitea_acquire_conflict_fix_lease(
    pr_number: int,
    branch: str,
    worktree_path: str,
    head_before: str,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Acquire a conflict-fix lease on a PR branch before pushing (#399)."""
    blocked = _profile_permission_block(
        task_capability_map.required_permission("comment_issue"),
        remote=remote,
        host=host,
        org=org,
        repo=repo,
        org_explicit=org is not None,
        repo_explicit=repo is not None,
    )
    if blocked:
        return blocked
    verify_preflight_purity(remote, worktree_path=worktree_path)
    comments = _list_pr_lease_comments(
        pr_number,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
    )
    reviewer_lease = pr_work_lease.find_active_reviewer_lease(
        comments, pr_number=pr_number)
    if reviewer_lease:
        return {
            "acquired": False,
            "reasons": [
                f"active reviewer lease on PR #{pr_number}; cannot acquire "
                "conflict-fix lease (fail closed)"
            ],
            "active_reviewer_lease": reviewer_lease,
        }
    profile_name = get_profile().get("profile_name") or "unknown"
    body = pr_work_lease.format_conflict_fix_lease_body(
        pr_number=pr_number,
        branch=branch,
        worktree=worktree_path,
        profile=profile_name,
        head_before=head_before,
        reviewer_active=bool(reviewer_lease),
    )
    posted = _post_structured_issue_comment(
        issue_number=pr_number,
        body=body,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
        audit_op="conflict_fix_lease_acquire",
    )
    return {
        "acquired": posted.get("success", False),
        "pr_number": pr_number,
        "branch": branch,
        "worktree_path": worktree_path,
        "head_before": head_before,
        "comment_id": posted.get("comment_id"),
        "active_reviewer_lease": reviewer_lease,
        "reasons": [] if posted.get("success") else ["lease comment post failed"],
    }


@mcp.tool()
def gitea_assess_conflict_fix_classification(
    pr_number: int,
    inventory_head_sha: str | None = None,
    inventory_mergeable: bool | None = None,
    live_head_sha: str | None = None,
    live_mergeable: bool | None = None,
) -> dict:
    """Read-only: classify conflict-fix need from live PR state (#522).

    Open PR inventory can be stale. Callers must pass a live re-fetched PR head
    and mergeability value before creating a conflict-fix worktree.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "performed": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }

    result = conflict_fix_classification.assess_conflict_fix_classification(
        pr_number=pr_number,
        inventory_head_sha=inventory_head_sha,
        inventory_mergeable=inventory_mergeable,
        live_head_sha=live_head_sha,
        live_mergeable=live_mergeable,
    )
    result["success"] = True
    result["performed"] = False
    return result


def _count_commits_behind(
    base_url: str,
    auth: dict,
    *,
    pr_head_sha: str,
    base_head_sha: str,
) -> int | None:
    """Return how many base commits are missing from the PR head, if knowable."""
    # compare old...new returns commits reachable from new not from old.
    # pr_head...base_head ≈ commits on base not in PR (behind count).
    try:
        cmp_url = (
            f"{base_url}/compare/{pr_head_sha}...{base_head_sha}"
        )
        data = api_request("GET", cmp_url, auth) or {}
        total = data.get("total_commits")
        if isinstance(total, int) and total >= 0:
            return total
        commits = data.get("commits")
        if isinstance(commits, list):
            return len(commits)
    except Exception:
        pass
    return None


def _matching_protection_rule(protections: Any, branch: str) -> dict | None:
    """Select the branch-protection rule governing ``branch`` (#751)."""
    if not isinstance(protections, list):
        return None
    for rule in protections:
        if not isinstance(rule, dict):
            continue
        name = (rule.get("branch_name") or rule.get("rule_name") or "").strip()
        # Exact match or glob-ish contains for common patterns.
        if name == branch or name in ("*", f"{branch}"):
            return rule
    # Fallback: any protection that mentions the base branch.
    for rule in protections:
        if not isinstance(rule, dict):
            continue
        name = (rule.get("branch_name") or rule.get("rule_name") or "").strip()
        if name and (branch in name or name.endswith(branch)):
            return rule
    return None


def _branch_protection_policy(
    base_url: str,
    auth: dict,
    *,
    base_branch: str,
) -> dict:
    """Read the live branch-protection policy for ``base_branch`` (#751).

    Exposes both the current-base rule (``block_on_outdated_branch``) and the
    status-check requirement (``enable_status_check`` / ``status_check_contexts``)
    from the same payload, so the checks assessor can tell "no checks required"
    apart from "required checks pending".

    ``determinable`` is False only when the policy genuinely could not be read
    (missing branch or API failure) — never merely because no rule exists. A
    successful read that finds no protection for the branch is authoritative
    evidence that status checks are not required.
    """
    policy: dict[str, Any] = {
        "determinable": False,
        "protection_found": False,
        "requires_current_base": None,
        "checks_enabled": None,
        "required_contexts": [],
        "base_branch": (base_branch or "").strip() or None,
    }
    branch = (base_branch or "").strip()
    if not branch:
        return policy

    def _apply(rule: dict) -> None:
        policy["protection_found"] = True
        if "block_on_outdated_branch" in rule:
            policy["requires_current_base"] = bool(
                rule.get("block_on_outdated_branch")
            )
        if "enable_status_check" in rule:
            policy["checks_enabled"] = bool(rule.get("enable_status_check"))
        contexts = rule.get("status_check_contexts")
        if isinstance(contexts, list):
            policy["required_contexts"] = [
                str(ctx).strip() for ctx in contexts if str(ctx or "").strip()
            ]

    try:
        protections = api_request(
            "GET", f"{base_url}/branch_protections", auth
        )
        rule = _matching_protection_rule(protections, branch)
        if rule is not None:
            _apply(rule)
        if not policy["protection_found"]:
            # Branch payload may embed effective protection.
            br = api_request("GET", f"{base_url}/branches/{branch}", auth) or {}
            prot = br.get("protection") or br.get("effective_branch_protection") or {}
            if isinstance(prot, dict) and prot:
                _apply(prot)
        policy["determinable"] = True
    except Exception:
        # Genuine read failure — leave determinable False so callers fail closed.
        return policy

    if not policy["protection_found"]:
        # Authoritative absence: no protection governs the branch, so no status
        # check is required by policy.
        policy["checks_enabled"] = False
    elif policy["checks_enabled"] is None:
        # Protection exists but omits the status-check field entirely: Gitea
        # only enforces contexts when the flag is set, so absence means off.
        policy["checks_enabled"] = False

    return policy


def _branch_protection_requires_current_base(
    base_url: str,
    auth: dict,
    *,
    base_branch: str,
) -> bool | None:
    """Read Gitea branch protection ``block_on_outdated_branch`` when present.

    Thin accessor over :func:`_branch_protection_policy`; return semantics are
    unchanged (``None`` when the rule is absent or unreadable).
    """
    policy = _branch_protection_policy(base_url, auth, base_branch=base_branch)
    return policy.get("requires_current_base")


def _commit_checks_snapshot(
    base_url: str,
    auth: dict,
    *,
    sha: str,
) -> dict:
    """Read the combined commit status *and its context collection* (#751).

    The combined ``state`` alone is not decisive: Gitea reports ``pending`` for
    a commit with an empty status-context collection, which is indistinguishable
    from executing CI unless the collection itself is inspected.
    """
    snapshot: dict[str, Any] = {
        "determinable": False,
        "combined_state": None,
        "statuses": [],
    }
    head = (sha or "").strip()
    if not head:
        return snapshot
    try:
        payload = api_request(
            "GET", f"{base_url}/commits/{head}/status", auth
        )
    except Exception:
        return snapshot
    if payload is None:
        return snapshot
    snapshot["determinable"] = True
    if not isinstance(payload, dict):
        return snapshot
    snapshot["combined_state"] = (payload.get("state") or "").strip().lower() or None
    statuses = payload.get("statuses")
    snapshot["statuses"] = statuses if isinstance(statuses, list) else []
    return snapshot


def _prove_author_ownership_for_pr(
    *,
    pr_number: int,
    pr_title: str | None,
    pr_body: str | None,
    source_branch: str | None,
    remote: str,
    host: str | None,
    org: str,
    repo: str,
    worktree_path: str | None = None,
) -> dict:
    """Prove author ownership for PR mutations without requiring issue==pr.

    Issue #727 / PR #728: tracking issues and PR numbers often differ. Ownership
    is proven via linked issues (title/body/branch), a live session lock for one
    of those issues (or the PR number itself), a durable issue lock for a linked
    issue, and optional branch/worktree binding on the lock. Unrelated issue
    locks fail closed.
    """
    reasons: list[str] = []
    text = f"{pr_title or ''}\n{pr_body or ''}"
    linked = list(
        extract_linked_issue_numbers(text, branch_name=source_branch)
    )
    # Always allow legacy same-number ownership when the lock is for the PR index.
    candidates = sorted(set(linked + [int(pr_number)]))
    remote_key = remote if remote in REMOTES else (host or "unknown")
    matched_issue: int | None = None
    matched_via: str | None = None
    lock_record: dict | None = None

    # 1) Session lock: active issue work lease for this session.
    try:
        session = issue_lock_store.read_session_issue_lock()
    except Exception:
        session = None
    if session and issue_lock_store.is_lease_live(session):
        try:
            sess_issue = int(session.get("issue_number") or 0)
        except (TypeError, ValueError):
            sess_issue = 0
        if sess_issue in candidates:
            # Optional branch binding: when both sides declare a branch, they must match.
            lock_branch = str(session.get("branch_name") or "").strip()
            src = (source_branch or "").strip()
            if lock_branch and src and lock_branch != src:
                reasons.append(
                    f"session lock for issue #{sess_issue} is bound to branch "
                    f"'{lock_branch}', not PR source branch '{src}' (fail closed)"
                )
            else:
                wt = (worktree_path or "").strip()
                lock_wt = str(session.get("worktree_path") or "").strip()
                if wt and lock_wt:
                    try:
                        same_wt = os.path.realpath(wt) == os.path.realpath(lock_wt)
                    except Exception:
                        same_wt = wt == lock_wt
                    if not same_wt:
                        reasons.append(
                            f"session lock for issue #{sess_issue} is bound to a "
                            "different worktree (fail closed)"
                        )
                    else:
                        matched_issue = sess_issue
                        matched_via = "session_lock"
                        lock_record = dict(session)
                else:
                    matched_issue = sess_issue
                    matched_via = "session_lock"
                    lock_record = dict(session)
        elif sess_issue:
            reasons.append(
                f"session lock issue #{sess_issue} is not linked to PR #{pr_number} "
                f"(linked={linked or 'none'}; fail closed)"
            )

    # 2) Durable per-issue locks for each ownership candidate.
    if matched_issue is None:
        for issue_n in candidates:
            try:
                durable = issue_lock_store.load_issue_lock(
                    remote=remote_key,
                    org=org,
                    repo=repo,
                    issue_number=issue_n,
                )
            except Exception:
                durable = None
            if not durable or not issue_lock_store.is_lease_live(durable):
                continue
            lock_branch = str(durable.get("branch_name") or "").strip()
            src = (source_branch or "").strip()
            if lock_branch and src and lock_branch != src:
                reasons.append(
                    f"durable lock for issue #{issue_n} is bound to branch "
                    f"'{lock_branch}', not PR source '{src}'"
                )
                continue
            matched_issue = issue_n
            matched_via = "durable_lock"
            lock_record = dict(durable)
            break

    # 3) Branch-owned live lock (any issue) matching the PR source branch.
    if matched_issue is None and (source_branch or "").strip():
        try:
            by_branch = issue_lock_store.find_live_lock_for_branch(
                str(source_branch).strip()
            )
        except Exception:
            by_branch = None
        if by_branch:
            try:
                branch_issue = int(by_branch.get("issue_number") or 0)
            except (TypeError, ValueError):
                branch_issue = 0
            if branch_issue in candidates:
                matched_issue = branch_issue
                matched_via = "branch_lock"
                lock_record = dict(by_branch)
            elif branch_issue:
                reasons.append(
                    f"live branch lock for '{source_branch}' belongs to unrelated "
                    f"issue #{branch_issue} (not linked to PR #{pr_number}; fail closed)"
                )

    proven = matched_issue is not None and lock_record is not None
    if not proven and not reasons:
        reasons.append(
            "no live author issue lock proven for PR "
            f"#{pr_number} via linked issues {linked or '[]'}, session lock, "
            "durable lock, or branch lock (fail closed; issue_number need not "
            "equal pr_number when the tracking issue is linked)"
        )
    # #768 AC2: when the lock that proved ownership was itself a sanctioned
    # dead-session recovery, carry that server-derived evidence into the push
    # gate rather than making it re-derive ownership blind. ``recorded_head``
    # and ``accepted_head`` let the caller see that the PR head it is about to
    # advance is the one recovery already sanctioned, not a foreign head.
    recovered_owning_pr = None
    if proven and lock_record:
        candidate = issue_lock_recovery.recovered_owning_pr_from_lock(lock_record)
        if candidate and int(candidate.get("pr_number") or 0) == int(pr_number):
            recovered_owning_pr = candidate
    return {
        "proven": proven,
        "has_author_lock": proven,
        "linked_issues": linked,
        "ownership_candidates": candidates,
        "matched_issue": matched_issue,
        "matched_via": matched_via,
        "lock_record": lock_record,
        "recovered_owning_pr": recovered_owning_pr,
        "reasons": reasons,
    }


@mcp.tool()
def gitea_assess_pr_sync_status(
    pr_number: int,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    prepared_verdict_head_sha: str | None = None,
    branch_protection_requires_current_base: bool | None = None,
    checks_status: str | None = None,
) -> dict:
    """Read-only: assess whether an approved/open PR is merge-ready or needs sync.

    Distinguishes ``merge_now``, ``update_branch_by_merge``,
    ``author_conflict_remediation``, ``fresh_review_required``, and ``blocked``.

    Pins exact PR head and live base head. Never mutates. Never returns tokens.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "performed": False,
            "recommended_next_action": pr_sync_status.ACTION_BLOCKED,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }

    try:
        h, o, r = _resolve(remote, host, org, repo)
    except Exception as exc:
        return {
            "success": False,
            "performed": False,
            "recommended_next_action": pr_sync_status.ACTION_BLOCKED,
            "reasons": [f"repository identity resolve failed: {_redact(str(exc))}"],
        }

    auth = _auth(h)
    if not auth:
        return {
            "success": False,
            "performed": False,
            "recommended_next_action": pr_sync_status.ACTION_BLOCKED,
            "reasons": ["credentials unavailable (fail closed)"],
            "host": h,
            "org": o,
            "repo": r,
        }

    base = repo_api_url(h, o, r)
    try:
        pr = api_request("GET", f"{base}/pulls/{pr_number}", auth) or {}
    except Exception as exc:
        return {
            "success": False,
            "performed": False,
            "recommended_next_action": pr_sync_status.ACTION_BLOCKED,
            "reasons": [f"PR details could not be retrieved: {_redact(str(exc))}"],
            "host": h,
            "org": o,
            "repo": r,
            "pr_number": pr_number,
        }

    pr_state = (pr.get("state") or "").strip().lower() or None
    head_obj = pr.get("head") or {}
    base_obj = pr.get("base") or {}
    pr_head_sha = (head_obj.get("sha") or "").strip() or None
    source_branch = (head_obj.get("ref") or "").strip() or None
    base_branch = (base_obj.get("ref") or "").strip() or "master"
    base_head_sha = (base_obj.get("sha") or "").strip() or None
    mergeable = pr.get("mergeable")
    # Gitea sometimes exposes conflict via mergeable=false only.
    has_conflicts = False if mergeable is True else (True if mergeable is False else None)

    # Prefer live base branch tip over the PR's stored base SHA (may lag).
    try:
        br = api_request("GET", f"{base}/branches/{base_branch}", auth) or {}
        live_base = ((br.get("commit") or {}).get("id") or "").strip()
        if live_base:
            base_head_sha = live_base
    except Exception:
        pass

    commits_behind = None
    if pr_head_sha and base_head_sha:
        commits_behind = _count_commits_behind(
            base, auth, pr_head_sha=pr_head_sha, base_head_sha=base_head_sha
        )
        if commits_behind is None:
            # Fail closed on unknown behind-count only when SHAs differ.
            commits_behind = 0 if pr_head_sha == base_head_sha else None

    # Single live read of the base-branch protection policy; it carries both
    # the current-base rule and the status-check requirement (#751).
    protection_policy = _branch_protection_policy(
        base, auth, base_branch=base_branch
    )
    if branch_protection_requires_current_base is None:
        branch_protection_requires_current_base = protection_policy.get(
            "requires_current_base"
        )
    # When protection cannot be read, fail closed by requiring current base
    # whenever the PR is behind (safer for protected repos). Callers may pass
    # an explicit False when policy is known not to require it.
    if branch_protection_requires_current_base is None:
        if commits_behind is not None and commits_behind > 0:
            branch_protection_requires_current_base = True
        else:
            branch_protection_requires_current_base = False

    approval_at_current_head = None
    try:
        feedback = gitea_get_pr_review_feedback(
            pr_number=pr_number, remote=remote, host=host, org=org, repo=repo,
        )
        if feedback.get("success"):
            approval_at_current_head = bool(feedback.get("approval_at_current_head"))
        else:
            approval_at_current_head = False
    except Exception:
        approval_at_current_head = None

    # Locks / leases (best-effort; absence is reported as None/False).
    # Ownership uses linked tracking issue / branch / session lock — not only
    # issue_number == pr_number (#727 / PR #728).
    active_author_lock = None
    try:
        ownership = _prove_author_ownership_for_pr(
            pr_number=pr_number,
            pr_title=pr.get("title"),
            pr_body=pr.get("body"),
            source_branch=source_branch,
            remote=remote,
            host=host,
            org=o,
            repo=r,
            worktree_path=None,
        )
        active_author_lock = bool(ownership.get("has_author_lock"))
    except Exception:
        active_author_lock = None

    active_reviewer_lease = None
    active_merger_lease = None
    try:
        comments = api_request(
            "GET", f"{base}/issues/{pr_number}/comments", auth
        ) or []
        if isinstance(comments, list):
            rev = pr_work_lease.find_active_reviewer_lease(
                comments, pr_number=pr_number
            )
            active_reviewer_lease = bool(rev)
            # Merger lease comments share reviewer_pr_lease session store when present.
            try:
                sess = reviewer_pr_lease.get_session_lease() or {}
                active_merger_lease = bool(
                    sess.get("active")
                    and int(sess.get("pr_number") or 0) == int(pr_number)
                    and (sess.get("phase") or "").startswith("merger")
                )
            except Exception:
                active_merger_lease = False
    except Exception:
        pass

    # ── Live checks derivation (#751) ────────────────────────────────────
    # Classify from the actual status-context collection plus the live
    # protection policy. The combined ``state`` is never treated as proof that
    # CI is executing, because Gitea reports ``pending`` for an empty
    # collection. ``checks_required`` is always derived from live evidence and
    # is deliberately not a caller-supplied input, so no session can declare
    # checks optional without proof.
    checks_snapshot = _commit_checks_snapshot(base, auth, sha=pr_head_sha or "")
    checks_classification = pr_sync_status.classify_commit_checks(
        combined_state=checks_snapshot.get("combined_state"),
        statuses=checks_snapshot.get("statuses"),
        checks_enabled=protection_policy.get("checks_enabled"),
        required_contexts=protection_policy.get("required_contexts"),
        policy_determinable=bool(protection_policy.get("determinable")),
        status_determinable=bool(checks_snapshot.get("determinable")),
    )
    checks_required = bool(checks_classification.get("checks_required", True))
    caller_supplied_checks_status = checks_status
    if checks_status is None:
        checks_status = checks_classification.get("checks_status")
    elif checks_classification.get("checks_status") != pr_sync_status.CHECKS_UNKNOWN:
        # Live evidence outranks a caller-supplied value; the override only
        # applies when live status could not be classified at all.
        checks_status = checks_classification.get("checks_status")

    assessment = pr_sync_status.assess_pr_sync_status(
        host=h,
        org=o,
        repo=r,
        pr_number=pr_number,
        pr_state=pr_state,
        source_branch=source_branch,
        pr_head_sha=pr_head_sha,
        base_head_sha=base_head_sha,
        commits_behind=commits_behind,
        mergeable=mergeable,
        has_conflicts=has_conflicts,
        branch_protection_requires_current_base=branch_protection_requires_current_base,
        approval_at_current_head=approval_at_current_head,
        checks_status=checks_status,
        active_author_lock=active_author_lock,
        active_reviewer_lease=active_reviewer_lease,
        active_merger_lease=active_merger_lease,
        prepared_verdict_head_sha=prepared_verdict_head_sha,
        checks_required=checks_required,
    )
    assessment["remote"] = remote if remote in REMOTES else None
    assessment["base_branch"] = base_branch
    # Evidence for the checks decision (#751) — no secrets, read-only.
    assessment["checks_evidence"] = {
        "combined_state": checks_classification.get("combined_state"),
        "context_count": checks_classification.get("context_count"),
        "observed_contexts": checks_classification.get("observed_contexts"),
        "required_contexts": checks_classification.get("required_contexts"),
        "missing_required_contexts": checks_classification.get(
            "missing_required_contexts"
        ),
        "policy_determinable": checks_classification.get("policy_determinable"),
        "status_determinable": checks_classification.get("status_determinable"),
        "protection_found": protection_policy.get("protection_found"),
        "checks_enabled": protection_policy.get("checks_enabled"),
        "caller_supplied_checks_status": caller_supplied_checks_status,
        "reasons": checks_classification.get("reasons"),
    }
    assessment["success"] = True
    assessment["performed"] = False
    return assessment


@mcp.tool()
def gitea_update_pr_branch_by_merge(
    pr_number: int,
    expected_pr_head_sha: str,
    expected_base_head_sha: str,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    worktree_path: str | None = None,
) -> dict:
    """Author-only: merge live base into the PR branch (Gitea Update-by-merge).

    Pins both expected PR head and expected base head; fails closed on either
    race. Never rebases or force-pushes. On conflicts, returns a structured
    author-remediation handoff without partial remote mutation.

    Requires author profile, existing issue/PR lock, PR worktree under
    branches/, clean control checkout, and master parity.
    """
    profile = get_profile()
    allowed = profile.get("allowed_operations") or []
    forbidden = profile.get("forbidden_operations") or []
    role = _role_kind(allowed, forbidden)

    # Permission: author branch push / PR mutation surface.
    push_block = _profile_operation_gate("gitea.branch.push")
    if push_block:
        return {
            "success": False,
            "performed": False,
            "mutation_allowed": False,
            "reasons": push_block,
            "permission_report": _permission_block_report("gitea.branch.push"),
            "role_kind": role,
        }

    if role != "author":
        pre = pr_sync_status.assess_update_pr_branch_preflight(
            role_kind=role,
            expected_pr_head_sha=expected_pr_head_sha,
            live_pr_head_sha=expected_pr_head_sha,
            expected_base_head_sha=expected_base_head_sha,
            live_base_head_sha=expected_base_head_sha,
            style="merge",
        )
        pre["success"] = False
        return pre

    try:
        h, o, r = _resolve(remote, host, org, repo)
    except Exception as exc:
        return {
            "success": False,
            "performed": False,
            "mutation_allowed": False,
            "reasons": [f"repository identity resolve failed: {_redact(str(exc))}"],
            "role_kind": role,
        }

    # Workspace / control / parity gates.
    try:
        _verify_role_mutation_workspace(
            remote, worktree_path=worktree_path, task="push_branch"
        )
    except Exception as exc:
        return {
            "success": False,
            "performed": False,
            "mutation_allowed": False,
            "reasons": [f"workspace gate failed: {_redact(str(exc))}"],
            "role_kind": role,
            "host": h,
            "org": o,
            "repo": r,
            "pr_number": pr_number,
        }

    control_clean = True
    try:
        porcelain = _get_workspace_porcelain(_canonical_local_git_root())
        # Untracked-only dirt is ignored; tracked edits contaminate control.
        tracked_dirty = [
            line for line in (porcelain or "").splitlines()
            if line.strip() and not line.startswith("??")
        ]
        control_clean = not bool(tracked_dirty)
    except Exception:
        control_clean = False

    master_ok = True
    try:
        stale = _master_parity_block("gitea.branch.push")
        master_ok = not bool(stale)
    except Exception:
        master_ok = False

    wt = (worktree_path or "").strip() or (
        os.environ.get("GITEA_AUTHOR_WORKTREE")
        or os.environ.get("GITEA_ACTIVE_WORKTREE")
        or ""
    ).strip()

    auth = _auth(h)
    if not auth:
        return {
            "success": False,
            "performed": False,
            "mutation_allowed": False,
            "reasons": ["credentials unavailable (fail closed)"],
            "role_kind": role,
            "host": h,
            "org": o,
            "repo": r,
            "pr_number": pr_number,
        }

    base = repo_api_url(h, o, r)
    try:
        pr = api_request("GET", f"{base}/pulls/{pr_number}", auth) or {}
    except Exception as exc:
        return {
            "success": False,
            "performed": False,
            "mutation_allowed": False,
            "reasons": [f"PR details could not be retrieved: {_redact(str(exc))}"],
            "role_kind": role,
            "host": h,
            "org": o,
            "repo": r,
            "pr_number": pr_number,
        }

    if (pr.get("state") or "").strip().lower() != "open":
        return {
            "success": False,
            "performed": False,
            "mutation_allowed": False,
            "reasons": [
                f"PR is not open (state={pr.get('state')}); refuse update"
            ],
            "role_kind": role,
            "host": h,
            "org": o,
            "repo": r,
            "pr_number": pr_number,
        }

    head_obj = pr.get("head") or {}
    base_obj = pr.get("base") or {}
    live_pr_head = (head_obj.get("sha") or "").strip() or None
    source_branch = (head_obj.get("ref") or "").strip() or None
    base_branch = (base_obj.get("ref") or "").strip() or "master"
    live_base_head = (base_obj.get("sha") or "").strip() or None
    try:
        br = api_request("GET", f"{base}/branches/{base_branch}", auth) or {}
        tip = ((br.get("commit") or {}).get("id") or "").strip()
        if tip:
            live_base_head = tip
    except Exception:
        pass

    mergeable = pr.get("mergeable")
    has_conflicts = False if mergeable is True else (True if mergeable is False else None)

    owns_branch = True
    if wt and source_branch:
        try:
            # Best-effort: worktree branch should match PR source branch.
            import subprocess as _sp
            out = _sp.check_output(
                ["git", "-C", wt, "rev-parse", "--abbrev-ref", "HEAD"],
                text=True,
                stderr=_sp.DEVNULL,
            ).strip()
            if out and out != source_branch:
                owns_branch = False
        except Exception:
            owns_branch = None  # unknown — do not hard-fail solely on probe

    # Prove ownership via linked issue lock / session / branch — not issue==pr.
    ownership = _prove_author_ownership_for_pr(
        pr_number=pr_number,
        pr_title=pr.get("title"),
        pr_body=pr.get("body"),
        source_branch=source_branch,
        remote=remote,
        host=host,
        org=o,
        repo=r,
        worktree_path=wt or None,
    )
    has_lock = bool(ownership.get("has_author_lock"))

    preflight = pr_sync_status.assess_update_pr_branch_preflight(
        role_kind=role,
        expected_pr_head_sha=expected_pr_head_sha,
        live_pr_head_sha=live_pr_head,
        expected_base_head_sha=expected_base_head_sha,
        live_base_head_sha=live_base_head,
        has_conflicts=has_conflicts,
        mergeable=mergeable,
        style="merge",
        has_author_lock=has_lock,
        worktree_path=wt or None,
        worktree_owns_source_branch=owns_branch,
        control_checkout_clean=control_clean,
        master_parity_ok=master_ok,
        force_push=False,
        rebase=False,
    )
    if not has_lock and ownership.get("reasons"):
        # Surface ownership diagnostics beyond the generic preflight line.
        preflight.setdefault("reasons", [])
        for reason in ownership["reasons"]:
            if reason not in preflight["reasons"]:
                preflight["reasons"].append(reason)
    preflight["ownership"] = {
        "linked_issues": ownership.get("linked_issues"),
        "matched_issue": ownership.get("matched_issue"),
        "matched_via": ownership.get("matched_via"),
        # #768 AC2: present only when a sanctioned dead-session recovery already
        # proved this exact PR belongs to this author.
        "recovered_owning_pr": ownership.get("recovered_owning_pr"),
    }
    preflight["host"] = h
    preflight["org"] = o
    preflight["repo"] = r
    preflight["pr_number"] = pr_number
    preflight["source_branch"] = source_branch
    preflight["base_branch"] = base_branch
    preflight["worktree_path"] = wt or None
    preflight["profile_name"] = profile.get("profile_name")

    if not preflight.get("mutation_allowed"):
        preflight["success"] = False
        preflight["performed"] = False
        if preflight.get("author_remediation_handoff"):
            preflight["recommended_next_action"] = (
                pr_sync_status.ACTION_AUTHOR_CONFLICT_REMEDIATION
            )
        return preflight

    # Native Gitea update: POST .../pulls/{index}/update?style=merge
    update_url = f"{base}/pulls/{pr_number}/update?style=merge"
    try:
        api_request("POST", update_url, auth)
    except Exception as exc:
        msg = _redact(str(exc)).lower()
        conflictish = any(
            token in msg
            for token in ("conflict", "409", "merge conflict", "not mergeable")
        )
        if conflictish:
            return {
                "success": False,
                "performed": False,
                "mutation_allowed": False,
                "author_remediation_handoff": True,
                "recommended_next_action": (
                    pr_sync_status.ACTION_AUTHOR_CONFLICT_REMEDIATION
                ),
                "reasons": [
                    "Gitea refused update-by-merge due to conflicts; "
                    "no partial remote update applied",
                    _redact(str(exc)),
                ],
                "host": h,
                "org": o,
                "repo": r,
                "pr_number": pr_number,
                "expected_pr_head_sha": expected_pr_head_sha,
                "expected_base_head_sha": expected_base_head_sha,
                "live_pr_head_sha_before": live_pr_head,
                "style": "merge",
                "role_kind": role,
            }
        return {
            "success": False,
            "performed": False,
            "mutation_allowed": False,
            "reasons": [
                f"update-by-merge API failed (fail closed): {_redact(str(exc))}"
            ],
            "host": h,
            "org": o,
            "repo": r,
            "pr_number": pr_number,
            "style": "merge",
            "role_kind": role,
        }

    # Re-read new head after successful update.
    new_head = None
    try:
        pr_after = api_request("GET", f"{base}/pulls/{pr_number}", auth) or {}
        new_head = ((pr_after.get("head") or {}).get("sha") or "").strip() or None
        mergeable_after = pr_after.get("mergeable")
    except Exception:
        pr_after = {}
        mergeable_after = None

    transition = pr_sync_status.assess_post_update_head_transition(
        former_pr_head_sha=live_pr_head,
        new_pr_head_sha=new_head,
        former_approval_head_sha=live_pr_head,
        prepared_verdict_head_sha=live_pr_head,
    )

    return {
        "success": True,
        "performed": True,
        "mutation_allowed": True,
        "style": "merge",
        "force_push": False,
        "rebase": False,
        "host": h,
        "org": o,
        "repo": r,
        "pr_number": pr_number,
        "source_branch": source_branch,
        "base_branch": base_branch,
        "former_pr_head_sha": live_pr_head,
        "new_pr_head_sha": new_head,
        "base_head_sha": live_base_head,
        "mergeable_after": mergeable_after,
        "approval_invalidated_for_former_head": transition.get(
            "approval_invalidated", True
        ),
        "reviewer_lease_superseded": transition.get("reviewer_lease_superseded"),
        "merger_lease_superseded": transition.get("merger_lease_superseded"),
        "prepared_verdict_invalidated": transition.get(
            "prepared_verdict_invalidated"
        ),
        "recommended_next_action": transition.get(
            "recommended_next_action",
            pr_sync_status.ACTION_FRESH_REVIEW_REQUIRED,
        ),
        "transition": transition,
        "role_kind": role,
        "profile_name": profile.get("profile_name"),
        "worktree_path": wt or None,
        "reasons": list(transition.get("reasons") or []) + [
            "update-by-merge completed via native Gitea API (style=merge only)"
        ],
    }


@mcp.tool()
def gitea_assess_conflict_fix_push(
    pr_number: int,
    branch_head_before: str,
    branch_head_after: str,
    worktree_path: str,
    push_cwd: str,
    is_fast_forward: bool = True,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Read-only pre-push gate for author conflict-fix sessions (#399)."""
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "push_allowed": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    fetched = _fetch_pr_lease_comments_safe(
        pr_number,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
        require_open=True,
    )
    if not fetched["success"]:
        return {
            "push_allowed": False,
            "block": True,
            "reasons": fetched["reasons"],
            "pr_lookup": fetched.get("pr_lookup"),
            "resolved_repo": fetched.get("resolved_repo"),
            "remote": fetched.get("remote"),
            "pr_number": pr_number,
            "assessment_failed": True,
        }
    assessment = pr_work_lease.assess_conflict_fix_push(
        pr_number=pr_number,
        comments=fetched["comments"],
        branch_head_before=branch_head_before,
        branch_head_after=branch_head_after,
        worktree_path=worktree_path,
        push_cwd=push_cwd,
        is_fast_forward=is_fast_forward,
    )
    assessment["pr_lookup"] = fetched.get("pr_lookup")
    assessment["resolved_repo"] = fetched.get("resolved_repo")
    assessment["remote"] = fetched.get("remote")
    assessment["live_pr_head_sha"] = fetched.get("head_sha")
    return assessment


@mcp.tool()
def gitea_reconcile_issue_claims(
    state: str = "open",
    stale_after_hours: int = 24,
    heartbeat_lease_minutes: int = 30,
    limit: int = 100,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Read-only inventory of issue claims and heartbeat lease status (#268)."""
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }

    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    base = repo_api_url(h, o, r)
    issues = api_get_all(f"{base}/issues?state={state}&type=issues", auth, limit=limit)
    open_prs = api_get_all(f"{base}/pulls?state=open", auth)
    branches = api_get_all(f"{base}/branches", auth, limit=limit)
    branch_names = [b.get("name") for b in branches if b.get("name")]

    comments_by_issue: dict[int, list[dict]] = {}
    for issue in issues:
        if not issue_claim_heartbeat.issue_has_in_progress_label(issue):
            continue
        number = int(issue["number"])
        api = f"{base}/issues/{number}/comments"
        comments_by_issue[number] = api_request("GET", api, auth) or []

    reclaim_after_minutes = max(heartbeat_lease_minutes * 2, 60)
    inventory = issue_claim_heartbeat.build_claim_inventory(
        issues=issues,
        comments_by_issue=comments_by_issue,
        open_prs=open_prs,
        branch_names=branch_names,
        heartbeat_lease_minutes=heartbeat_lease_minutes,
        reclaim_after_minutes=reclaim_after_minutes,
    )
    live_locks = issue_lock_store.list_live_locks()
    inventory["live_issue_locks"] = live_locks
    inventory["live_issue_lock_numbers"] = sorted(
        {
            int(entry["issue_number"])
            for entry in live_locks
            if entry.get("issue_number") is not None
        }
    )
    inventory["cleanup_plan"] = issue_claim_heartbeat.build_cleanup_plan(inventory)
    inventory["success"] = True
    inventory["performed"] = False
    return inventory


@mcp.tool()
def gitea_cleanup_stale_claims(
    dry_run: bool = True,
    execute_confirmed: bool = False,
    heartbeat_lease_minutes: int = 30,
    limit: int = 100,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Propose or execute stale-claim cleanup for phantom/reclaimable issues (#268)."""
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "performed": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }

    inventory = gitea_reconcile_issue_claims(
        heartbeat_lease_minutes=heartbeat_lease_minutes,
        limit=limit,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
    )
    if not inventory.get("success"):
        return inventory

    plan = [
        entry
        for entry in inventory.get("cleanup_plan") or []
        if entry.get("action") == "remove_status_in_progress_and_comment"
    ]
    report = {
        "success": True,
        "dry_run": dry_run,
        "performed": False,
        "planned_actions": plan,
        "inventory_counts": inventory.get("counts"),
    }
    if dry_run:
        return report

    if not execute_confirmed:
        raise ValueError(
            "execute_confirmed must be True when dry_run=False (fail closed)"
        )

    blocked = _profile_permission_block(
        task_capability_map.required_permission("cleanup_stale_claims"),
        remote=remote,
        host=host,
        org=org,
        repo=repo,
        org_explicit=org is not None,
        repo_explicit=repo is not None,
    )
    if blocked:
        return blocked
    verify_preflight_purity(
        remote, task="cleanup_stale_claims", org=org, repo=repo
    )

    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    base = repo_api_url(h, o, r)
    # Paginated inventory (#627) — do not use single-page labels?limit=100.
    label_id = _repo_label_id_map(base, auth).get("status:in-progress")
    if label_id is None:
        raise RuntimeError("Label 'status:in-progress' not found")

    actions: list[dict] = []
    active_profile = get_profile().get("profile_name")
    for entry in plan:
        issue_number = int(entry["issue_number"])
        with _audited(
            "unlabel_issue",
            host=h,
            remote=remote,
            org=o,
            repo=r,
            issue_number=issue_number,
            request_metadata={"op": "cleanup_stale_claim", "label": "status:in-progress"},
        ):
            api_request(
                "DELETE",
                f"{base}/issues/{issue_number}/labels/{label_id}",
                auth,
            )
        cleanup_body = issue_claim_heartbeat.format_heartbeat_body(
            kind="cleanup",
            issue_number=issue_number,
            branch="none",
            phase="stale-claim-cleanup",
            profile=active_profile,
            next_action="issue reclaimable by queue",
            blocker=entry.get("status") or "stale",
        )
        comment = _post_structured_issue_comment(
            issue_number=issue_number,
            body=cleanup_body,
            remote=remote,
            host=host,
            org=org,
            repo=repo,
            audit_op="cleanup_heartbeat",
        )
        actions.append(
            {
                "issue_number": issue_number,
                "label_removed": True,
                "cleanup_comment_id": comment.get("comment_id"),
            }
        )

    report["performed"] = True
    report["actions"] = actions
    return report


@mcp.tool()
def gitea_list_labels(
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> list:
    """List all available labels in a Gitea repository.

    Args:
        remote: Known Gitea instance ('dadeschools' or 'prgs').
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.

    Returns:
        list of labels.
    """
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    base = repo_api_url(h, o, r)
    return api_get_all(f"{base}/labels", auth)


@mcp.tool()
def gitea_create_label(
    name: str,
    color: str,
    description: str = "",
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    worktree_path: str | None = None,
) -> dict:
    """Create a new label on a Gitea repository.

    Args:
        name: Name of the label (e.g. 'bug', 'epic').
        color: HTML color code (hex, e.g. 'fbca04' or '#fbca04').
        description: Description of the label.
        remote: Known Gitea instance ('dadeschools' or 'prgs').
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.
        worktree_path: Optional branches worktree to verify before mutation.

    Returns:
        dict containing the created label details.
    """
    blocked = _profile_permission_block(
        task_capability_map.required_permission("create_label"),
        remote=remote,
        host=host,
        org=org,
        repo=repo,
        org_explicit=org is not None,
        repo_explicit=repo is not None,
    )
    if blocked:
        return blocked
    verify_preflight_purity(
        remote,
        worktree_path=worktree_path,
        task="create_label",
        org=org,
        repo=repo,
    )
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    base = repo_api_url(h, o, r)

    if color.startswith("#"):
        color = color[1:]

    payload = {
        "name": name,
        "color": color,
        "description": description,
    }
    with _audited("create_label", host=h, remote=remote, org=o, repo=r,
                  request_metadata={"name": name}):
        return api_request("POST", f"{base}/labels", auth, payload)


@mcp.tool()
def gitea_set_issue_labels(
    issue_number: int,
    labels: list[str],
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    worktree_path: str | None = None,
) -> list:
    """Replace all labels on a Gitea issue with a new list of label names.

    Args:
        issue_number: The issue number.
        labels: List of label names to apply.
        remote: Known Gitea instance ('dadeschools' or 'prgs').
        host: Override the Gitea host.
        org: Override the owner/organization.
        repo: Override the repository name.
        worktree_path: Optional branches worktree to verify before mutation.

    Returns:
        list of all labels currently applied to the issue.
    """
    blocked = _profile_permission_block(
        task_capability_map.required_permission("set_issue_labels"),
        issue_number=issue_number,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
        org_explicit=org is not None,
        repo_explicit=repo is not None,
    )
    if blocked:
        return blocked
    verify_preflight_purity(
        remote,
        worktree_path=worktree_path,
        task="set_issue_labels",
        org=org,
        repo=repo,
    )
    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    base = repo_api_url(h, o, r)

    # Full-set replacement via paginated name→id map + post-mutation verify (#627).
    # Never use single-page GET labels?limit=100 — Gitea caps pages at 50.
    with _audited("set_issue_labels", host=h, remote=remote, org=o, repo=r,
                  issue_number=issue_number, request_metadata={"labels": list(labels)}):
        res = _put_issue_label_names(
            base=base,
            auth=auth,
            issue_number=issue_number,
            names=list(labels),
        )
    return res


@mcp.tool()
def gitea_mirror_refs(
    apply: bool = False,
    force: bool = False,
) -> dict:
    """Mirror branches and tags between dadeschools and prgs Timesheet repos.

    Additive only — never deletes branches or tags. Diverged branches are
    skipped unless force is True.

    Args:
        apply: If True, actually push. If False (default), dry-run only.
        force: If True, force-push diverged branches.

    Returns:
        dict with 'output' (script stdout) and 'return_code'.
    """
    script = os.path.join(PROJECT_ROOT, "mirror_refs.sh")
    args = [script]
    if apply:
        args.append("--apply")
    if force:
        args.append("--force")

    result = subprocess.run(
        args, capture_output=True, text=True, timeout=120,
    )
    return {
        "output": result.stdout + result.stderr,
        "return_code": result.returncode,
    }

_VALIDATION_STATUSES = ("passed", "failed", "skipped", "not-run")


def build_validation_report(commands: list[dict]) -> dict:
    """Build an explicit validation report for LLM final summaries (#167).

    Each entry must carry 'command' and a 'status' from 'passed' /
    'failed' / 'skipped' / 'not-run'. A failed entry must carry the exact
    'output'; vague language ("shell-invocation quirks") is rejected as an
    unknown status and a missing failure output raises, so a report can
    never hide what actually happened. Optional keys: 'reason' (for
    skipped/not-run, e.g. what targeted check replaced it) and
    'full_suite': True to mark the full-test-suite command —
    'full_suite_passed' is True only if that entry passed, False if it
    failed/was skipped/not run, and None when no entry is marked, so
    full-suite success is never implied.
    """
    entries = []
    full_suite_passed = None
    for raw in commands:
        command = (raw.get("command") or "").strip()
        if not command:
            raise ValueError("validation entry missing 'command' (fail closed)")
        status = raw.get("status")
        if status not in _VALIDATION_STATUSES:
            raise ValueError(
                f"unknown validation status {status!r} for '{command}'; "
                f"use one of {_VALIDATION_STATUSES} (fail closed)")
        output = raw.get("output")
        if status == "failed" and not (output or "").strip():
            raise ValueError(
                f"failed validation '{command}' must include the exact "
                "command output (fail closed)")
        entry = {"command": command, "status": status}
        if output:
            entry["output"] = output
        if raw.get("reason"):
            entry["reason"] = raw["reason"]
        if raw.get("full_suite"):
            entry["full_suite"] = True
            full_suite_passed = status == "passed"
        entries.append(entry)
    label = {"passed": "PASSED", "failed": "FAILED",
             "skipped": "SKIPPED", "not-run": "NOT-RUN"}
    lines = []
    for e in entries:
        line = f"{label[e['status']]}: {e['command']}"
        if e.get("output"):
            line += f" — {e['output']}"
        if e.get("reason"):
            line += f" ({e['reason']})"
        lines.append(line)
    return {
        "entries": entries,
        "all_passed": bool(entries) and all(
            e["status"] == "passed" for e in entries),
        "full_suite_passed": full_suite_passed,
        "summary": "\n".join(lines),
    }


@mcp.tool()
def gitea_route_task_session(
    task_type: str,
    remote: str = "dadeschools",
    host: str | None = None,
) -> dict:
    """Pre-task role/session router (#206).

    Classify *task_type* against the active MCP profile before any mutation.
    Returns ``route_result`` — only ``allowed_current_session`` permits
    downstream tool use. Reviewer tasks under an author-bound session return
    ``wrong_role_stop`` with no fallback to author mutations.
    """
    task_type = (task_type or "").strip()
    profile = get_profile()
    allowed = profile.get("allowed_operations") or []
    forbidden = profile.get("forbidden_operations") or []
    active_role = _role_kind(allowed, forbidden)

    if not task_type:
        return role_session_router.route_task_session(
            "",
            active_profile=profile["profile_name"],
            active_role_kind=active_role,
            allowed_in_current_session=False,
        )

    capability = None
    try:
        capability = gitea_resolve_task_capability(
            task=task_type,
            remote=remote,
            host=host,
        )
    except ValueError:
        capability = None

    if capability is not None:
        return role_session_router.route_task_session(
            task_type,
            active_profile=capability["active_profile"],
            active_role_kind=active_role,
            allowed_in_current_session=capability["allowed_in_current_session"],
            runtime_switching_supported=capability["runtime_switching_supported"],
        )

    return role_session_router.route_task_session(
        task_type,
        active_profile=profile["profile_name"],
        active_role_kind=active_role,
        allowed_in_current_session=False,
    )


# #685: resolver stale-runtime detection is report-only. Config touch / os._exit
# self-recovery was removed from the read-only path (was _trigger_mcp_auto_restart).
# Recovery is owned exclusively by the IDE/client reconnect path.


def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) -> list[str]:
    """Read-only: report missing or stale MCP runtimes (no config or process mutation).

    #685: Never touches MCP client config, never spawns recovery threads, never
    calls ``os._exit``. Stale detection remains fail-closed via returned reasons
    only; the IDE/client owns reconnect/reload.
    """
    import subprocess
    import re
    from datetime import datetime

    reasons = []
    code_path = os.path.join(PROJECT_ROOT, "gitea_mcp_server.py")
    if not os.path.exists(code_path):
        return reasons

    code_mtime = datetime.fromtimestamp(os.path.getmtime(code_path))

    try:
        proc = subprocess.run(
            ["ps", "-o", "pid,lstart,command", "-ax"],
            capture_output=True, text=True, check=True
        )
    except Exception as exc:
        return [f"stale-runtime: failed to list running processes: {exc}"]

    current_head_sha = None
    try:
        current_head_sha = subprocess.run(
            ["git", "rev-parse", "HEAD"],
            capture_output=True, text=True, check=True, cwd=PROJECT_ROOT
        ).stdout.strip()
    except Exception:
        pass

    git_stale = False
    if _process_boot_head_sha and current_head_sha and _process_boot_head_sha != current_head_sha:
        git_stale = True

    self_pid = os.getpid()
    self_stale = False

    running_profiles = {}
    for line in proc.stdout.splitlines()[1:]:
        line = line.strip()
        if not line or "mcp_server.py" not in line:
            continue

        parts = line.split(None, 6)
        if len(parts) < 7:
            continue
        pid_str = parts[0]
        lstart_str = " ".join(parts[1:6])

        try:
            pid = int(pid_str)
            start_time = datetime.strptime(lstart_str, "%a %b %d %H:%M:%S %Y")
        except Exception:
            continue

        try:
            env_proc = subprocess.run(
                ["ps", "eww", str(pid)],
                capture_output=True, text=True, check=True
            )
            env_out = env_proc.stdout
        except Exception:
            continue

        profile = "gitea-default"
        match = re.search(r'\bGITEA_MCP_PROFILE=([^\s]+)', env_out)
        if match:
            profile = match.group(1)

        is_stale = (start_time < code_mtime) or git_stale
        if pid == self_pid and is_stale:
            self_stale = True

        if profile not in running_profiles or start_time > running_profiles[profile]["start_time"]:
            running_profiles[profile] = {
                "pid": pid,
                "start_time": start_time,
                "is_stale": is_stale
            }

    if self_stale:
        # #685: report-only — no config utime, no thread, no os._exit.
        reasons.append(
            "stale-runtime: The active Gitea MCP server process is stale "
            "(running code from before changes were merged). "
            "Reconnect the IDE/client-managed MCP namespace for this profile "
            "so it reloads current master. The resolver does not touch "
            "mcp_config.json, spawn recovery threads, or terminate this process."
        )

    if matching_profiles:
        any_running = False
        any_fresh = False
        for mp in matching_profiles:
            if mp in running_profiles:
                any_running = True
                if not running_profiles[mp]["is_stale"]:
                    any_fresh = True
                    break
        if not any_running:
            reasons.append(
                f"stale-runtime: None of the matching profiles for task '{task}' ({matching_profiles}) are running in the OS. "
                "Please restart the MCP server to ensure they are spawned."
            )
        elif not any_fresh:
            reasons.append(
                f"stale-runtime: All matching profiles for task '{task}' ({matching_profiles}) are running but stale. "
                "Please fully restart the Gitea MCP server and retry."
            )

    return reasons


@mcp.tool()
def gitea_resolve_task_capability(
    task: str,
    remote: str = "dadeschools",
    host: str | None = None,
) -> dict:
    """Read-only / side-effect free: resolve capability, profile, and namespace for a task.

    Does **not** mutate MCP client configuration, spawn recovery threads, kill
    processes, or trigger daemon reloads (#685). Stale-runtime detection remains
    fail-closed: when the serving process is stale the result includes
    ``blocker_kind=runtime_reconnect_required``, ``restart_required=true``,
    ``stop_required=true``, and ``mutation_performed=false`` with a precise
    ``exact_safe_next_action`` pointing at IDE/client reconnect. Recovery is
    owned by the client reconnect path — never by this resolver.

    Args:
        task: The task/action to check (e.g. review_pr, create_issue).
        remote: Known remote instance name.
        host: Optional override for the Gitea host.
    """
    TASK_MAP = task_capability_map.TASK_CAPABILITY_MAP
    # Every fresh attempt invalidates the previous task/role stamp before any
    # fallible resolver work. Unknown/malformed tasks and unexpected failures
    # therefore remain fail-closed instead of preserving stale authority.
    _clear_resolved_capability_stamp()

    if task not in TASK_MAP:
        # #723: structured fail-closed unknown_task (never raise into internal_error).
        profile = get_profile()
        h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
        username = _authenticated_username(h) if h else None
        active_role_kind = _profile_role_kind(profile)
        switching = gitea_config.is_runtime_switching_enabled()
        result = {
            "requested_task": task,
            "required_operation_permission": "unknown",
            "required_role_kind": "unknown",
            "active_profile": profile.get("profile_name", "unknown"),
            "active_identity": username,
            "active_role_kind": active_role_kind,
            "active_profile_allowed_operations": profile.get("allowed_operations") or [],
            "active_profile_permission_allowed": False,
            "allowed_in_current_session": False,
            "available_in_session": False,
            "configured": False,
            "restart_required": False,
            "stop_required": True,
            "task_role_guidance": [
                f"STOP: Unknown task/action: '{task}' (fail closed)"
            ],
            "matching_configured_profile": [],
            "runtime_switching_supported": switching,
            "different_mcp_namespace_required": False,
            "exact_safe_next_action": (
                f"None; task '{task}' is unrecognized by the capability map."
            ),
            "reason": f"Unknown task/action: '{task}' (fail closed)",
            "reason_code": "unknown_task",
            "mutation_performed": False,
        }
        role_session_router.sync_route_from_capability(result)
        was_terminal = capability_stop_terminal.is_active()
        terminal = capability_stop_terminal.sync_from_capability_result(result)
        if terminal:
            result["terminal_mode"] = True
            result["terminal_report"] = (
                capability_stop_terminal.build_terminal_report(result)
            )
        elif was_terminal:
            result["cleared_stale_denial"] = True
        return result

    required_permission = task_capability_map.required_permission(task)
    required_role = task_capability_map.required_role(task)
    role_exclusive_tasks = task_capability_map.ROLE_EXCLUSIVE_TASKS

    infra_assessment = role_session_router.assess_infra_stop(PROJECT_ROOT)
    if required_role == "reviewer":
        if not infra_assessment["infra_stop"]:
            capability_stop_terminal.clear()
            last_route = role_session_router.last_route()
            if last_route and last_route.get("route_result") == role_session_router.ROUTE_INFRA_STOP:
                role_session_router.clear_route_state()
        elif infra_assessment["infra_stop"]:
            profile = get_profile()
            h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
            username = _authenticated_username(h) if h else None
            detail = "; ".join(infra_assessment.get("infra_stop_reasons") or [])
            next_safe_action = (
                "infra_stop: Unresolved merge conflict or mid-merge state detected in "
                f"MCP runtime source ({detail}). Please resolve all conflicts manually, "
                "finish/abort the merge, and retry."
            )
            return {
                "requested_task": task,
                "required_operation_permission": required_permission,
                "required_role_kind": required_role,
                "active_profile": profile["profile_name"],
                "active_identity": username,
                "active_profile_allowed_operations": profile.get("allowed_operations") or [],
                "allowed_in_current_session": False,
                "stop_required": True,
                "infra_stop": True,
                "infra_stop_assessment": infra_assessment,
                "task_role_guidance": [next_safe_action],
                "matching_configured_profile": [],
                "runtime_switching_supported": gitea_config.is_runtime_switching_enabled(),
                "different_mcp_namespace_required": False,
                "exact_safe_next_action": next_safe_action,
            }

    # #702 F1 + #685: assess inherited GITEA_ACTIVE_WORKTREE binding BEFORE the
    # terminal launcher probe so missing-cwd wedges can be diagnosed. On the
    # read-only capability-resolve path recovery is report-only
    # (auto_recover=False): never clear env, write session-state, or otherwise
    # mutate during gitea_resolve_task_capability. Sanctioned recovery remains
    # available to mutation entrypoints that call _assess_stale_active_binding
    # with auto_recover=True.
    try:
        stale_binding = _assess_stale_active_binding(auto_recover=False)
    except Exception:
        stale_binding = None

    # ── Terminal Launcher Preflight Check (#556) ──
    if task in native_mcp_preference.GITEA_MUTATION_TASKS:
        in_test = _preflight_in_test_mode()
        if not in_test or os.environ.get("GITEA_FORCE_TERMINAL_PROBE") == "1":
            resolved_cwd = os.environ.get("GITEA_ACTIVE_WORKTREE") or PROJECT_ROOT
            probe_res = native_mcp_preference.probe_terminal_spawn(resolved_cwd)
            if not probe_res["healthy"]:
                # Only block if the failure is due to a missing CWD, missing shell, or the circuit breaker is tripped.
                # Other failures (like missing executable in the MCP process's limited PATH) should not block the agent's shell capability.
                is_real_blocker = (
                    probe_res.get("error_type") in ("missing cwd", "missing shell")
                    or native_mcp_preference.shell_health_status().get("hard_stopped")
                )
                if is_real_blocker:
                    profile = get_profile()
                    h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
                    username = _authenticated_username(h) if h else None
                    next_safe_action = (
                        "BLOCKED + DIAGNOSE: terminal-launcher-failure "
                        f"({probe_res['error_type']}): {probe_res['error_msg']}. "
                        "Do not attempt git/pytest finalization or unsafe fallbacks. "
                        "Run gitea_diagnose_terminal, emit blocked-diagnose-report, and stop."
                    )
                    blocked_result = {
                        "requested_task": task,
                        "required_operation_permission": required_permission,
                        "required_role_kind": required_role,
                        "active_profile": profile["profile_name"],
                        "active_identity": username,
                        "active_profile_allowed_operations": profile.get("allowed_operations") or [],
                        "allowed_in_current_session": False,
                        "stop_required": True,
                        "infra_stop": True,
                        "blocked": True,
                        "blocked_reason": "BLOCKED + DIAGNOSE",
                        "terminal_launcher_unhealthy": True,
                        "terminal_launcher_diagnostics": probe_res,
                        "task_role_guidance": [next_safe_action],
                        "matching_configured_profile": [],
                        "runtime_switching_supported": gitea_config.is_runtime_switching_enabled(),
                        "different_mcp_namespace_required": False,
                        "exact_safe_next_action": next_safe_action,
                    }
                    if stale_binding is not None and stale_binding.get(
                        "classification"
                    ) != stale_binding_recovery.CLASSIFICATION_UNBOUND:
                        blocked_result["stale_binding_recovery"] = stale_binding
                    return blocked_result

    # Record the purity baseline now, but do not record a role/task authority
    # stamp until the final allow decision is known (#723).
    record_preflight_check("capability")

    # #714: never auto-switch profiles during capability resolution.
    profile = get_profile()
    config = gitea_config.load_config()
    contexts = (config or {}).get("contexts") if config else None

    h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
    username = _authenticated_username(h) if h else None
    expected_username = (profile.get("username") or "").strip() or None

    # Seed / re-check immutable session context for consistent multi-tool reads.
    _seed_session_context(
        profile=profile,
        remote=remote,
        host=h,
        identity=username,
        source="resolve_task_capability",
    )
    ctx_assess = session_ctx.assess_session_context(
        profile_name=profile.get("profile_name"),
        remote=remote,
        host=h,
        identity=username,
        expected_username=expected_username,
        require_bound=False,
    )
    id_assess = session_ctx.assess_identity_match(
        authenticated=username, expected_username=expected_username
    )
    remote_assess = session_ctx.profile_allowed_for_remote(
        profile, remote, REMOTES, contexts=contexts
    )

    # Load active permissions
    active_allowed = profile.get("allowed_operations") or []
    active_forbidden = profile.get("forbidden_operations") or []
    active_role_kind = _profile_role_kind(profile)

    # Check if allowed in current session (active profile only — #714)
    permission_allowed_in_current_session, _ = gitea_config.check_operation(
        required_permission, active_allowed, active_forbidden
    )
    role_matches_current_session = (
        task not in role_exclusive_tasks
        or active_role_kind == required_role
    )
    role_mismatch_reason = None
    if not role_matches_current_session:
        role_mismatch_reason = (
            f"Active profile role '{active_role_kind}' cannot perform "
            f"{required_role} task '{task}' even if nearby permissions are "
            "present (fail closed)."
        )
    cross_host_block = bool(remote_assess.get("block"))
    identity_block = bool(id_assess.get("block"))
    drift_block = bool(ctx_assess.get("block"))
    allowed_in_current_session = (
        permission_allowed_in_current_session
        and role_matches_current_session
        and not cross_host_block
        and not identity_block
        and not drift_block
    )

    switching = gitea_config.is_runtime_switching_enabled()
    available_in_session = allowed_in_current_session
    configured = False
    restart_required = False
    reason_msg = None

    # Matching profiles: same remote/host only (#714) — advisory, never activated.
    matching_profiles = _matching_configured_profiles(
        config,
        required_permission,
        remote=remote,
        required_role_kind=(
            required_role if task in role_exclusive_tasks else None
        ),
    )

    configured = len(matching_profiles) > 0
    available_in_session = allowed_in_current_session
    runtime_stale_blocker = False

    if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ:
        runtime_reasons = _check_mcp_runtimes_diagnostics(task, matching_profiles)
        if runtime_reasons:
            restart_required = True
            runtime_stale_blocker = True
            reason_msg = "; ".join(runtime_reasons)

    if not allowed_in_current_session:
        deny_parts: list[str] = []
        if cross_host_block:
            deny_parts.extend(remote_assess.get("reasons") or [])
        if identity_block:
            deny_parts.extend(id_assess.get("reasons") or [])
        if drift_block:
            deny_parts.extend(ctx_assess.get("reasons") or [])
        if not permission_allowed_in_current_session and not deny_parts:
            deny_parts.append(
                f"Active profile '{profile.get('profile_name')}' is not allowed "
                f"to '{required_permission}' (no substitute profile activated; fail closed)."
            )
        if role_mismatch_reason:
            deny_parts.append(role_mismatch_reason)
        if deny_parts:
            reason_msg = "; ".join(deny_parts)
        elif configured and switching:
            # Same-remote profile exists but is not the active one — explicit switch only.
            restart_required = False
            available_in_session = False
            if not reason_msg:
                reason_msg = (
                    f"Active profile cannot perform '{required_permission}'. "
                    f"Same-remote matching profiles (advisory only, not activated): "
                    f"{matching_profiles}."
                )
        elif not configured:
            if not reason_msg:
                reason_msg = (
                    f"No same-remote profile configured with permission "
                    f"'{required_permission}' for remote '{remote}'."
                )
        elif role_mismatch_reason:
            if not reason_msg:
                reason_msg = role_mismatch_reason
    different_namespace_required = False
    next_safe_action = "None; ready for operations."

    if not allowed_in_current_session:
        if cross_host_block or identity_block or drift_block:
            different_namespace_required = False
            next_safe_action = (
                "BLOCKED + DIAGNOSE: session context/host/identity is inconsistent "
                "or the active profile cannot serve this remote. Re-pin the correct "
                "profile with gitea_activate_profile for the intended remote, verify "
                "with gitea_whoami, and do not use cross-host profile substitution."
            )
        elif switching:
            different_namespace_required = False
            next_safe_action = (
                f"Switch explicitly with gitea_activate_profile to a same-remote "
                f"profile that allows '{required_permission}' "
                f"(matching configured profiles: {matching_profiles}). "
                "Capability resolution never auto-substitutes profiles (#714)."
            )
        else:
            different_namespace_required = True
            if required_role == "reviewer":
                next_safe_action = (
                    "Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, "
                    "or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."
                )
            elif required_role == "author":
                next_safe_action = (
                    "Switch to the author MCP session (e.g. gitea-author) which has author permissions configured, "
                    "or ask the operator to update GITEA_MCP_PROFILE to an author profile."
                )
            else:
                next_safe_action = (
                    f"Relaunch the server with GITEA_MCP_PROFILE set to a profile that has the required permission (e.g. one of: {matching_profiles}) "
                    "or use the corresponding MCP namespace."
                )

    # #685: stale-runtime typed remediation wins for exact_next_action when the
    # serving process/profile inventory is stale — even if permission is OK.
    if runtime_stale_blocker:
        next_safe_action = (
            "blocker_kind=runtime_reconnect_required: reconnect/restart the "
            "IDE-managed Gitea MCP server for this profile so it reloads current "
            "master. Do not edit mcp_config.json by hand; the resolver does not "
            "touch config, spawn recovery threads, or terminate the process."
        )

    # Task/role alignment guards (#167): the requested task, not the
    # available credential, decides what the session may do. A review/merge
    # task under a non-reviewer profile must stop — not silently degrade
    # into author-side mutations.
    stop_required = not allowed_in_current_session
    task_role_guidance = []
    if role_mismatch_reason:
        task_role_guidance.append(f"STOP: {role_mismatch_reason}")
    if required_role == "reviewer":
        if allowed_in_current_session:
            task_role_guidance.append(
                "Review/merge task: proceed only through the gated "
                "review/merge tools and their eligibility checks; "
                "self-review and self-merge remain blocked.")
        else:
            task_role_guidance.append(
                "STOP: the requested task is review/merge but the active "
                "profile cannot perform it. Do not fall back to author-side "
                "work — do not commit, push, edit files, or comment as "
                "author unless the operator explicitly changes the task to "
                "author-side remediation (task "
                "'address_pr_change_requests').")
    elif task == "address_pr_change_requests":
        if allowed_in_current_session:
            task_role_guidance.append(
                "Author-side remediation: you may commit and push fixes to "
                "the PR branch, but you must not submit review verdicts and "
                "must not merge.")
        else:
            task_role_guidance.append(
                "STOP: author-side remediation requires an author profile "
                "with branch push permission; the active profile cannot "
                "perform it.")
    elif not allowed_in_current_session:
        task_role_guidance.append(
            "STOP: the active profile cannot perform the requested task; "
            "follow exact_safe_next_action instead of improvising.")

    if task == "review_pr":
        init_review_decision_lock(
            remote if remote in REMOTES else None,
            task,
            force=False,
        )

    record_mutation_authority(profile["profile_name"], username, remote if remote in REMOTES else None, task)

    result = {
        "requested_task": task,
        "required_operation_permission": required_permission,
        "required_role_kind": required_role,
        "active_profile": profile["profile_name"],
        "active_identity": username,
        "active_role_kind": active_role_kind,
        "active_profile_allowed_operations": active_allowed,
        "active_profile_permission_allowed": permission_allowed_in_current_session,
        "allowed_in_current_session": allowed_in_current_session,
        "available_in_session": available_in_session,
        "configured": configured,
        "restart_required": restart_required,
        "stop_required": stop_required or restart_required,
        # #685: resolver is always side-effect free; never claims mutations.
        "mutation_performed": False,
        "task_role_guidance": task_role_guidance,
        "matching_configured_profile": matching_profiles,
        "runtime_switching_supported": switching,
        "different_mcp_namespace_required": different_namespace_required,
        "exact_safe_next_action": next_safe_action,
        # #714: immutable session context proof (must match whoami / runtime).
        "requested_remote": remote,
        "resolved_host": h,
        "session_context_audit": session_ctx.mutation_context_audit_fields(),
        "profile_remote_compatible": not cross_host_block,
        "identity_match": not identity_block,
        "auto_profile_substitution": False,
    }
    # #685: report typed reconnect blocker without mutating config or exiting.
    if runtime_stale_blocker:
        result["blocker_kind"] = "runtime_reconnect_required"
    # #702: optional stale-binding recovery guidance (read-only annotation).
    if stale_binding is not None and stale_binding.get("classification") != (
        stale_binding_recovery.CLASSIFICATION_UNBOUND
    ):
        result["stale_binding_recovery"] = stale_binding
    if reason_msg:
        result["reason"] = reason_msg
    if task in (
        "acquire_reviewer_pr_lease",
        "gitea_acquire_reviewer_pr_lease",
        "review_pr",
        "merge_pr",
    ):
        result["workflow_load_proof"] = review_workflow_load.workflow_load_status(
            PROJECT_ROOT)
        if not result["workflow_load_proof"].get("workflow_load_valid"):
            guidance = (
                "Call gitea_load_review_workflow before any reviewer review "
                "or merge mutation."
            )
            if guidance not in task_role_guidance:
                task_role_guidance.append(guidance)
    role_session_router.sync_route_from_capability(result)
    if audit_reconciliation_mode.check_audit_task_enters_phase(task):
        phase_record = audit_reconciliation_mode.enter_audit_phase(task)
        result["reconciliation_phase"] = phase_record.get("phase")
    was_terminal = capability_stop_terminal.is_active()
    terminal = capability_stop_terminal.sync_from_capability_result(result)
    if terminal:
        result["terminal_mode"] = True
        result["terminal_report"] = (
            capability_stop_terminal.build_terminal_report(result)
        )
    elif was_terminal and not (stop_required or restart_required):
        result["cleared_stale_denial"] = True
    if stop_required or restart_required:
        # A denied or stale resolver result is diagnostic evidence, never a
        # consumable capability token for a later mutation (#763).
        _clear_preflight_capability_state()
    else:
        # Stamp only after every resolver gate and fallible bookkeeping step
        # has completed. A denied, stale, malformed, or unexpectedly failing
        # attempt therefore cannot transiently authorize a later mutation.
        record_preflight_check(
            "capability", required_role, resolved_task=task
        )
    return result


@mcp.tool()
def gitea_capability_stop_terminal_report() -> dict:
    """Read-only: terminal report template after reviewer capability denial (#197)."""
    record = capability_stop_terminal.active_record()
    if not record:
        return {
            "terminal_mode": False,
            "reasons": ["capability stop terminal mode is not active"],
        }
    return capability_stop_terminal.build_terminal_report({
        "requested_task": record.get("requested_task"),
        "required_role_kind": record.get("required_role_kind"),
        "active_profile": record.get("active_profile"),
        "active_identity": record.get("active_identity"),
        "stop_required": record.get("stop_required"),
        "exact_safe_next_action": record.get("exact_safe_next_action"),
    })


def _control_plane_db_or_error() -> tuple[Any | None, list[str]]:
    """Open the #613 control-plane DB substrate; fail closed on errors."""
    try:
        db = control_plane_db.ControlPlaneDB()
        return db, []
    except Exception as exc:  # noqa: BLE001
        return None, [
            f"control-plane DB substrate unavailable: {_redact(str(exc))} "
            "(fail closed, #613/#600)"
        ]


def _allocator_candidates_from_gitea(
    *,
    remote: str,
    host: str | None,
    org: str,
    repo: str,
    include_issues: bool = True,
    include_prs: bool = True,
) -> tuple[list[Any], list[str], bool]:
    """Build allocator candidates from live Gitea open issues/PRs.

    Returns ``(candidates, reasons, inventory_complete)``. The inventory is
    assembled in full and is never truncated before ranking (#758 AC1/AC3):
    any result/display bound belongs to the caller's reporting, not to
    candidate construction. ``inventory_complete`` is False when a required
    listing failed, so the caller can fail closed instead of ranking a
    silently short candidate set.
    """
    reasons: list[str] = []
    candidates: list[Any] = []
    inventory_complete = True
    try:
        h, o, r = _resolve(remote, host, org, repo)
        auth = _auth(h)
    except Exception as exc:  # noqa: BLE001
        return [], [f"failed to resolve Gitea target: {_redact(str(exc))}"], False

    if include_prs:
        try:
            prs = api_get_all(
                f"{repo_api_url(h, o, r)}/pulls?state=open", auth
            ) or []
        except Exception as exc:  # noqa: BLE001
            reasons.append(f"failed to list open PRs: {_redact(str(exc))}")
            prs = []
            inventory_complete = False
        for pr in prs:
            if not isinstance(pr, dict):
                continue
            number = pr.get("number")
            if number is None:
                continue
            head = pr.get("head") if isinstance(pr.get("head"), dict) else {}
            head_sha = head.get("sha") or pr.get("head_sha")
            labels = []
            for lab in pr.get("labels") or []:
                if isinstance(lab, dict) and lab.get("name"):
                    labels.append(str(lab["name"]))
                elif isinstance(lab, str):
                    labels.append(lab)
            # Lightweight review signals (best-effort; fail soft into reviewer path).
            rc_current = False
            approval_current = False
            approval_stale = False
            try:
                feedback = gitea_get_pr_review_feedback(
                    int(number), remote=remote, org=o, repo=r
                )
                if feedback.get("success"):
                    rc_current = bool(
                        feedback.get("has_blocking_change_requests")
                        and not feedback.get("review_feedback_stale")
                    )
                    # Stale RC means author pushed; reviewer still next.
                    if feedback.get("has_blocking_change_requests") and feedback.get(
                        "review_feedback_stale"
                    ):
                        approval_stale = False
            except Exception:
                pass
            mergeable = bool(pr.get("mergeable"))
            try:
                candidates.append(
                    allocator_service.WorkCandidate(
                        kind="pr",
                        number=int(number),
                        state="open",
                        labels=tuple(labels),
                        title=str(pr.get("title") or ""),
                        priority=10 if rc_current else 5,
                        head_sha=head_sha,
                        request_changes_current_head=rc_current,
                        approval_on_current_head=approval_current,
                        approval_stale=approval_stale,
                        mergeable=mergeable,
                    )
                )
            except Exception as exc:  # noqa: BLE001
                reasons.append(
                    f"skipped invalid PR candidate #{number}: {_redact(str(exc))}"
                )

    if include_issues:
        try:
            issues = api_get_all(
                f"{repo_api_url(h, o, r)}/issues?state=open&type=issues", auth
            ) or []
        except Exception as exc:  # noqa: BLE001
            # Fallback without type filter
            try:
                issues = api_get_all(
                    f"{repo_api_url(h, o, r)}/issues?state=open", auth
                ) or []
            except Exception as exc2:  # noqa: BLE001
                reasons.append(
                    f"failed to list open issues: {_redact(str(exc2))}"
                )
                issues = []
                inventory_complete = False

        # Live dependency evidence (#758 AC5). The complete open-issue listing
        # already proves which references are still open; anything absent from
        # it is confirmed by a targeted lookup rather than assumed closed, so a
        # deleted or unreachable reference fails closed instead of passing.
        open_issue_numbers = {
            int(i["number"])
            for i in issues
            if isinstance(i, dict)
            and i.get("number") is not None
            and i.get("pull_request") is None
        }
        dep_state_cache: dict[int, str | None] = {}

        def _issue_state(number: int) -> str | None:
            if number in open_issue_numbers:
                return allocator_dependencies.DEP_STATE_OPEN
            if number in dep_state_cache:
                return dep_state_cache[number]
            state: str | None = None
            try:
                data = api_request(
                    "GET", f"{repo_api_url(h, o, r)}/issues/{number}", auth
                )
                if isinstance(data, dict):
                    state = str(data.get("state") or "").strip().lower() or None
            except Exception:  # noqa: BLE001 — unavailable evidence fails closed
                state = None
            dep_state_cache[number] = state
            return state

        for issue in issues:
            if not isinstance(issue, dict):
                continue
            # Pull requests also appear in /issues on Gitea — skip them.
            if issue.get("pull_request") is not None:
                continue
            number = issue.get("number")
            if number is None:
                continue
            labels = []
            for lab in issue.get("labels") or []:
                if isinstance(lab, dict) and lab.get("name"):
                    labels.append(str(lab["name"]).lower())
                elif isinstance(lab, str):
                    labels.append(lab.lower())
            body = str(issue.get("body") or "")
            title = str(issue.get("title") or "")
            blocked = "status:blocked" in labels
            # #758: parse the canonical "Depends: #N, #N" declaration into
            # structured references and resolve each against live issue state.
            # Body substrings no longer decide dependency status.
            dep_refs = allocator_dependencies.parse_dependency_refs(body)
            dep_result = allocator_dependencies.resolve_dependency_state(
                dep_refs, _issue_state, subject=f"issue#{number}"
            )
            dep_unmet = dep_result["dependency_unmet"]
            dep_reason = dep_result["reason"]
            try:
                candidates.append(
                    allocator_service.WorkCandidate(
                        kind="issue",
                        number=int(number),
                        state="open",
                        labels=tuple(labels),
                        title=title,
                        priority=20 if "status:ready" in labels else 1,
                        blocked=blocked,
                        dependency_unmet=dep_unmet,
                        dependency_reason=dep_reason,
                    )
                )
            except Exception as exc:  # noqa: BLE001
                reasons.append(
                    f"skipped invalid issue candidate #{number}: {_redact(str(exc))}"
                )

    return candidates, reasons, inventory_complete


@mcp.tool()
def gitea_observability_list_projects(
    config_path: str | None = None,
    mappings_json: str | None = None,
) -> dict:
    """List configured Sentry/GlitchTip → Gitea project mappings (#612).

    Does not load provider tokens. Mappings come from
    ``GITEA_OBSERVABILITY_PROJECTS_JSON``,
    ``GITEA_OBSERVABILITY_PROJECTS_FILE``, or explicit arguments.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "projects": [],
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    try:
        projects = incident_bridge.load_project_mappings(
            config_path=config_path, mappings_json=mappings_json
        )
    except Exception as exc:  # noqa: BLE001
        return {
            "success": False,
            "projects": [],
            "reasons": [incident_bridge.redact_text(exc)],
        }
    return {
        "success": True,
        "projects": [p.as_dict() for p in projects],
        "count": len(projects),
        "reasons": [] if projects else ["no project mappings configured"],
        "note": (
            "Provider API tokens are never returned. "
            "Raw incidents are not assignable work (#612)."
        ),
    }


def _incident_recurrence_comment_fn(
    *,
    apply: bool,
    remote: str,
    host: str | None,
    org: str | None,
    repo: str | None,
    worktree_path: str | None = None,
):
    """Sanctioned issue-comment route for AC4 recurrence comments (#607).

    Returns ``None`` on dry runs (never comment) and when the active profile
    lacks ``gitea.issue.comment``. Withholding the callable degrades to "link
    updated, no comment" rather than failing the durable mapping, and the
    reconcile result records why the comment was withheld.
    """
    if not apply:
        return None
    if _profile_operation_gate("gitea.issue.comment"):
        return None

    def comment_fn(issue_number, body, g_org, g_repo):
        return gitea_create_issue_comment(
            issue_number=int(issue_number),
            body=body,
            remote=remote,
            host=host,
            org=g_org or org,
            repo=g_repo or repo,
            worktree_path=worktree_path,
        )

    return comment_fn


@mcp.tool()
def gitea_observability_reconcile_incident(
    observation_json: str,
    apply: bool = False,
    mappings_json: str | None = None,
    config_path: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    force_gitea_issue_number: int | None = None,
    worktree_path: str | None = None,
) -> dict:
    """Reconcile one Sentry/GlitchTip observation into Gitea + incident_links (#612).

    Phase-1: pass a sanitized observation as JSON. Dry-run is default
    (``apply=false``) — no Gitea mutation, no DB write.

    When ``apply=true``:
    * reuses existing ``incident_links`` row when present
    * otherwise creates a **normal Gitea issue** (never a raw work_item incident)
    * upserts the canonical ``incident_links`` row on the #613 control-plane DB

    Never assigns raw provider incidents to the #600 allocator.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "apply": bool(apply),
            "outcome": incident_bridge.OUTCOME_BLOCKED,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
            "raw_incident_assignable": False,
        }

    if apply:
        create_block = _profile_permission_block(
            task_capability_map.required_permission("create_issue"),
            number=None,
            remote=remote,
            host=host,
            org=org,
            repo=repo,
            org_explicit=org is not None,
            repo_explicit=repo is not None,
        )
        if create_block:
            return {
                "success": False,
                "apply": True,
                "outcome": incident_bridge.OUTCOME_BLOCKED,
                "reasons": create_block.get("reasons")
                if isinstance(create_block, dict)
                else [str(create_block)],
                "raw_incident_assignable": False,
            }

    try:
        observation = json.loads(observation_json)
    except Exception as exc:  # noqa: BLE001
        return {
            "success": False,
            "apply": bool(apply),
            "outcome": incident_bridge.OUTCOME_BLOCKED,
            "reasons": [
                f"invalid observation_json: {incident_bridge.redact_text(exc)} "
                "(fail closed)"
            ],
            "raw_incident_assignable": False,
        }

    try:
        mappings = incident_bridge.load_project_mappings(
            config_path=config_path, mappings_json=mappings_json
        )
    except Exception as exc:  # noqa: BLE001
        return {
            "success": False,
            "apply": bool(apply),
            "outcome": incident_bridge.OUTCOME_BLOCKED,
            "reasons": [incident_bridge.redact_text(exc)],
            "raw_incident_assignable": False,
        }

    # Allow org/repo overrides on the observation when not mapped.
    if isinstance(observation, dict):
        try:
            _, o_def, r_def = _resolve(remote, host, org, repo)
        except ValueError:
            o_def, r_def = org, repo
        observation.setdefault("gitea_org", o_def)
        observation.setdefault("gitea_repo", r_def)

    try:
        db = control_plane_db.ControlPlaneDB()
    except Exception as exc:  # noqa: BLE001
        return {
            "success": False,
            "apply": bool(apply),
            "outcome": incident_bridge.OUTCOME_BLOCKED,
            "reasons": [
                f"control-plane DB unavailable: {incident_bridge.redact_text(exc)} "
                "(fail closed, #613)"
            ],
            "raw_incident_assignable": False,
        }

    create_fn = None
    if apply and force_gitea_issue_number is None:

        def create_fn(title, body, labels, g_org, g_repo):
            return gitea_create_issue(
                title=title,
                body=body,
                remote=remote,
                host=host,
                org=g_org,
                repo=g_repo,
                labels=list(labels),
                issue_type="bug",
                initial_status="ready",
                require_workflow_labels=False,
                worktree_path=worktree_path,
            )

    comment_fn = _incident_recurrence_comment_fn(
        apply=bool(apply),
        remote=remote,
        host=host,
        org=org,
        repo=repo,
        worktree_path=worktree_path,
    )

    result = incident_bridge.reconcile_incident(
        db,
        observation=observation if isinstance(observation, dict) else {},
        mappings=mappings,
        apply=bool(apply),
        create_issue_fn=create_fn,
        comment_issue_fn=comment_fn,
        force_gitea_issue_number=force_gitea_issue_number,
    )
    result["remote"] = remote
    return result


@mcp.tool()
def gitea_observability_link_issue(
    observation_json: str,
    gitea_issue_number: int,
    apply: bool = False,
    mappings_json: str | None = None,
    config_path: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
) -> dict:
    """Explicitly link a provider observation to an existing Gitea issue (#612).

    Dry-run by default. ``apply=true`` upserts ``incident_links`` only — does
    not create a new issue. Fails closed on mapping/fingerprint conflicts.
    """
    return gitea_observability_reconcile_incident(
        observation_json=observation_json,
        apply=apply,
        mappings_json=mappings_json,
        config_path=config_path,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
        force_gitea_issue_number=int(gitea_issue_number),
        worktree_path=None,
    )


def _sentry_bridge_config(
    base_url: str | None = None,
    sentry_org: str | None = None,
    sentry_project: str | None = None,
    lookback: str | None = None,
    min_events: int | None = None,
) -> "sentry_incident_bridge.SentryBridgeConfig":
    """Env-backed Sentry bridge config with explicit per-call overrides."""
    return sentry_incident_bridge.config_with_overrides(
        sentry_incident_bridge.load_bridge_config(),
        base_url=base_url,
        org=sentry_org,
        project=sentry_project,
        lookback=lookback,
        min_events_for_issue=min_events,
    )


def _sentry_error_result(exc: "sentry_incident_bridge.SentryApiError") -> dict:
    """Convert a Sentry read failure into a redacted, fail-closed result."""
    payload = exc.as_dict()
    payload.update({"success": False, "reasons": [str(exc)]})
    return payload


def _sentry_find_issue(
    config: "sentry_incident_bridge.SentryBridgeConfig",
    sentry_issue_id: str,
    token: str,
) -> dict | None:
    """Locate one live Sentry issue by id within the configured project."""
    wanted = str(sentry_issue_id).strip()
    listing = sentry_incident_bridge.list_issues(
        config,
        token=token,
        query=f"issue.id:{wanted}",
        limit=1,
        max_pages=1,
    )
    for item in listing.get("issues", []):
        if str(item.get("id")) == wanted:
            return item
    return None


@mcp.tool()
def gitea_sentry_list_issues(
    query: str = "is:unresolved",
    limit: int = 25,
    max_pages: int = 10,
    cursor: str | None = None,
    environment: str | None = None,
    base_url: str | None = None,
    sentry_org: str | None = None,
    sentry_project: str | None = None,
    lookback: str | None = None,
) -> dict:
    """List unresolved Sentry issues from the self-hosted server (#607).

    Read-only. The auth token is read from ``SENTRY_AUTH_TOKEN`` in the
    environment only and is never returned. Results are sanitized (secrets
    redacted, local paths reduced to a category) before leaving this tool.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "issues": [],
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    config = _sentry_bridge_config(base_url, sentry_org, sentry_project, lookback)
    try:
        return sentry_incident_bridge.list_issues(
            config,
            token=sentry_incident_bridge.resolve_token(),
            query=query,
            limit=limit,
            max_pages=max_pages,
            cursor=cursor,
            environment=environment,
        )
    except sentry_incident_bridge.SentryApiError as exc:
        result = _sentry_error_result(exc)
        result["issues"] = []
        return result


@mcp.tool()
def gitea_sentry_get_issue_events(
    sentry_issue_id: str,
    limit: int = 10,
    base_url: str | None = None,
    sentry_org: str | None = None,
    sentry_project: str | None = None,
) -> dict:
    """Fetch sanitized recent events for one Sentry issue (#607).

    Read-only. Returns the latest event plus sanitized tags, environment,
    release, and timestamps. Sensitive tag keys are dropped.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "events": [],
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    config = _sentry_bridge_config(base_url, sentry_org, sentry_project)
    try:
        return sentry_incident_bridge.get_issue_events(
            config,
            sentry_issue_id,
            token=sentry_incident_bridge.resolve_token(),
            limit=limit,
        )
    except sentry_incident_bridge.SentryApiError as exc:
        result = _sentry_error_result(exc)
        result["events"] = []
        return result


@mcp.tool()
def gitea_sentry_reconcile_issue(
    sentry_issue_id: str,
    apply: bool = False,
    mappings_json: str | None = None,
    config_path: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    base_url: str | None = None,
    sentry_org: str | None = None,
    sentry_project: str | None = None,
    worktree_path: str | None = None,
) -> dict:
    """Reconcile one live Sentry issue into a durable Gitea issue (#607).

    Fetches the Sentry issue plus its latest event, converts it into a #612
    observation, then delegates dedupe/link/create to the sanctioned
    ``gitea_observability_reconcile_incident`` path. Dry-run by default.

    Dedupe basis: provider identity — provider, base URL, org, project, and
    Sentry issue id. This bridge does not populate ``fingerprint``, so
    fingerprint plays no part in its dedupe decisions.

    Gitea remains the source of truth; the raw Sentry incident is never an
    assignable control-plane work item.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "apply": bool(apply),
            "outcome": incident_bridge.OUTCOME_BLOCKED,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
            "raw_incident_assignable": False,
        }

    config = _sentry_bridge_config(base_url, sentry_org, sentry_project)
    token = sentry_incident_bridge.resolve_token()
    try:
        issue = _sentry_find_issue(config, sentry_issue_id, token)
        if issue is None:
            return {
                "success": False,
                "apply": bool(apply),
                "outcome": incident_bridge.OUTCOME_BLOCKED,
                "reasons": [
                    f"Sentry issue {sentry_issue_id} not found in the configured "
                    "project/window (fail closed)"
                ],
                "raw_incident_assignable": False,
            }
        latest_event = sentry_incident_bridge.get_issue_events(
            config, issue["id"], token=token, limit=1
        ).get("latest_event")
    except sentry_incident_bridge.SentryApiError as exc:
        result = _sentry_error_result(exc)
        result.update(
            {
                "apply": bool(apply),
                "outcome": incident_bridge.OUTCOME_BLOCKED,
                "raw_incident_assignable": False,
            }
        )
        return result

    observation = sentry_incident_bridge.observation_from_issue(
        issue, config, latest_event=latest_event
    )
    return gitea_observability_reconcile_incident(
        observation_json=json.dumps(observation),
        apply=apply,
        mappings_json=mappings_json,
        config_path=config_path,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
        worktree_path=worktree_path,
    )


@mcp.tool()
def gitea_sentry_link_gitea_issue(
    sentry_issue_id: str,
    gitea_issue_number: int,
    apply: bool = False,
    mappings_json: str | None = None,
    config_path: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    base_url: str | None = None,
    sentry_org: str | None = None,
    sentry_project: str | None = None,
) -> dict:
    """Link a live Sentry issue to an existing Gitea issue (#607).

    Dry-run by default. ``apply=true`` upserts the durable ``incident_links``
    mapping only — it never creates a new Gitea issue and never reopens one.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "apply": bool(apply),
            "outcome": incident_bridge.OUTCOME_BLOCKED,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
            "raw_incident_assignable": False,
        }

    config = _sentry_bridge_config(base_url, sentry_org, sentry_project)
    try:
        issue = _sentry_find_issue(
            config, sentry_issue_id, sentry_incident_bridge.resolve_token()
        )
    except sentry_incident_bridge.SentryApiError as exc:
        result = _sentry_error_result(exc)
        result.update(
            {
                "apply": bool(apply),
                "outcome": incident_bridge.OUTCOME_BLOCKED,
                "raw_incident_assignable": False,
            }
        )
        return result

    if issue is None:
        return {
            "success": False,
            "apply": bool(apply),
            "outcome": incident_bridge.OUTCOME_BLOCKED,
            "reasons": [
                f"Sentry issue {sentry_issue_id} not found in the configured "
                "project/window (fail closed)"
            ],
            "raw_incident_assignable": False,
        }

    observation = sentry_incident_bridge.observation_from_issue(issue, config)
    return gitea_observability_link_issue(
        observation_json=json.dumps(observation),
        gitea_issue_number=int(gitea_issue_number),
        apply=apply,
        mappings_json=mappings_json,
        config_path=config_path,
        remote=remote,
        host=host,
        org=org,
        repo=repo,
    )


@mcp.tool()
def gitea_sentry_watchdog(
    apply: bool = False,
    query: str = "is:unresolved",
    limit: int = 25,
    max_pages: int = 10,
    min_events: int | None = None,
    lookback: str | None = None,
    mappings_json: str | None = None,
    config_path: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    base_url: str | None = None,
    sentry_org: str | None = None,
    sentry_project: str | None = None,
    worktree_path: str | None = None,
) -> dict:
    """Scan Sentry and create/update durable Gitea issues for incidents (#607).

    Dry-run by default. ``apply=true`` additionally requires
    ``MCP_SENTRY_ISSUE_BRIDGE_ENABLED`` and issue-create permission. Each
    incident is deduped through the #612 ``incident_links`` substrate by
    provider identity (provider, base URL, org, project, Sentry issue id), so
    a recurring failure updates one issue instead of creating duplicates.

    When a linked issue sees continued events, a sanitized recurrence comment
    is posted through ``gitea_create_issue_comment`` (AC4). That requires
    ``gitea.issue.comment``; without it the link still updates and the result
    records that the comment was withheld.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "apply": bool(apply),
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
            "raw_incident_assignable": False,
        }

    if apply:
        create_block = _profile_permission_block(
            task_capability_map.required_permission("create_issue"),
            number=None,
            remote=remote,
            host=host,
            org=org,
            repo=repo,
            org_explicit=org is not None,
            repo_explicit=repo is not None,
        )
        if create_block:
            return {
                "success": False,
                "apply": True,
                "reasons": create_block.get("reasons")
                if isinstance(create_block, dict)
                else [str(create_block)],
                "raw_incident_assignable": False,
            }

    config = _sentry_bridge_config(
        base_url, sentry_org, sentry_project, lookback, min_events
    )

    try:
        mappings = incident_bridge.load_project_mappings(
            config_path=config_path, mappings_json=mappings_json
        )
    except Exception as exc:  # noqa: BLE001
        return {
            "success": False,
            "apply": bool(apply),
            "reasons": [incident_bridge.redact_text(exc)],
            "raw_incident_assignable": False,
        }

    try:
        db = control_plane_db.ControlPlaneDB()
    except Exception as exc:  # noqa: BLE001
        return {
            "success": False,
            "apply": bool(apply),
            "reasons": [
                f"control-plane DB unavailable: {incident_bridge.redact_text(exc)} "
                "(fail closed, #613)"
            ],
            "raw_incident_assignable": False,
        }

    try:
        _, o_def, r_def = _resolve(remote, host, org, repo)
    except ValueError:
        o_def, r_def = org, repo

    create_fn = None
    if apply:

        def create_fn(title, body, labels, g_org, g_repo):
            return gitea_create_issue(
                title=title,
                body=body,
                remote=remote,
                host=host,
                org=g_org,
                repo=g_repo,
                labels=list(labels),
                issue_type="bug",
                initial_status="ready",
                require_workflow_labels=False,
                worktree_path=worktree_path,
            )

    result = sentry_incident_bridge.watchdog(
        db,
        config,
        token=sentry_incident_bridge.resolve_token(),
        apply=bool(apply),
        mappings=mappings,
        gitea_org=o_def,
        gitea_repo=r_def,
        query=query,
        limit=limit,
        max_pages=max_pages,
        create_issue_fn=create_fn,
        comment_issue_fn=_incident_recurrence_comment_fn(
            apply=bool(apply),
            remote=remote,
            host=host,
            org=org,
            repo=repo,
            worktree_path=worktree_path,
        ),
    )
    result["remote"] = remote
    return result


@mcp.tool()
def gitea_allocate_next_work(
    apply: bool = False,
    role: str | None = None,
    session_id: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    include_issues: bool = True,
    include_prs: bool = True,
    candidates_json: str | None = None,
    limit: int = 50,
) -> dict:
    """Controller-owned next-work allocator using the #613 control-plane DB (#600).

    Workers must not self-select exclusive work under the standard multi-LLM
    workflow. Call this tool instead.

    *apply=false* (default): dry-run selection only — no assignment/lease.
    *apply=true*: atomically assign + lease the selected Gitea issue/PR via
    ``ControlPlaneDB.assign_and_lease`` (never file locks or comment-only
    leases as the coordination source).

    Outcomes include: ``assigned_work``, ``preview``, ``wait``,
    ``blocked_by_terminal_path``, ``no_safe_work``, ``role_ineligible``.

    *candidates_json* may inject a JSON list of candidate dicts (tests /
    controller overrides). When omitted, open issues/PRs are loaded from Gitea.

    #612 remains downstream: raw monitoring incidents are never candidates.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "outcome": allocator_service.OUTCOME_NO_SAFE,
            "apply": bool(apply),
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
            "assignment": None,
            "substrate": "control_plane_db",
            "file_lock_only": False,
            "comment_lease_only": False,
        }

    try:
        h, o, r = _resolve(remote, host, org, repo)
    except ValueError as exc:
        return {
            "success": False,
            "outcome": allocator_service.OUTCOME_NO_SAFE,
            "reasons": [str(exc)],
            "assignment": None,
            "substrate": "control_plane_db",
        }

    profile = get_profile()
    profile_name = (profile.get("profile_name") or "").strip() or None
    active_role = _profile_role_kind(profile)
    role_in = (role or active_role or "").strip() or "author"

    try:
        username = _authenticated_username(h)
    except Exception:
        username = None

    db, db_errs = _control_plane_db_or_error()
    if db is None:
        return {
            "success": False,
            "outcome": allocator_service.OUTCOME_NO_SAFE,
            "apply": bool(apply),
            "reasons": db_errs,
            "assignment": None,
            "substrate": "control_plane_db",
            "file_lock_only": False,
            "comment_lease_only": False,
        }

    inv_reasons: list[str] = []
    candidates: list[Any] = []
    if candidates_json:
        try:
            raw = json.loads(candidates_json)
            if not isinstance(raw, list):
                raise ValueError("candidates_json must be a JSON list")
            for item in raw:
                if not isinstance(item, dict):
                    continue
                candidates.append(allocator_service.candidate_from_dict(item))
        except Exception as exc:  # noqa: BLE001
            return {
                "success": False,
                "outcome": allocator_service.OUTCOME_NO_SAFE,
                "apply": bool(apply),
                "reasons": [
                    f"invalid candidates_json: {_redact(str(exc))} (fail closed)"
                ],
                "assignment": None,
                "substrate": "control_plane_db",
            }
    else:
        candidates, inv_reasons, inventory_complete = _allocator_candidates_from_gitea(
            remote=remote,
            host=host,
            org=o,
            repo=r,
            include_issues=include_issues,
            include_prs=include_prs,
        )
        # #758 AC3: ranking a silently short inventory could select the wrong
        # candidate, so an incomplete listing is a fail-closed stop.
        if not inventory_complete:
            return {
                "success": False,
                "outcome": allocator_service.OUTCOME_NO_SAFE,
                "apply": bool(apply),
                "reasons": inv_reasons
                + [
                    "candidate inventory is incomplete; refusing to rank a "
                    "partial candidate set (fail closed, #758)"
                ],
                "inventory_complete": False,
                "assignment": None,
                "substrate": "control_plane_db",
                "file_lock_only": False,
                "comment_lease_only": False,
            }

    sid = (session_id or "").strip() or (
        f"{profile_name or 'session'}-{os.getpid()}-{uuid.uuid4().hex[:8]}"
    )
    result = allocator_service.allocate_next_work(
        db,
        session_id=sid,
        role=role_in,
        remote=remote if remote in REMOTES else remote,
        org=o,
        repo=r,
        candidates=candidates,
        apply=bool(apply),
        profile_name=profile_name,
        username=username,
    )
    if inv_reasons:
        result.setdefault("inventory_warnings", inv_reasons)
    result["candidate_count"] = len(candidates)
    result["inventory_source"] = (
        "candidates_json" if candidates_json else "gitea_live"
    )
    result["inventory_complete"] = True
    result["selection_policy"] = allocator_service.SELECTION_POLICY
    # #758 AC2/AC10: `limit` bounds the reported skip list only — selection has
    # already happened over the complete inventory above. Any truncation here is
    # stated explicitly so a shortened report is never read as full coverage.
    reported_limit = max(1, int(limit))
    skipped_all = result.get("skipped") or []
    result["skipped_total"] = len(skipped_all)
    result["limit_applies_to"] = "reported_skip_list_only"
    if len(skipped_all) > reported_limit:
        result["skipped"] = skipped_all[:reported_limit]
        result["skipped_report_truncated"] = True
        result.setdefault("reasons", []).append(
            f"skip list truncated for reporting: showing {reported_limit} of "
            f"{len(skipped_all)} skipped candidates (selection unaffected)"
        )
    else:
        result["skipped_report_truncated"] = False
    # #606: watchdog check-ins for the recurring jobs this allocator run
    # performs — global stale-lease expiry, terminal-lock lookup, and the
    # allocator itself. Best-effort; a failed selection reports "error".
    _alloc_ok = bool(result.get("success"))
    sentry_observability.monitor_checkin(
        "allocator_health", "ok" if _alloc_ok else "error"
    )
    if _alloc_ok:
        # These two scans complete inside allocate_next_work before selection;
        # a successful result proves both ran.
        sentry_observability.monitor_checkin("stale_lease_scan", "ok")
        sentry_observability.monitor_checkin("terminal_lock_scan", "ok")
    return result




# ── Control-plane lease lifecycle (#601) ──────────────────────────────────────


@mcp.tool()
def gitea_list_workflow_leases(
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    role: str | None = None,
    include_non_active: bool = False,
    limit: int = 100,
) -> dict:
    """List control-plane leases as first-class workflow state (#601).

    Read-only. Returns active (default) or all leases from the #613 DB substrate.
    File locks and comment-only leases are not listed as authoritative here.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "reasons": read_block,
            "leases": [],
            "permission_report": _permission_block_report("gitea.read"),
        }
    try:
        h, o, r = _resolve(remote, host, org, repo)
    except ValueError as exc:
        return {"success": False, "reasons": [str(exc)], "leases": []}
    db, errs = _control_plane_db_or_error()
    if db is None:
        return {"success": False, "reasons": errs, "leases": []}
    result = lease_lifecycle.list_active_leases(
        db,
        remote=remote if remote in REMOTES else remote,
        org=o,
        repo=r,
        role=role,
        include_non_active=bool(include_non_active),
        limit=int(limit),
    )
    result["remote"] = remote
    result["org"] = o
    result["repo"] = r
    return result


@mcp.tool()
def gitea_workflow_dashboard(
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    include_issues: bool = True,
    include_prs: bool = True,
    include_non_active_leases: bool = True,
    candidates_json: str | None = None,
    limit: int = 100,
) -> dict:
    """Read-only MCP workflow dashboard: queues, leases, blockers, next safe action (#605).

    Machine-readable and human-readable. Never assigns work — exclusive work
    still requires ``gitea_allocate_next_work``. Never presents blocked or
    terminal-locked items as safe next work.

    *candidates_json* may inject a JSON list of candidate dicts (tests /
    offline fixtures). When omitted, open issues/PRs are loaded from Gitea.
    Incomplete live inventory fails closed (no safe suggestions).
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "read_only": True,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
            "primary_next_safe_action": None,
        }

    try:
        h, o, r = _resolve(remote, host, org, repo)
    except ValueError as exc:
        return {
            "success": False,
            "read_only": True,
            "reasons": [str(exc)],
            "primary_next_safe_action": None,
        }

    inv_reasons: list[str] = []
    candidates: list[Any] = []
    inventory_complete = True
    if candidates_json:
        try:
            raw = json.loads(candidates_json)
            if not isinstance(raw, list):
                raise ValueError("candidates_json must be a JSON list")
            for item in raw:
                if not isinstance(item, dict):
                    continue
                candidates.append(allocator_service.candidate_from_dict(item))
        except Exception as exc:  # noqa: BLE001
            return {
                "success": False,
                "read_only": True,
                "inventory_complete": False,
                "reasons": [
                    f"invalid candidates_json: {_redact(str(exc))} (fail closed)"
                ],
                "primary_next_safe_action": None,
            }
    else:
        candidates, inv_reasons, inventory_complete = _allocator_candidates_from_gitea(
            remote=remote,
            host=host,
            org=o,
            repo=r,
            include_issues=include_issues,
            include_prs=include_prs,
        )

    leases: list[dict] = []
    terminal_pr: int | None = None
    terminal_lock: dict | None = None
    db, db_errs = _control_plane_db_or_error()
    if db is None:
        inv_reasons.extend(db_errs or ["control-plane DB unavailable"])
        # Leases/terminal lock unavailable — still render queues fail-closed
        # for safe suggestions when inventory is incomplete; when inventory is
        # complete, surface lease gap in reasons without inventing leases.
        inv_reasons.append(
            "control-plane DB unavailable; lease/terminal sections empty"
        )
    else:
        try:
            lease_result = lease_lifecycle.list_active_leases(
                db,
                remote=remote if remote in REMOTES else remote,
                org=o,
                repo=r,
                role=None,
                include_non_active=bool(include_non_active_leases),
                limit=max(1, int(limit)),
            )
            leases = list(lease_result.get("leases") or [])
        except Exception as exc:  # noqa: BLE001
            inv_reasons.append(
                f"lease listing failed: {_redact(str(exc))}"
            )
        try:
            terminal = db.get_active_terminal_lock(
                remote=remote if remote in REMOTES else remote,
                org=o,
                repo=r,
            )
            if terminal:
                terminal_lock = dict(terminal)
                terminal_pr = int(terminal["terminal_pr"])
        except Exception as exc:  # noqa: BLE001
            inv_reasons.append(
                f"terminal lock lookup failed: {_redact(str(exc))}"
            )

    snap = workflow_dashboard.build_workflow_dashboard(
        candidates=candidates,
        remote=remote if remote in REMOTES else remote,
        org=o,
        repo=r,
        leases=leases,
        terminal_pr=terminal_pr,
        terminal_lock=terminal_lock,
        inventory_complete=inventory_complete,
        inventory_reasons=inv_reasons,
    )
    payload = snap.as_dict()
    payload["inventory_source"] = (
        "candidates_json" if candidates_json else "gitea_live"
    )
    payload["limit_applies_to"] = "lease_listing_only"
    return payload


@mcp.tool()
def gitea_inspect_workflow_lease(
    lease_id: str,
    session_id: str | None = None,
    worktree_path: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
) -> dict:
    """Inspect one control-plane lease with safe_next_action (#601).

    Distinguishes owner-resume, wait-foreign, reclaim-expired, abandon-allowed,
    and stale prompt ids. Control-plane DB is authoritative.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    db, errs = _control_plane_db_or_error()
    if db is None:
        return {"success": False, "reasons": errs}
    profile = get_profile()
    profile_name = (profile.get("profile_name") or "").strip() or "session"
    sid = (session_id or "").strip() or f"{profile_name}-{os.getpid()}"
    return lease_lifecycle.inspect_lease(
        db,
        lease_id,
        caller_session_id=sid,
        caller_worktree=worktree_path,
    )


@mcp.tool()
def gitea_adopt_workflow_lease(
    lease_id: str,
    session_id: str | None = None,
    role: str | None = None,
    worktree_path: str | None = None,
    expected_head_sha: str | None = None,
    operator_authorized: bool = False,
    remote: str = "dadeschools",
    host: str | None = None,
) -> dict:
    """Adopt a control-plane lease through the sanctioned path (#601).

    Same-owner resume refreshes provenance. Foreign active leases are refused.
    Expired leases may be reclaimed; provenance records adopted_from/by.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    db, errs = _control_plane_db_or_error()
    if db is None:
        return {"success": False, "reasons": errs}
    profile = get_profile()
    profile_name = (profile.get("profile_name") or "").strip() or "session"
    active_role = _profile_role_kind(profile) or "author"
    sid = (session_id or "").strip() or (
        f"{profile_name}-{os.getpid()}-{uuid.uuid4().hex[:8]}"
    )
    try:
        return lease_lifecycle.adopt_lease(
            db,
            lease_id=lease_id,
            adopter_session_id=sid,
            role=(role or active_role).strip() or "author",
            worktree_path=worktree_path,
            expected_head_sha=expected_head_sha,
            owner_pid=os.getpid(),
            operator_authorized=bool(operator_authorized),
        )
    except (lease_lifecycle.LeaseLifecycleError, control_plane_db.ControlPlaneError) as exc:
        return {
            "success": False,
            "outcome": "blocked",
            "reasons": [_redact(str(exc))],
            "lease_id": lease_id,
            "authoritative_source": "control_plane_db",
            "file_lock_only": False,
            "comment_lease_only": False,
        }


@mcp.tool()
def gitea_release_workflow_lease(
    lease_id: str,
    session_id: str,
    remote: str = "dadeschools",
    host: str | None = None,
) -> dict:
    """Explicitly release a control-plane lease owned by *session_id* (#601).

    Recorded in the DB event log with release provenance. Foreign release fails closed.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    db, errs = _control_plane_db_or_error()
    if db is None:
        return {"success": False, "reasons": errs}
    try:
        return lease_lifecycle.release_lease(
            db, lease_id=lease_id, session_id=session_id
        )
    except (lease_lifecycle.LeaseLifecycleError, control_plane_db.ControlPlaneError) as exc:
        return {
            "success": False,
            "outcome": "blocked",
            "reasons": [_redact(str(exc))],
            "lease_id": lease_id,
            "authoritative_source": "control_plane_db",
        }


@mcp.tool()
def gitea_expire_workflow_leases(
    remote: str = "dadeschools",
    host: str | None = None,
) -> dict:
    """Expire stale control-plane leases whose expires_at has passed (#601).

    Deterministic reclaim prerequisite. Does not steal active non-expired leases.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    db, errs = _control_plane_db_or_error()
    if db is None:
        return {"success": False, "reasons": errs}
    return lease_lifecycle.expire_leases(db)


@mcp.tool()
def gitea_abandon_workflow_lease(
    lease_id: str,
    session_id: str | None = None,
    dead_process: bool = False,
    missing_worktree: bool = False,
    no_open_pr: bool = False,
    no_live_mutation_risk: bool = False,
    operator_authorized: bool = False,
    worktree_path: str | None = None,
    owner_pid: int | None = None,
    notes: str = "",
    remote: str = "dadeschools",
    host: str | None = None,
) -> dict:
    """Abandon a control-plane lease with required proof (#601).

    Requires (dead_process or missing_worktree) and no_live_mutation_risk.
    Foreign abandon also needs operator_authorized or
    (dead_process and missing_worktree and no_open_pr).
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    db, errs = _control_plane_db_or_error()
    if db is None:
        return {"success": False, "reasons": errs}
    profile = get_profile()
    profile_name = (profile.get("profile_name") or "").strip() or "session"
    sid = (session_id or "").strip() or f"{profile_name}-{os.getpid()}"
    proof = lease_lifecycle.AbandonProof(
        dead_process=bool(dead_process),
        missing_worktree=bool(missing_worktree),
        no_open_pr=bool(no_open_pr),
        no_live_mutation_risk=bool(no_live_mutation_risk),
        operator_authorized=bool(operator_authorized),
        worktree_path=worktree_path,
        owner_pid=owner_pid,
        notes=notes or "",
    )
    try:
        return lease_lifecycle.abandon_lease(
            db,
            lease_id=lease_id,
            requester_session_id=sid,
            proof=proof,
        )
    except (lease_lifecycle.LeaseLifecycleError, control_plane_db.ControlPlaneError) as exc:
        return {
            "success": False,
            "outcome": "blocked",
            "reasons": [_redact(str(exc))],
            "lease_id": lease_id,
            "authoritative_source": "control_plane_db",
            "file_lock_only": False,
            "comment_lease_only": False,
        }


@mcp.tool()
def gitea_reclaim_expired_workflow_lease(
    lease_id: str,
    session_id: str | None = None,
    role: str | None = None,
    worktree_path: str | None = None,
    expected_head_sha: str | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
) -> dict:
    """Reclaim an expired control-plane lease for a new/owner session (#601).

    Deterministic: expire markers applied, then atomic assign+lease with provenance.
    Active foreign leases are refused.
    """
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }
    db, errs = _control_plane_db_or_error()
    if db is None:
        return {"success": False, "reasons": errs}
    profile = get_profile()
    profile_name = (profile.get("profile_name") or "").strip() or "session"
    active_role = _profile_role_kind(profile) or "author"
    sid = (session_id or "").strip() or (
        f"{profile_name}-{os.getpid()}-{uuid.uuid4().hex[:8]}"
    )
    try:
        return lease_lifecycle.reclaim_expired_lease(
            db,
            lease_id=lease_id,
            session_id=sid,
            role=(role or active_role).strip() or "author",
            worktree_path=worktree_path,
            expected_head_sha=expected_head_sha,
        )
    except (lease_lifecycle.LeaseLifecycleError, control_plane_db.ControlPlaneError) as exc:
        return {
            "success": False,
            "outcome": "blocked",
            "reasons": [_redact(str(exc))],
            "lease_id": lease_id,
            "authoritative_source": "control_plane_db",
        }


@mcp.tool()
def gitea_quarantine_contaminated_review(
    pr_number: int,
    review_id: int,
    confirmation: str,
    reason: str,
    reviewed_head_sha: str,
    incident_issue: int = 695,
    forensic_comment_ids: list[int] | None = None,
    remote: str = "dadeschools",
    host: str | None = None,
    org: str | None = None,
    repo: str | None = None,
    post_audit_comment: bool = True,
) -> dict:
    """Controller quarantine of a contaminated formal review (#695 AC8).

    Writes a durable quarantine record that live feedback / eligibility /
    merge gates honor by ``review_id``. Forensic Gitea review objects and
    historical comments are retained (never deleted).

    **Native transport only.** Untrusted local imports / offline scripts
    cannot create quarantine records. Confirmation must equal exactly
    ``QUARANTINE CONTAMINATED REVIEW <review_id> PR <pr_number>``.

    Restricted to reconciler / merger / controller profiles (not author or
    the contaminated reviewer acting alone). Does not auto-apply to review
    427 until an independent adversarial reviewer and controller deployment
    of this tooling have completed.
    """
    # Fail closed outside production native MCP before any mutation assessment.
    # Test-mode bootstrap must never reach this production mutation endpoint (#695).
    try:
        mcp_daemon_guard.assert_production_mutation_runtime(
            "gitea_quarantine_contaminated_review"
        )
    except mcp_daemon_guard.UnsanctionedRuntimeError as exc:
        return {
            "success": False,
            "quarantined": False,
            "reasons": [str(exc)],
            "native_runtime": mcp_daemon_guard.native_runtime_status(),
        }

    assessment = review_quarantine.assess_quarantine_write(
        confirmation=confirmation,
        pr_number=pr_number,
        review_id=review_id,
        reason=reason,
        native_required=True,
    )
    if not assessment.get("allowed"):
        return {
            "success": False,
            "quarantined": False,
            "reasons": assessment.get("reasons") or [],
            "expected_confirmation": assessment.get("expected_confirmation"),
            "native_runtime": mcp_daemon_guard.native_runtime_status(),
        }

    profile = get_profile()
    profile_name = (profile.get("profile_name") or "").strip()
    role = _actual_profile_role()
    allowed_roles = {"reconciler", "merger"}
    controller_named = "controller" in profile_name.lower()
    if role not in allowed_roles and not controller_named:
        return {
            "success": False,
            "quarantined": False,
            "reasons": [
                f"quarantine requires reconciler/merger/controller profile "
                f"(active role={role!r}, profile={profile_name!r}); "
                "author/reviewer-only sessions cannot quarantine (#695)"
            ],
            "native_runtime": mcp_daemon_guard.native_runtime_status(),
        }

    comment_block = _profile_operation_gate("gitea.pr.comment")
    if comment_block and post_audit_comment:
        return {
            "success": False,
            "quarantined": False,
            "reasons": comment_block,
            "permission_report": _permission_block_report("gitea.pr.comment"),
        }
    read_block = _profile_operation_gate("gitea.read")
    if read_block:
        return {
            "success": False,
            "quarantined": False,
            "reasons": read_block,
            "permission_report": _permission_block_report("gitea.read"),
        }

    h, o, r = _resolve(remote, host, org, repo)
    auth = _auth(h)
    try:
        identity = _authenticated_username(h) or profile.get("username") or ""
    except Exception:
        identity = profile.get("username") or ""

    # Verify review exists (forensic retention — do not dismiss via Gitea API).
    try:
        reviews = (
            api_request(
                "GET",
                f"{repo_api_url(h, o, r)}/pulls/{pr_number}/reviews",
                auth,
            )
            or []
        )
    except Exception as exc:  # noqa: BLE001
        return {
            "success": False,
            "quarantined": False,
            "reasons": [
                f"could not list PR reviews before quarantine (fail closed): "
                f"{_redact(str(exc))}"
            ],
        }
    match = None
    for rv in reviews:
        if int(rv.get("id") or 0) == int(review_id):
            match = rv
            break
    if match is None:
        return {
            "success": False,
            "quarantined": False,
            "reasons": [
                f"review_id {review_id} not found on PR #{pr_number} "
                f"(fail closed; refuse quarantine of missing review)"
            ],
        }
    live_head = (match.get("commit_id") or "").strip().lower()
    want_head = (reviewed_head_sha or "").strip().lower()
    if want_head and live_head and want_head != live_head:
        return {
            "success": False,
            "quarantined": False,
            "reasons": [
                f"reviewed_head_sha {want_head[:12]}… does not match review "
                f"commit_id {live_head[:12]}… (fail closed, #695)"
            ],
        }

    record = review_quarantine.build_quarantine_record(
        remote=remote,
        org=o,
        repo=r,
        pr_number=pr_number,
        review_id=review_id,
        reviewed_head_sha=want_head or live_head,
        reason=reason,
        actor_username=identity,
        profile_name=profile_name,
        incident_issue=incident_issue,
        forensic_comment_ids=forensic_comment_ids,
    )
    try:
        written = review_quarantine.write_quarantine_record(record)
    except mcp_daemon_guard.UnsanctionedRuntimeError as exc:
        return {
            "success": False,
            "quarantined": False,
            "reasons": [str(exc)],
            "native_runtime": mcp_daemon_guard.native_runtime_status(),
        }
    except OSError as exc:
        return {
            "success": False,
            "quarantined": False,
            "reasons": [f"quarantine persist failed: {_redact(str(exc))}"],
        }

    audit_comment_id = None
    if post_audit_comment:
        body = review_quarantine.format_quarantine_audit_comment(record)
        try:
            with _audited(
                "comment_pr",
                host=h,
                remote=remote,
                org=o,
                repo=r,
                pr_number=pr_number,
                request_metadata={"source": "quarantine_contaminated_review"},
            ):
                posted = api_request(
                    "POST",
                    f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments",
                    auth,
                    {"body": body},
                )
            if isinstance(posted, dict):
                audit_comment_id = posted.get("id")
        except Exception as exc:  # noqa: BLE001
            return {
                "success": True,
                "quarantined": True,
                "review_id": review_id,
                "pr_number": pr_number,
                "path": written.get("path"),
                "audit_comment_id": None,
                "warnings": [
                    f"quarantine written but audit comment failed: "
                    f"{_redact(str(exc))}"
                ],
                "record": {
                    k: v
                    for k, v in record.items()
                    if k != "native_provenance"
                } | {
                    "native_provenance": record.get("native_provenance"),
                },
                "native_runtime": mcp_daemon_guard.native_runtime_status(),
            }

    return {
        "success": True,
        "quarantined": True,
        "review_id": review_id,
        "pr_number": pr_number,
        "path": written.get("path"),
        "audit_comment_id": audit_comment_id,
        "retain_forensic_evidence": True,
        "merge_authorization": "void",
        "record": record,
        "native_runtime": mcp_daemon_guard.native_runtime_status(),
        "reasons": [
            f"review_id {review_id} quarantined for PR #{pr_number}; "
            "merge authorization void; forensic evidence retained (#695)"
        ],
    }


# ── Entry point ───────────────────────────────────────────────────────────────

if __name__ == "__main__":
    # #558 / #695: claim the resolved canonical entrypoint, then bind the live
    # native MCP transport lifecycle before any tool dispatch. Env vars,
    # basename-only stack frames, and import-only launch cannot reconstruct
    # native transport; offline imports / standalone scripts fail closed.
    mcp_daemon_guard.mark_sanctioned_daemon()
    mcp_daemon_guard.bind_native_mcp_transport(transport="stdio")
    # Lock this session's launch profile into the environment so child CLI
    # processes (e.g. review_pr.py) can detect and refuse profile
    # side-channel overrides (#199).
    _export_session_profile_lock()
    # #702: an auto-reconnected daemon inherits GITEA_ACTIVE_WORKTREE from its
    # parent environment. Validate it at boot and clear it only when provably
    # stale (missing path); anything less certain is surfaced, not cleared.
    try:
        _assess_stale_active_binding(auto_recover=True)
    except Exception:
        pass
    # #606: optional self-hosted Sentry observability. No-op unless
    # MCP_SENTRY_ENABLED is truthy and SENTRY_DSN is set; never blocks startup.
    _sentry_status = sentry_observability.init_sentry()
    sys.stderr.write(
        f"--- Sentry observability: {_sentry_status.get('reason')} ---\n"
    )
    mcp.run(transport="stdio")
