"""Plan management commands for CleverAgents CLI.

The ``agents plan`` command group manages plans in the CleverAgents v3
plan lifecycle.

## Commands

| Command                       | Description                             |
|-------------------------------|-----------------------------------------|
| ``agents plan use``           | Create plan from action + project(s)    |
| ``agents plan list``          | List plans with optional filters        |
| ``agents plan status``        | Show plan status / details              |
| ``agents plan execute``       | Run phase-aware plan execution          |
| ``agents plan apply``         | Transition to Apply phase               |
| ``agents plan cancel``        | Cancel a non-terminal plan              |
| ``agents plan diff``          | Show ChangeSet as unified diff          |
| ``agents plan artifacts``     | Show ChangeSet ID, sandbox refs, summary|
| ``agents plan errors``        | Show error decisions + recovery hints   |
"""

from __future__ import annotations

import contextlib
import json
import os
import re
import shutil
import time
from contextlib import suppress
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Any, Literal, cast

import structlog
import typer
from rich.console import Console
from rich.markup import escape as rich_escape
from rich.panel import Panel
from rich.table import Table
from sqlalchemy.exc import SQLAlchemyError

from cleveragents.a2a.models import A2aRequest
from cleveragents.application.container import get_container
from cleveragents.cli.formatting import OutputFormat, format_output
from cleveragents.core.error_handling import redact_error_details
from cleveragents.core.exceptions import (
    CleverAgentsError,
    NotFoundError,
    PlanError,
    ValidationError,
)
from cleveragents.domain.models.core.error_recovery import (
    ErrorCategory,
    classify_error,
)
from cleveragents.domain.models.core.plan import (
    ExecutionEnvPriority,
    PlanPhase,
    ProcessingState,
)
from cleveragents.domain.models.core.plan import Plan as LifecyclePlan
from cleveragents.infrastructure.sandbox.git_worktree import (
    GitWorktreeSandbox,
)

# Regex for validating namespaced actor names (namespace/name format)
_NAMESPACED_ACTOR_RE = re.compile(r"^[a-z][a-z0-9-]*/[a-z][a-z0-9._-]*$")

# Regex for validating ULID format (26 characters, Crockford base32 alphabet)
# ULIDs use the alphabet: 0123456789ABCDEFGHJKMNPQRSTVWXYZ (case-insensitive)
_PLAN_ULID_RE = re.compile(r"^[0-9A-HJKMNP-TV-Z]{26}$", re.IGNORECASE)

# Actionable error message shown when a non-ULID is passed to a v3 command
_ULID_VALIDATION_ERROR_MSG = (
    "The v3 plan lifecycle expects a ULID identifier "
    "(e.g., 01HXM8C2ZK4Q7C2B3F2R4VYV6J), not a plan name.\n\n"
    "Possible causes:\n"
    "  1. You created a plan with 'agents tell' (legacy workflow) and are\n"
    "     trying to reference it with a v3 command. These workflows are\n"
    "     incompatible and cannot be mixed — legacy plans exist only in the\n"
    "     legacy storage system and are invisible to v3 commands.\n"
    "  2. You referenced the wrong plan ID.\n\n"
    "To use the v3 workflow:\n"
    "  - Run 'agents plan use <action> <project>' to create a v3 plan\n"
    "    (this returns a ULID you can use with subsequent commands).\n"
    "  - Run 'agents plan execute <PLAN_ID>' to execute it.\n"
    "  - Run 'agents plan apply <PLAN_ID>' to apply changes.\n\n"
    "Legacy commands ('agents tell', 'agents build') operate in a separate\n"
    "system and cannot be mixed with v3 commands."
)

logger = structlog.get_logger(__name__)


def _notify_facade(operation: str, params: dict[str, Any]) -> None:
    """Best-effort A2A facade notification for protocol bookkeeping.

    The facade dispatch is fire-and-forget — if the facade is not
    available or fails, the operation continues via direct service
    calls.  This ensures the A2A protocol data flow is respected
    without breaking existing CLI functionality.

    stdout/stderr are redirected during facade construction to prevent
    structlog or Rich output from polluting CLI output captured by
    test runners.
    """
    try:
        import contextlib
        import io

        with (
            contextlib.redirect_stdout(io.StringIO()),
            contextlib.redirect_stderr(io.StringIO()),
        ):
            from cleveragents.a2a.cli_bootstrap import get_facade

            facade = get_facade()
            request = A2aRequest(method=operation, params=params)
            facade.dispatch(request)
    except Exception:
        pass


def validate_namespaced_actor(value: str, flag_name: str) -> str:
    """Validate that an actor name follows ``namespace/name`` format.

    Args:
        value: The actor name string to validate.
        flag_name: CLI flag name for error messages.

    Returns:
        The validated actor name (unchanged).

    Raises:
        ValidationError: If the value does not match namespace/name.
    """
    if not _NAMESPACED_ACTOR_RE.match(value):
        raise ValidationError(
            f"Actor '{value}' for {flag_name} must follow "
            f"'namespace/name' format (e.g., 'openai/gpt-4')."
        )
    return value


def _format_relative_time(dt: datetime) -> str:
    """Format a datetime as a human-readable relative time string.

    Returns a string like "42 minutes ago", "2 hours ago", "3 days ago",
    or "just now" for very recent timestamps.

    Args:
        dt: The datetime to format (timezone-aware or naive UTC).

    Returns:
        A human-readable relative time string.
    """
    from datetime import UTC

    now = datetime.now(UTC)
    # Normalise naive datetimes to UTC
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=UTC)
    delta = now - dt
    total_seconds = int(delta.total_seconds())

    # Guard against future timestamps (e.g. clock skew between CLI host and server)
    if total_seconds < 0:
        return "just now"
    if total_seconds < 60:
        return "just now"
    if total_seconds < 3600:
        minutes = total_seconds // 60
        return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
    if total_seconds < 86400:
        hours = total_seconds // 3600
        return f"{hours} hour{'s' if hours != 1 else ''} ago"
    days = total_seconds // 86400
    return f"{days} day{'s' if days != 1 else ''} ago"


def _validate_plan_ulid(plan_id: str) -> str:
    """Validate that ``plan_id`` is a valid ULID before querying v3 storage.

    The v3 plan lifecycle exclusively uses ULIDs as plan identifiers.
    Passing a human-readable name (e.g., from the legacy ``agents tell``
    workflow) will always fail with a confusing "Plan not found" error
    because the two systems use completely separate storage.

    This function detects that mismatch early and provides an actionable
    error message explaining the workflow incompatibility.

    Args:
        plan_id: The plan identifier string to validate.

    Returns:
        The validated ``plan_id`` (unchanged) if it is a valid ULID.

    Raises:
        ValidationError: If ``plan_id`` does not match the ULID format,
            with a message explaining the legacy/v3 workflow incompatibility
            and the correct migration path.
    """
    if not _PLAN_ULID_RE.match(plan_id):
        raise ValidationError(
            f"Plan '{plan_id}' not found.\n\n" + _ULID_VALIDATION_ERROR_MSG
        )
    return plan_id


if TYPE_CHECKING:
    from cleveragents.application.services.plan_apply_service import (
        PlanApplyService,
    )
    from cleveragents.application.services.plan_lifecycle_service import (
        PlanLifecycleService,
    )
    from cleveragents.domain.models.core import Project
    from cleveragents.domain.models.core.decision import Decision

# Create sub-app for plan commands
app = typer.Typer(
    help=(
        "V3 Plan Lifecycle: Create plans with 'use', execute with 'execute', "
        "apply changes with 'apply'. (Actor required; set default via "
        "'agents actor set-default')"
    )
)
console = Console()


# Reusable --format option description
_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)"


def _plan_spec_dict(plan: Any) -> dict[str, object]:
    """Return plan data using spec field names.

    Keys: plan_id, namespaced_name, phase, processing_state,
    project_links, arguments, automation_profile, action_name,
    execution_environment, execution_env_priority.
    """
    from cleveragents.domain.models.core.plan import (
        ExecutionEnvPriority,
    )
    from cleveragents.domain.models.core.plan import (
        Plan as LifecyclePlan,
    )

    if isinstance(plan, LifecyclePlan):
        result: dict[str, object] = {
            "plan_id": plan.identity.plan_id,
            "namespaced_name": str(plan.namespaced_name),
            "phase": plan.phase.value,
            "processing_state": plan.processing_state.value,
            # Spec §Example 7 uses ``jq -r '.state'`` — provide a
            # convenience alias so both field names work.
            "state": plan.processing_state.value,
            "project_links": [
                {
                    "project_name": link.project_name,
                    **({"alias": link.alias} if link.alias else {}),
                    **({"read_only": True} if link.read_only else {}),
                }
                for link in plan.project_links
            ],
            "arguments": plan.arguments,
            "automation_profile": (
                plan.automation_profile.profile_name
                if plan.automation_profile
                else None
            ),
            "action_name": plan.action_name,
            "description": plan.description,
            "definition_of_done": plan.definition_of_done,
            "strategy_actor": plan.strategy_actor,
            "execution_actor": plan.execution_actor,
            "estimation_actor": plan.estimation_actor,
            "invariant_actor": plan.invariant_actor,
            "created_at": plan.timestamps.created_at.isoformat(),
            "updated_at": plan.timestamps.updated_at.isoformat(),
            "is_terminal": plan.is_terminal,
        }
        if plan.execution_environment:
            result["execution_environment"] = plan.execution_environment
            result["execution_env_priority"] = (
                plan.execution_env_priority.value
                if plan.execution_env_priority is not None
                else ExecutionEnvPriority.FALLBACK.value
            )
        if plan.invariants:
            result["invariants"] = [
                {"text": inv.text, "source": inv.source.value}
                for inv in plan.invariants
            ]
        if plan.estimation_result is not None:
            result["estimation"] = plan.estimation_result.as_display_dict()
        if plan.validation_summary and plan.validation_summary.get("dod_evaluated"):
            result["dod_evaluation"] = {
                "all_passed": plan.validation_summary.get("dod_all_passed", False),
                "required_passed": plan.validation_summary.get("required_passed", 0),
                "required_failed": plan.validation_summary.get("required_failed", 0),
            }
        if plan.error_message:
            result["error_message"] = plan.error_message
        if plan.last_completed_step >= 0:
            result["last_completed_step"] = plan.last_completed_step
        if plan.last_checkpoint_id:
            result["last_checkpoint_id"] = plan.last_checkpoint_id
        return result

    # Legacy plan fallback
    return {"plan": str(plan)}


def _execute_output_dict(
    plan: Any,
    started_at: datetime | None = None,
    duration_ms: int | None = None,
) -> dict[str, object]:
    """Build the spec-required execute output envelope.

    Returns the structured JSON envelope for ``agents plan execute --format json``
    as defined in the specification §agents plan execute.

    The envelope structure is::

        {
          "command": "plan execute",
          "status": "ok",
          "exit_code": 0,
          "data": {
            "plan_id": "...",
            "phase": "execute",
            "sandbox": {"strategy": ..., "path": ..., "branch": ..., "status": ...},
            "worker": "local/executor",
            "started": "HH:MM:SS",
            "attempt": 1,
            "strategy_summary": {...},
            "progress": [...]
          },
          "timing": {"started": "...", "duration_ms": ...},
          "messages": ["Execution started"]
        }

    Args:
        plan: The ``Plan`` domain model after execution.
        started_at: When execution started (used for timing).  Falls back
            to ``plan.timestamps.execute_started_at`` when ``None``.
        duration_ms: Elapsed milliseconds.  ``None`` when not available.

    Returns:
        A JSON-serialisable dict matching the spec-required envelope.
    """
    if not isinstance(plan, LifecyclePlan):
        # Legacy plan fallback — return minimal envelope
        return {
            "command": "plan execute",
            "status": "ok",
            "exit_code": 0,
            "data": {"plan": str(plan)},
            "timing": {},
            "messages": ["Execution started"],
        }

    plan_id = plan.identity.plan_id

    # ── Sandbox info ──────────────────────────────────────────────────────────
    # Derive sandbox details from plan.sandbox_refs when available.
    # sandbox_refs is a list of sandbox reference IDs; the first entry is the
    # primary sandbox.  We build a best-effort dict from available plan data.
    sandbox: dict[str, object]
    if plan.sandbox_refs:
        # Use the first sandbox ref as the primary sandbox path/id
        primary_ref = plan.sandbox_refs[0]
        sandbox = {
            # Hardcoded to "git_worktree" (the current default for all local
            # execution environments); derive from the plan model once it
            # exposes sandbox configuration (see issue #9022 notes).
            "strategy": "git_worktree",
            "path": primary_ref,
            "branch": f"cleveragents/plan-{plan_id[:8]}",
            "status": "active",
        }
    else:
        sandbox = {
            # Hardcoded to "git_worktree" (the current default for all local
            # execution environments); derive from the plan model once it
            # exposes sandbox configuration (see issue #9022 notes).
            "strategy": "git_worktree",
            "path": None,
            "branch": f"cleveragents/plan-{plan_id[:8]}",
            "status": "pending",
        }

    # ── Worker ────────────────────────────────────────────────────────────────
    worker: str = plan.execution_actor or "local/executor"

    # ── Started timestamp ─────────────────────────────────────────────────────
    ts_started = started_at or plan.timestamps.execute_started_at
    started_str: str | None = (
        ts_started.strftime("%H:%M:%S") if ts_started is not None else None
    )

    # ── Attempt ───────────────────────────────────────────────────────────────
    attempt: int = plan.identity.attempt

    # ── Strategy summary ──────────────────────────────────────────────────────
    # Derive from estimation_result when available; fall back to decisions count.
    strategy_summary: dict[str, object]
    if plan.estimation_result is not None:
        est = plan.estimation_result.as_display_dict()
        strategy_summary = {
            "decisions": len(plan.decisions),
            "invariants": len(plan.invariants),
            "planned_child_plans": est.get("planned_child_plans", 0),
            "estimated_files": est.get("estimated_files", 0),
            "risk": est.get("risk", "unknown"),
        }
    else:
        strategy_summary = {
            "decisions": len(plan.decisions),
            "invariants": len(plan.invariants),
            "planned_child_plans": 0,
            "estimated_files": 0,
            "risk": "unknown",
        }

    # ── Progress steps ────────────────────────────────────────────────────────
    # Map plan phase/state to progress step statuses.
    # ProcessingState is imported at module level (line 46); no inline import needed.
    is_complete = plan.processing_state in (
        ProcessingState.COMPLETE,
        ProcessingState.APPLIED,
    )
    is_errored = plan.processing_state == ProcessingState.ERRORED

    def _step_status(step_index: int) -> str:
        """Return status for a progress step given the plan's current state."""
        if is_errored:
            return "error" if step_index == 0 else "pending"
        if is_complete:
            return "complete"
        # In-progress: first step is running, rest are pending
        return "running" if step_index == 0 else "pending"

    progress: list[dict[str, str]] = [
        {"label": "Collect context", "status": _step_status(0)},
        {"label": "Run tools", "status": _step_status(1)},
        {"label": "Build changeset", "status": _step_status(2)},
        {"label": "Validate", "status": _step_status(3)},
    ]

    # ── Timing ────────────────────────────────────────────────────────────────
    timing: dict[str, object] = {}
    if ts_started is not None:
        timing["started"] = ts_started.isoformat()
    if duration_ms is not None:
        timing["duration_ms"] = duration_ms

    # ── Data payload ──────────────────────────────────────────────────────────
    data: dict[str, object] = {
        "plan_id": plan_id,
        "phase": plan.phase.value,
        "sandbox": sandbox,
        "worker": worker,
        "attempt": attempt,
        "strategy_summary": strategy_summary,
        "progress": progress,
    }
    if started_str is not None:
        data["started"] = started_str

    return {
        "command": "plan execute",
        "status": "ok",
        "exit_code": 0,
        "data": data,
        "timing": timing,
        "messages": ["Execution started"],
    }


def _get_current_project() -> Project:
    """Get the current project or exit with error.

    Returns:
        Project: The current project

    Raises:
        typer.Abort: If no project found
    """
    from cleveragents.application.container import get_container
    from cleveragents.application.services.project_service import ProjectService

    container = get_container()
    project_service: ProjectService = container.project_service()
    project = project_service.get_current_project()

    if not project:
        console.print("[red]Error:[/red] No project found. Run 'agents init' first.")
        raise typer.Abort()

    return project


# =============================================================================
# V3 Plan Lifecycle Commands
# =============================================================================
# These commands implement the new three-phase plan lifecycle:
# Strategize -> Execute -> Apply -> Applied (terminal)
# Plans are created from Action templates via the 'use' command.


def _get_lifecycle_service():
    """Get the PlanLifecycleService from the container.

    Uses the container's registered provider which includes UnitOfWork
    for database persistence across subprocess invocations.
    """
    from cleveragents.application.container import get_container

    container = get_container()
    return container.plan_lifecycle_service()


_cleanup_logger = structlog.get_logger("cleveragents.cli.commands.plan.cleanup")


def _cleanup_sandbox_for_plan(
    plan_id: str,
    service: PlanLifecycleService,
) -> None:
    """Remove any worktree sandbox left by a previous execute.

    Resolves the plan's linked git-checkout resource and delegates
    to :meth:`GitWorktreeSandbox.cleanup_stale` in the infrastructure
    layer.  Silently does nothing if no sandbox is found.

    Used by ``plan cancel`` to prevent resource leaks.
    """
    if not plan_id or not plan_id.strip():
        _cleanup_logger.warning("cleanup_sandbox.skipped", reason="empty plan_id")
        return

    container = get_container()

    try:
        plan = service.get_plan(plan_id)
    except (NotFoundError, CleverAgentsError) as exc:
        _cleanup_logger.warning(
            "cleanup_sandbox.plan_not_found",
            plan_id=plan_id,
            error=str(exc),
        )
        return

    project_names = [pl.project_name for pl in getattr(plan, "project_links", [])]

    for project_name in project_names:
        try:
            project = container.namespaced_project_repo().get(project_name)
        except (NotFoundError, CleverAgentsError, SQLAlchemyError) as exc:
            _cleanup_logger.debug(
                "cleanup_sandbox.project_lookup_failed",
                project_name=project_name,
                error=str(exc),
            )
            continue
        if project is None:
            continue
        for lr in getattr(project, "linked_resources", []):
            try:
                resource = container.resource_registry_service().show_resource(
                    lr.resource_id,
                )
            except (NotFoundError, CleverAgentsError, SQLAlchemyError) as exc:
                _cleanup_logger.debug(
                    "cleanup_sandbox.resource_lookup_failed",
                    resource_id=lr.resource_id,
                    error=str(exc),
                )
                continue
            if (
                resource.resource_type_name not in ("git-checkout", "git")
                or not resource.location
            ):
                continue

            GitWorktreeSandbox.cleanup_stale(resource.location, plan_id)


def _ensure_gitignore_entry(project_root: str, entry: str) -> None:
    """Ensure *entry* appears in the ``.gitignore`` at *project_root*.

    Only acts when a ``.git`` directory is present (i.e. we are inside a git
    repo).  Appends the entry if not already present so that generated
    ``plan-output/`` files are not accidentally staged and committed.

    M8 fix: plan-output/ in cwd risks accidental VCS commits because
    ``git add .`` picks up generated files.  Auto-adding the directory to
    ``.gitignore`` prevents this.
    """
    if not os.path.isdir(os.path.join(project_root, ".git")):
        return  # not a git repo — nothing to do

    gitignore_path = os.path.join(project_root, ".gitignore")
    # Normalise: both "plan-output/" and "plan-output" are considered equivalent.
    entry_normalised = entry.rstrip("/")
    try:
        if os.path.isfile(gitignore_path):
            with open(gitignore_path) as _f:
                existing = _f.read()
            for line in existing.splitlines():
                if line.strip().rstrip("/") == entry_normalised:
                    return  # already present
            with open(gitignore_path, "a") as _f:
                _f.write(f"\n# Auto-added by CleverAgents plan executor\n{entry}\n")
        else:
            with open(gitignore_path, "w") as _f:
                _f.write(f"# Auto-generated by CleverAgents plan executor\n{entry}\n")
    except OSError:
        # Non-fatal: gitignore update is best-effort.
        pass


class _SandboxInfo:
    """Metadata for a per-resource sandbox."""

    __slots__ = ("project_name", "resource_location", "sandbox_obj", "sandbox_path")

    def __init__(
        self,
        sandbox_path: str,
        sandbox_obj: Any,
        resource_location: str,
        project_name: str,
    ) -> None:
        self.sandbox_path = sandbox_path
        self.sandbox_obj = sandbox_obj
        self.resource_location = resource_location
        self.project_name = project_name


def _create_sandbox_for_plan(
    plan_id: str,
    service: PlanLifecycleService,
) -> tuple[str | None, list[_SandboxInfo]]:
    """Create per-resource git worktree sandboxes for a plan.

    Per spec §19310, each resource gets its own sandbox.  A parent
    directory is created under ``plan-output/<plan_id>/``
    with per-resource subdirectories named by resource ID.

    Returns:
        A ``(parent_sandbox_root, sandbox_infos)`` tuple.
        *parent_sandbox_root* is the parent directory containing all
        per-resource subdirectories (passed to ``PlanExecutor`` as
        ``sandbox_root``).  *sandbox_infos* is a list of
        :class:`_SandboxInfo` objects, one per git-checkout resource.
        When no git resources are found, falls back to a flat directory
        and returns an empty list.
    """
    from cleveragents.application.container import get_container

    container = get_container()
    plan = service.get_plan(plan_id)

    # Guard: when plan is already execute/processing or execute/complete,
    # the sandbox branch holds output awaiting apply or is actively being
    # used by an in-progress execution.  Do NOT destroy it via cleanup_stale.
    if (
        plan is not None
        and plan.phase == PlanPhase.EXECUTE
        and plan.state in (ProcessingState.PROCESSING, ProcessingState.COMPLETE)
    ):
        flat_root = os.path.join(os.getcwd(), "plan-output", plan_id)
        os.makedirs(flat_root, exist_ok=True)
        _ensure_gitignore_entry(os.getcwd(), "plan-output/")
        return flat_root, []

    project_names = [pl.project_name for pl in getattr(plan, "project_links", [])]

    sandboxes: list[_SandboxInfo] = []
    # Track processed repo paths to avoid cleanup_stale destroying
    # a sandbox we just created for the same repo (M1 fix).
    processed_repos: set[str] = set()

    for project_name in project_names:
        try:
            project = container.namespaced_project_repo().get(project_name)
        except Exception:
            continue
        if project is None:
            continue
        for linked_resource in getattr(project, "linked_resources", []):
            try:
                resource = container.resource_registry_service().show_resource(
                    linked_resource.resource_id,
                )
            except Exception:
                continue
            if (
                resource.resource_type_name in ("git-checkout", "git")
                and resource.location
                and os.path.isdir(os.path.join(resource.location, ".git"))
            ):
                repo_abs = os.path.realpath(resource.location)
                if repo_abs in processed_repos:
                    continue  # M1: skip duplicate repos
                processed_repos.add(repo_abs)

                GitWorktreeSandbox.cleanup_stale(
                    resource.location,
                    plan_id,
                )
                sandbox = GitWorktreeSandbox(
                    resource_id=resource.resource_id,
                    original_path=resource.location,
                )
                try:
                    ctx = sandbox.create(plan_id)
                except Exception:
                    # M3: cleanup already-created sandboxes on failure
                    for prev in sandboxes:
                        prev.sandbox_obj.cleanup()
                    raise
                sandboxes.append(
                    _SandboxInfo(
                        sandbox_path=ctx.sandbox_path,
                        sandbox_obj=sandbox,
                        resource_location=resource.location,
                        project_name=project_name,
                    )
                )

    # Always use local plan-output directory for better discoverability.
    # This ensures users can find plan output directly in their working directory
    # rather than in /tmp/ or hidden .cleveragents/ directories.
    # Use the full plan_id to avoid collisions when multiple plans run
    # in the same working directory (batch operations, concurrent plans).
    if sandboxes:
        return sandboxes[0].sandbox_path, sandboxes

    sandbox_base = os.path.join(os.getcwd(), "plan-output", plan_id)
    os.makedirs(sandbox_base, exist_ok=True)
    _ensure_gitignore_entry(os.getcwd(), "plan-output/")

    return sandbox_base, sandboxes


def _apply_sandbox_changes(
    plan_id: str,
    service: PlanLifecycleService,
    console: Console,
) -> bool:
    """Apply sandbox changes to the project.

    Tries git worktree merge first.  If the plan's sandbox was a git
    worktree (branch ``cleveragents/plan-<plan_id>`` exists), merges
    the branch, prints spec-aligned summary panels, and cleans up.
    Otherwise falls back to flat file copy from
    ``plan-output/<plan_id>/``.

    Returns:
        ``True`` if changes were applied successfully, ``False`` if
        the merge failed (conflict, timeout, etc.).

    Spec reference: ``specification.md`` §19310-19313, §13241-13276.
    """
    import subprocess

    from cleveragents.application.container import get_container

    container = get_container()
    plan = service.get_plan(plan_id)
    project_names = [pl.project_name for pl in getattr(plan, "project_links", [])]
    branch_name = f"cleveragents/plan-{plan_id}"

    # Try git worktree merge for each linked git-checkout resource.
    # Per spec §19312: Apply commits each sandbox separately.
    merged_count = 0
    merge_failed = False

    for project_name in project_names:
        try:
            project = container.namespaced_project_repo().get(project_name)
        except Exception:
            continue
        if project is None:
            continue
        for lr in getattr(project, "linked_resources", []):
            try:
                resource = container.resource_registry_service().show_resource(
                    lr.resource_id,
                )
            except Exception:
                continue
            if (
                resource.resource_type_name not in ("git-checkout", "git")
                or not resource.location
            ):
                continue
            repo_path = resource.location

            # Check if our worktree branch exists (exact ref match)
            check = subprocess.run(
                ["git", "rev-parse", "--verify", f"refs/heads/{branch_name}"],
                cwd=repo_path,
                capture_output=True,
                text=True,
                check=False,
                timeout=10,
            )
            if check.returncode != 0:
                continue

            # Get diff summary BEFORE merge for the Apply Summary panel
            diff_stat = subprocess.run(
                ["git", "diff", "--stat", f"HEAD...{branch_name}"],
                cwd=repo_path,
                capture_output=True,
                text=True,
                check=False,
                timeout=10,
            )
            diff_shortstat = subprocess.run(
                ["git", "diff", "--shortstat", f"HEAD...{branch_name}"],
                cwd=repo_path,
                capture_output=True,
                text=True,
                check=False,
                timeout=10,
            )

            # Count changed files from diff --stat
            stat_lines = (diff_stat.stdout or "").strip().splitlines()
            artifact_count = max(0, len(stat_lines) - 1) if stat_lines else 0

            # Parse insertions/deletions from --shortstat
            short = (diff_shortstat.stdout or "").strip()
            insertions = 0
            deletions = 0
            for part in short.split(","):
                part = part.strip()
                if "insertion" in part:
                    insertions = int(part.split()[0])
                elif "deletion" in part:
                    deletions = int(part.split()[0])

            # Merge the worktree branch
            try:
                subprocess.run(
                    [
                        "git",
                        "-c",
                        "commit.gpgsign=false",
                        "merge",
                        branch_name,
                        "--no-edit",
                        "-m",
                        f"cleveragents: apply plan {plan_id}",
                    ],
                    cwd=repo_path,
                    capture_output=True,
                    text=True,
                    check=True,
                    timeout=30,
                )
            except subprocess.TimeoutExpired:
                console.print(
                    f"[red]Merge timed out for {project_name}.[/red]\n"
                    "[yellow]Run 'git merge --abort' manually.[/yellow]"
                )
                merge_failed = True
                continue
            except subprocess.CalledProcessError as merge_err:
                detail = (merge_err.stdout or merge_err.stderr or "").strip()
                if not detail:
                    detail = "Unknown merge error"
                console.print(f"[red]Merge failed for {project_name}:[/red] {detail}")
                try:
                    abort_result = subprocess.run(
                        ["git", "merge", "--abort"],
                        cwd=repo_path,
                        capture_output=True,
                        check=False,
                        timeout=10,
                    )
                except subprocess.TimeoutExpired:
                    console.print(
                        "[red]Merge abort timed out.[/red]\n"
                        "[yellow]Run 'git merge --abort' manually.[/yellow]"
                    )
                    merge_failed = True
                    continue
                if abort_result.returncode == 0:
                    console.print(
                        f"[yellow]{project_name}: merge aborted — "
                        "project is unchanged.[/yellow]"
                    )
                else:
                    abort_err = (
                        abort_result.stdout or abort_result.stderr or ""
                    ).strip()
                    if not abort_err:
                        abort_err = "Unknown error"
                    console.print(
                        f"[red]Merge abort failed:[/red] {abort_err}\n"
                        "[yellow]Run 'git merge --abort' manually.[/yellow]"
                    )
                merge_failed = True
                continue

            merged_count += 1
            applied_at = datetime.now().strftime("%Y-%m-%d %H:%M")

            # ── Per-resource Apply Summary panel (spec §13241) ──
            summary = (
                f"[cyan]Plan:[/cyan] {plan_id}\n"
                f"[blue]Artifacts:[/blue] {artifact_count} file(s) updated\n"
                f"[yellow]Changes:[/yellow] "
                f"{insertions} insertion(s), {deletions} deletion(s)\n"
                f"[blue]Project:[/blue] {project_name}\n"
                f"[green]Applied At:[/green] {applied_at}"
            )
            console.print(Panel(summary, title="Apply Summary", expand=False))

            # ── Sandbox Cleanup (spec §13256-13260) ──
            worktree_removed = False
            branch_deleted = False

            wt_list = subprocess.run(
                ["git", "worktree", "list", "--porcelain"],
                cwd=repo_path,
                capture_output=True,
                text=True,
                check=False,
                timeout=10,
            )
            for wt_line in wt_list.stdout.split("\n\n"):
                if f"branch refs/heads/{branch_name}" in wt_line:
                    for part in wt_line.splitlines():
                        if part.startswith("worktree "):
                            wt_path = part.split("worktree ", 1)[1]
                            subprocess.run(
                                [
                                    "git",
                                    "worktree",
                                    "remove",
                                    "--force",
                                    wt_path,
                                ],
                                cwd=repo_path,
                                capture_output=True,
                                check=False,
                                timeout=10,
                            )
                            worktree_removed = True

            del_result = subprocess.run(
                ["git", "branch", "-D", branch_name],
                cwd=repo_path,
                capture_output=True,
                check=False,
                timeout=10,
            )
            branch_deleted = del_result.returncode == 0

            subprocess.run(
                ["git", "worktree", "prune"],
                cwd=repo_path,
                capture_output=True,
                check=False,
                timeout=10,
            )

            cleanup_text = (
                f"[green]Worktree:[/green] "
                f"{'removed' if worktree_removed else 'already clean'}\n"
                f"[green]Branch:[/green] "
                f"{'merged to main' if branch_deleted else 'cleaned'}"
            )
            console.print(Panel(cleanup_text, title="Sandbox Cleanup", expand=False))

    # Show footer after all resources are processed
    if merged_count > 0:
        console.print(
            Panel(
                "- Review git diff\n- Commit changes",
                title="Next Steps",
                expand=False,
            )
        )
        console.print("[green]✓ OK[/green] Changes applied")
        if merge_failed:
            console.print(
                "[yellow]Some resources failed to merge. See errors above.[/yellow]"
            )
            return False
        return True

    if merge_failed:
        return False

    # Fallback: flat file copy from plan-output/<plan_id>/ (non-git projects).
    sandbox_root = os.path.join(os.getcwd(), "plan-output", plan_id)
    project_root = os.getcwd()
    _skip_dirs = frozenset({".cleveragents", ".git", ".hg", ".svn", "plan-output"})

    if not os.path.isdir(sandbox_root):
        return True  # No sandbox — nothing to apply, not an error

    applied_count = 0
    failed_count = 0
    for dirpath, dirnames, filenames in os.walk(sandbox_root):
        dirnames[:] = [d for d in dirnames if d not in _skip_dirs]
        for fname in filenames:
            src = os.path.join(dirpath, fname)
            rel = os.path.relpath(src, sandbox_root)
            dst = os.path.normpath(os.path.join(project_root, rel))
            if not dst.startswith(project_root + os.sep):
                continue
            if rel.split(os.sep)[0] in _skip_dirs:
                continue
            os.makedirs(os.path.dirname(dst), exist_ok=True)
            try:
                shutil.copy2(src, dst)
                applied_count += 1
            except OSError:
                failed_count += 1

    if applied_count > 0:
        console.print(
            f"[green]Applied {applied_count} file(s) from sandbox to project.[/green]"
        )
    if failed_count == 0 and applied_count > 0:
        shutil.rmtree(sandbox_root, ignore_errors=True)
    if applied_count > 0:
        console.print("[green]✓ OK[/green] Changes applied")

    return failed_count == 0


def _route_sandbox_files_to_worktrees(
    sandbox_infos: list[_SandboxInfo],
    plan_output_path: str | None = None,
) -> None:
    """Route files from the primary sandbox to per-resource worktrees.

    When a plan has multiple git-checkout resources, the LLM writes all
    ``FILE:`` blocks to the first resource's worktree.  This function
    moves files that belong to other resources into their respective
    worktrees by matching file paths against each resource's known
    file list (via ``git ls-files``).

    Also handles the plan-output/ directory - if the LLM wrote files there
    (via the discoverable sandbox path), this function copies them to the
    primary worktree so they get committed.

    Per spec §19310: each resource gets its own sandbox.
    """
    import subprocess

    # Handle plan-output/ → worktree copying
    # The LLM writes to the discoverable plan-output/ path, but we need
    # to copy those files to the worktree for commit (unless there's a
    # specific worktree sandbox path)
    if plan_output_path and os.path.isdir(plan_output_path):
        primary = sandbox_infos[0] if sandbox_infos else None
        if primary and primary.sandbox_path != plan_output_path:
            # Copy all files from plan-output/ to primary worktree
            for dirpath, _dirnames, filenames in os.walk(plan_output_path):
                for fname in filenames:
                    src = os.path.join(dirpath, fname)
                    rel_path = os.path.relpath(src, plan_output_path)
                    dst = os.path.join(primary.sandbox_path, rel_path)
                    os.makedirs(os.path.dirname(dst), exist_ok=True)
                    try:
                        shutil.copy2(src, dst)
                    except OSError:
                        logger.warning(
                            "route_sandbox_file_copy_failed",
                            src=src,
                            dst=dst,
                            exc_info=True,
                        )

    if len(sandbox_infos) <= 1:
        return  # Single resource — nothing to route

    primary = sandbox_infos[0]

    # Build file list for the primary resource so we never move
    # files that belong to it (C1 fix: prevents data loss when
    # projects share the same relative path, e.g. README.md).
    try:
        primary_result = subprocess.run(
            ["git", "ls-files", "--cached", "--others", "--exclude-standard"],
            cwd=primary.resource_location,
            capture_output=True,
            text=True,
            check=True,
            timeout=30,
        )
        primary_files: set[str] = {
            f.strip() for f in primary_result.stdout.splitlines() if f.strip()
        }
    except Exception:
        # Cannot determine primary file list — skip routing entirely
        # to avoid data loss (M-NEW-2: empty set would move all files).
        return

    # Build file lists for non-primary resources
    resource_files: dict[int, set[str]] = {}
    for idx, info in enumerate(sandbox_infos[1:], start=1):
        try:
            result = subprocess.run(
                ["git", "ls-files", "--cached", "--others", "--exclude-standard"],
                cwd=info.resource_location,
                capture_output=True,
                text=True,
                check=True,
                timeout=30,
            )
            resource_files[idx] = {
                f.strip() for f in result.stdout.splitlines() if f.strip()
            }
        except Exception:
            resource_files[idx] = set()

    # Walk the primary sandbox and move files that belong elsewhere.
    # Only move a file if it matches a secondary resource's file list
    # AND does NOT exist in the primary resource's file list.
    for dirpath, _dirnames, filenames in os.walk(primary.sandbox_path):
        for fname in filenames:
            full_path = os.path.join(dirpath, fname)
            rel_path = os.path.relpath(full_path, primary.sandbox_path)

            # Never move files that belong to the primary resource
            if rel_path in primary_files:
                continue

            for idx, known_files in resource_files.items():
                if rel_path in known_files:
                    target = sandbox_infos[idx]
                    dst = os.path.join(target.sandbox_path, rel_path)
                    os.makedirs(os.path.dirname(dst), exist_ok=True)
                    shutil.move(full_path, dst)
                    break


def _recover_errored_execute_plan(
    plan_id: str,
    service: PlanLifecycleService,
    executor: Any,
    console: Console,
) -> None:
    """Recover a plan stuck in execute/errored.

    Uses the domain model's ``classify_error()`` to determine the error
    category, then selects the appropriate recovery path:

    1. **Transient** (spec: modify_config automation flag):
       reset to ``execute/queued`` and
       re-execute with the same strategy.  Preserves
       ``strategy_decisions_json``.
    2. **Non-transient** (spec: Execute → Strategize reversion): delegate to
       ``service.try_auto_revert_from_execute()`` which enforces the
       ``MAX_REVERSIONS`` loop guard, increments ``reversion_count``,
       records a reversion decision, and respects the
       ``delete_content`` automation threshold.

    Does nothing if the plan is not in ``execute/errored``.
    """
    current_plan = service.get_plan(plan_id)
    if (
        current_plan is None
        or current_plan.phase != PlanPhase.EXECUTE
        or current_plan.state != ProcessingState.ERRORED
    ):
        return

    error_type = (current_plan.error_details or {}).get(
        "exception_type",
        "",
    )
    error_msg = (current_plan.error_details or {}).get(
        "error_message",
        "",
    )
    category = classify_error(error_msg, error_type)
    is_transient = category == ErrorCategory.TRANSIENT
    safe_error_type = rich_escape(error_type or "unknown")

    if is_transient:
        # Transient retry (spec: modify_config automation flag).
        # Reset to queued, re-execute with same strategy.  Explicit CLI
        # modify_config automation threshold.
        console.print(
            f"[yellow]Plan is in execute/errored "
            f"(transient: {safe_error_type}). "
            f"Retrying with same strategy (spec: modify_config).[/yellow]"
        )
        # Preserve strategy_decisions_json so _build_decisions() can
        # reconstruct the full strategy hierarchy.
        strategy_json = (current_plan.error_details or {}).get(
            "strategy_decisions_json",
        )
        current_plan.processing_state = ProcessingState.QUEUED
        current_plan.error_details = None
        if strategy_json:
            current_plan.error_details = {
                "strategy_decisions_json": strategy_json,
            }
        service.commit_plan(current_plan)
        current_plan = service.get_plan(plan_id)
        if current_plan is None:
            console.print(
                f"[red]Plan '{plan_id}' not found after transient recovery.[/red]"
            )
            raise typer.Abort()
        return

    # Non-transient failure — revert to Strategize
    # (spec: Execute → Strategize reversion).
    # Use service.revert_plan() (manual reversion entry point) which
    # enforces MAX_REVERSIONS loop guard, records a reversion decision,
    # and increments reversion_count.  Unlike try_auto_revert_from_execute(),
    # revert_plan() does NOT check the delete_content automation threshold —
    # correct for explicit CLI invocations where the user's intent is clear.
    console.print(
        f"[yellow]Plan is in execute/errored "
        f"(non-transient: {safe_error_type}). "
        f"Reverting to Strategize for revised "
        f"strategy (spec: Execute → Strategize reversion).[/yellow]"
    )

    # Revert first, then store error findings — prevents data corruption
    # if the reversion is blocked (e.g. MAX_REVERSIONS exceeded).
    try:
        service.revert_plan(
            plan_id,
            PlanPhase.STRATEGIZE,
            reason="execute_error_recovery",
        )
    except Exception as revert_err:
        console.print(
            f"[red]Strategy reversion failed:[/red] {rich_escape(str(revert_err))}"
        )
        raise typer.Abort() from revert_err

    # Store redacted error findings for the strategy actor
    # (spec: reversion error findings) — only after reversion.
    current_plan = service.get_plan(plan_id)
    if current_plan is None:
        console.print(f"[red]Plan '{plan_id}' not found after reversion.[/red]")
        raise typer.Abort()

    prior_errors = redact_error_details(
        dict(current_plan.error_details or {}),
    )
    current_plan.error_details = {
        "reversion_reason": "execute_error_recovery",
        "prior_error_type": error_type,
        "prior_error_details": json.dumps(prior_errors),
    }
    service.commit_plan(current_plan)

    # If reversion succeeded, re-run strategize with error findings
    if current_plan.phase == PlanPhase.STRATEGIZE:
        executor.run_strategize(plan_id)
        current_plan = service.get_plan(plan_id)
        if current_plan is None:
            console.print(f"[red]Plan '{plan_id}' not found after strategize.[/red]")
            raise typer.Abort()

    # Transition to execute after revised strategy
    if (
        current_plan.phase == PlanPhase.STRATEGIZE
        and current_plan.state == ProcessingState.COMPLETE
    ):
        service.execute_plan(plan_id)
    elif current_plan.phase == PlanPhase.EXECUTE:
        pass  # auto_progress or reversion didn't happen
    else:
        console.print(
            f"[red]Strategy revision failed "
            f"({current_plan.phase.value}/"
            f"{current_plan.state.value}).[/red]"
        )
        raise typer.Abort()


def _commit_worktree_changes(worktree_path: str, plan_id: str) -> None:
    """Stage and commit LLM output in the worktree branch.

    Commits without merging — the merge happens during the apply phase.
    Silently ignores failures (e.g. no changes to commit).
    """
    import subprocess

    try:
        subprocess.run(
            ["git", "add", "-A"],
            cwd=worktree_path,
            check=True,
            timeout=10,
            capture_output=True,
        )
        subprocess.run(
            [
                "git",
                "-c",
                "commit.gpgsign=false",
                "commit",
                "-m",
                f"cleveragents: plan {plan_id} execute output",
            ],
            cwd=worktree_path,
            check=True,
            timeout=10,
            capture_output=True,
        )
    except Exception:
        pass  # No changes or commit failed; apply falls back to flat copy


def _get_plan_executor(
    lifecycle_service: PlanLifecycleService | None = None,
    sandbox_root: str | None = None,
) -> Any:
    """Build a ``PlanExecutor`` wired with real LLM actors.

    Resolves the ``ProviderRegistry`` and ``PlanLifecycleService`` from
    the DI container and constructs a ``StrategyActor`` (via
    ``resolve_strategy_actor``) / ``LLMExecuteActor`` so that
    ``plan execute`` invocations drive real LLM calls instead of the
    local-only stub actors.

    The strategy actor is selected by the ``actor.default.strategy``
    config key:
    - ``"llm"`` or unset with a provider registry → ``StrategyActor``
    - ``"stub"`` → ``StrategizeStubActor`` (via ``None`` return)
    - Plan-level ``strategy_actor`` override is resolved inside
      ``StrategyActor._execute_with_llm`` via the lifecycle service.

    Args:
        lifecycle_service: Optional pre-existing ``PlanLifecycleService``
            instance.  When provided the executor shares the same service
            (and its in-memory cache) as the caller, avoiding stale-read
            bugs caused by the DI ``Factory`` creating independent instances.
    """
    from cleveragents.application.container import get_container
    from cleveragents.application.services.execute_phase_context_assembler import (
        ACMSExecutePhaseContextAssembler,
    )
    from cleveragents.application.services.llm_actors import LLMExecuteActor
    from cleveragents.application.services.plan_executor import PlanExecutor
    from cleveragents.application.services.strategy_actor import (
        resolve_strategy_actor,
    )

    container = get_container()
    registry = container.provider_registry()
    if lifecycle_service is None:
        lifecycle_service = _get_lifecycle_service()

    # Read actor.default.strategy config key to allow operator override.
    config_value: str | None = None
    try:
        config_service = container.config_service()
        resolved = config_service.resolve("actor.default.strategy")
        config_value = resolved.value
    except Exception as e:
        logger.warning(
            "Failed to resolve actor.default.strategy config: %s; "
            "falling back to default",
            e,
        )

    # resolve_strategy_actor returns StrategyActor when an LLM registry
    # is available (or config_value == "llm"), None when config_value ==
    # "stub".  PlanExecutor falls back to StrategizeStubActor on None.
    strategize_actor = resolve_strategy_actor(
        provider_registry=registry,
        lifecycle_service=lifecycle_service,
        acms_pipeline=container.acms_pipeline(),
        tier_service=container.context_tier_service(),
        config_value=config_value,
    )

    context_assembler = ACMSExecutePhaseContextAssembler(
        context_tier_service=container.context_tier_service(),
        project_repository=container.namespaced_project_repo(),
        hot_max_tokens=container.settings().context_max_tokens_hot,
    )
    execute_actor = LLMExecuteActor(
        provider_registry=registry,
        lifecycle_service=lifecycle_service,
        context_assembler=context_assembler,
        tier_service=container.context_tier_service(),
        project_repository=container.namespaced_project_repo(),
        resource_registry=container.resource_registry_service(),
    )

    subplan_service = container.subplan_service()
    checkpoint_manager = container.checkpoint_manager()

    return PlanExecutor(
        lifecycle_service=lifecycle_service,
        strategize_actor=strategize_actor,
        execute_actor=execute_actor,
        sandbox_root=sandbox_root,
        checkpoint_manager=checkpoint_manager,
        tier_service=container.context_tier_service(),
        project_repository=container.namespaced_project_repo(),
        resource_registry=container.resource_registry_service(),
        subplan_service=subplan_service,
    )


def _print_lifecycle_plan(plan: Any, title: str = "Plan") -> None:
    """Print v3 lifecycle plan details in multiple panels.

    Renders five panels per specification:
    1. Plan Status - basic plan info (ID, phase, processing state, action,
       projects, arguments, automation profile, actors, created/updated)
    2. Progress - Strategize/Execute/Apply step progress
    3. Timing - Started, Elapsed, ETA, and all phase timestamps
    4. Execution Detail - Sandbox, Tool Calls, Files Modified, Child Plans,
       Checkpoints
    5. Cost - Tokens Used, Cost So Far, Estimated

    Args:
        plan: A v3 Plan object from plan.py (not plan_legacy.py)
        title: Panel title (used for Plan Status panel)
    """
    if not isinstance(plan, LifecyclePlan):
        # Fall back to legacy plan display - this shouldn't happen
        console.print(Panel(f"Plan: {plan}", title=title, expand=False))
        return

    state_display = plan.state.value if plan.state else "unknown"

    # Format project links with aliases
    project_parts: list[str] = []
    for link in plan.project_links:
        label = link.project_name
        if link.alias:
            label = f"{label} (alias: {link.alias})"
        if link.read_only:
            label = f"{label} [read-only]"
        project_parts.append(label)
    projects = ", ".join(project_parts) if project_parts else "(none)"

    # ===== PANEL 1: Plan Status =====
    plan_status_details = (
        f"[cyan bold]Plan:[/cyan bold] {plan.identity.plan_id}\n"
        f"[yellow bold]Phase:[/yellow bold] {plan.phase.value}\n"
        f"[magenta bold]Processing State:[/magenta bold] {state_display}\n"
        f"[#5599ff bold]Action:[/#5599ff bold] {plan.action_name}\n"
        f"[#66cc66 bold]Projects:[/#66cc66 bold] {projects}\n"
        f"[#5599ff bold]Attempt:[/#5599ff bold] {plan.identity.attempt}"
    )

    # Arguments (ordered)
    if plan.arguments:
        ordered_keys = (
            plan.arguments_order
            if plan.arguments_order
            else sorted(plan.arguments.keys())
        )
        args_lines = "\n".join(
            f"  {key} = {plan.arguments[key]}"
            for key in ordered_keys
            if key in plan.arguments
        )
        plan_status_details += f"\n[bold]Arguments:[/bold]\n{args_lines}"

    # Automation profile info
    if plan.automation_profile:
        plan_status_details += (
            f"\n[#5599ff bold]Automation Profile:[/#5599ff bold] "
            f"{plan.automation_profile.profile_name} "
            f"(source: {plan.automation_profile.provenance.value})"
        )

    # Optional actors
    if plan.strategy_actor:
        plan_status_details += f"\n[bold]Strategy Actor:[/bold] {plan.strategy_actor}"
    if plan.execution_actor:
        plan_status_details += f"\n[bold]Execution Actor:[/bold] {plan.execution_actor}"
    if plan.estimation_actor:
        plan_status_details += (
            f"\n[bold]Estimation Actor:[/bold] {plan.estimation_actor}"
        )
    if plan.invariant_actor:
        plan_status_details += f"\n[bold]Invariant Actor:[/bold] {plan.invariant_actor}"

    # Execution environment
    if plan.execution_environment:
        plan_status_details += (
            f"\n[bold]Execution Environment:[/bold] {plan.execution_environment}"
        )
        priority_display = (
            plan.execution_env_priority.value
            if plan.execution_env_priority is not None
            else ExecutionEnvPriority.FALLBACK.value
        )
        plan_status_details += (
            f"\n[bold]Execution Env Priority:[/bold] {priority_display}"
        )

    # Timestamps: Created and Updated always shown
    plan_status_details += (
        f"\n[bold]Created:[/bold] {plan.timestamps.created_at}"
        f"\n[bold]Updated:[/bold] {plan.timestamps.updated_at}"
    )

    # Description (truncated to 200 chars)
    if plan.description:
        description_preview = plan.description[:200]
        if len(plan.description) > 200:
            description_preview = f"{description_preview}..."
        plan_status_details += f"\n[bold]Description:[/bold]\n  {description_preview}"

    # Definition-of-Done
    if plan.definition_of_done:
        dod_preview = plan.definition_of_done[:200]
        if len(plan.definition_of_done) > 200:
            dod_preview += "..."
        plan_status_details += f"\n[bold]Definition of Done:[/bold]\n  {dod_preview}"

    # DoD evaluation summary
    if plan.validation_summary and plan.validation_summary.get("dod_evaluated"):
        vs = plan.validation_summary
        dod_passed = vs.get("dod_all_passed", False)
        req_pass = vs.get("required_passed", 0)
        req_fail = vs.get("required_failed", 0)
        dod_total = req_pass + req_fail
        status_color = "green" if dod_passed else "red"
        status_label = "PASSED" if dod_passed else "FAILED"
        plan_status_details += (
            f"\n[bold]DoD Evaluation:[/bold] "
            f"[{status_color}]{status_label}[/{status_color}]"
            f" ({req_pass}/{dod_total} passed"
            f"{f', {req_fail} failed' if req_fail else ''})"
        )

    # Invariants
    if plan.invariants:
        plan_status_details += "\n[bold]Invariants:[/bold]"
        for inv in plan.invariants:
            plan_status_details += f"\n  [{inv.source.value}] {inv.text}"

    # Resume metadata
    if plan.last_completed_step >= 0:
        plan_status_details += (
            f"\n[bold]Last Completed Step:[/bold] {plan.last_completed_step}"
        )
    if plan.last_checkpoint_id:
        plan_status_details += (
            f"\n[bold]Last Checkpoint:[/bold] {plan.last_checkpoint_id}"
        )

    # Multi-project changeset summaries
    if (
        plan.multi_project_metadata is not None
        and plan.multi_project_metadata.project_scopes
    ):
        plan_status_details += "\n[bold]Multi-Project Scopes:[/bold]"
        for scope in plan.multi_project_metadata.project_scopes:
            label = scope.project_name
            if scope.alias:
                label += f" (alias: {scope.alias})"
            if scope.read_only:
                label += " [read-only]"
            plan_status_details += f"\n  {label}"
            if scope.changeset_summary is not None:
                cs = scope.changeset_summary
                plan_status_details += (
                    f"\n    Changed: {cs.files_changed}  "
                    f"Added: {cs.files_added}  "
                    f"Deleted: {cs.files_deleted}  "
                    f"Lines: {cs.total_lines_changed}"
                )
                if cs.validation_passed is not None:
                    v_color = "green" if cs.validation_passed else "red"
                    v_label = "PASSED" if cs.validation_passed else "FAILED"
                    plan_status_details += (
                        f"  Validation: [{v_color}]{v_label}[/{v_color}]"
                    )

    # Error message
    if plan.error_message:
        plan_status_details += f"\n[bold red]Error:[/bold red] {plan.error_message}"

    console.print(Panel(plan_status_details, title=title, expand=False))

    # ===== PANEL 2: Progress =====
    # Determine status of each phase based on current phase and state
    strategize_status = "✓"
    strategize_color = "green"
    execute_status = "⏳"
    execute_color = "cyan"
    apply_status = "•"
    apply_color = "yellow"

    if plan.phase == PlanPhase.ACTION:
        strategize_status = "•"
        strategize_color = "yellow"
        execute_status = "•"
        execute_color = "yellow"
        apply_status = "•"
        apply_color = "yellow"
    elif plan.phase == PlanPhase.STRATEGIZE:
        if plan.processing_state == ProcessingState.COMPLETE:
            strategize_status = "✓"
            strategize_color = "green"
            execute_status = "•"
            execute_color = "yellow"
        else:
            strategize_status = "⏳"
            strategize_color = "cyan"
            execute_status = "•"
            execute_color = "yellow"
    elif plan.phase == PlanPhase.EXECUTE:
        strategize_status = "✓"
        strategize_color = "green"
        if plan.processing_state == ProcessingState.COMPLETE:
            execute_status = "✓"
            execute_color = "green"
            apply_status = "•"
            apply_color = "yellow"
        else:
            execute_status = "⏳"
            execute_color = "cyan"
            apply_status = "•"
            apply_color = "yellow"
    elif plan.phase == PlanPhase.APPLY:
        strategize_status = "✓"
        strategize_color = "green"
        execute_status = "✓"
        execute_color = "green"
        applied_or_constrained = plan.processing_state in (
            ProcessingState.APPLIED,
            ProcessingState.CONSTRAINED,
        )
        if applied_or_constrained:
            apply_status = "✓"
            apply_color = "green"
        else:
            apply_status = "⏳"
            apply_color = "cyan"

    progress_details = (
        f"[{strategize_color}]{strategize_status}[/{strategize_color}] Strategize\n"
        f"[{execute_color}]{execute_status}[/{execute_color}] Execute\n"
        f"[{apply_color}]{apply_status}[/{apply_color}] Apply"
    )
    console.print(Panel(progress_details, title="Progress", expand=False))

    # ===== PANEL 3: Timing =====
    # Calculate timing information
    started_time = "N/A"
    elapsed_time = "N/A"
    eta_time = "N/A"

    if plan.timestamps.strategize_started_at:
        started_time = plan.timestamps.strategize_started_at.strftime("%H:%M:%S")

        # Calculate elapsed time
        now = datetime.now(plan.timestamps.strategize_started_at.tzinfo)
        elapsed_seconds = (now - plan.timestamps.strategize_started_at).total_seconds()
        hours = int(elapsed_seconds // 3600)
        minutes = int((elapsed_seconds % 3600) // 60)
        seconds = int(elapsed_seconds % 60)
        elapsed_time = f"{hours:02d}:{minutes:02d}:{seconds:02d}"

        # Calculate ETA using estimation result when available
        if (
            plan.estimation_result is not None
            and plan.estimation_result.estimated_time_seconds is not None
        ):
            estimated_total_seconds = plan.estimation_result.estimated_time_seconds
            remaining_seconds = max(0, estimated_total_seconds - elapsed_seconds)
            eta_hours = int(remaining_seconds // 3600)
            eta_minutes = int((remaining_seconds % 3600) // 60)
            eta_secs = int(remaining_seconds % 60)
            eta_time = f"{eta_hours:02d}:{eta_minutes:02d}:{eta_secs:02d}"
        else:
            eta_time = "N/A"

    timing_details = (
        f"[#66cc66 bold]Started:[/#66cc66 bold] {started_time}\n"
        f"[yellow bold]Elapsed:[/yellow bold] {elapsed_time}\n"
        f"[#66cc66 bold]ETA:[/#66cc66 bold] {eta_time}"
    )

    # Optional phase timestamps
    if plan.timestamps.strategize_started_at:
        timing_details += (
            f"\n[bold]Strategize Started:[/bold] "
            f"{plan.timestamps.strategize_started_at}"
        )
    if plan.timestamps.strategize_completed_at:
        timing_details += (
            f"\n[bold]Strategize Completed:[/bold] "
            f"{plan.timestamps.strategize_completed_at}"
        )
    if plan.timestamps.execute_started_at:
        timing_details += (
            f"\n[bold]Execute Started:[/bold] {plan.timestamps.execute_started_at}"
        )
    if plan.timestamps.execute_completed_at:
        timing_details += (
            f"\n[bold]Execute Completed:[/bold] {plan.timestamps.execute_completed_at}"
        )
    if plan.timestamps.applied_at:
        timing_details += f"\n[bold]Applied At:[/bold] {plan.timestamps.applied_at}"

    console.print(Panel(timing_details, title="Timing", expand=False))

    # ===== PANEL 4: Execution Detail =====
    # Extract execution details
    sandbox_strategy = "N/A"
    if plan.sandbox_refs:
        # Note: sandbox_refs are internal ULID references; the spec example
        # shows a human-readable strategy name (e.g. 'git_worktree'). N/A
        # would be more accurate but raw refs are retained for diagnostic use.
        sandbox_strategy = ", ".join(plan.sandbox_refs[:3])
        if len(plan.sandbox_refs) > 3:
            sandbox_strategy += f" (+{len(plan.sandbox_refs) - 3} more)"

    # tool_calls is not tracked in CostMetadata; display N/A
    tool_calls_display = "N/A"
    # files_modified is not directly available; display N/A
    files_modified_display = "N/A"

    child_plans = "0/0 complete"
    if plan.subplan_statuses:
        completed = sum(
            1 for sp in plan.subplan_statuses if sp.completed_at is not None
        )
        child_plans = f"{completed}/{len(plan.subplan_statuses)} complete"

    # Checkpoint count approximation: we only know whether at least one
    # checkpoint exists (via last_checkpoint_id).  A more accurate count
    # would require an additional query; displayed as 'N/A' would be
    # consistent with tool_calls and files_modified, but the binary signal
    # (0 vs 1) is intentional for now.
    checkpoints = 0
    if plan.last_checkpoint_id:
        checkpoints = 1  # At least one checkpoint exists

    execution_details = (
        f"[#5599ff bold]Sandbox:[/#5599ff bold] {sandbox_strategy}\n"
        f"[#5599ff bold]Tool Calls:[/#5599ff bold] {tool_calls_display}\n"
        f"[#5599ff bold]Files Modified:[/#5599ff bold] {files_modified_display}\n"
        f"[#5599ff bold]Child Plans:[/#5599ff bold] {child_plans}\n"
        f"[#5599ff bold]Checkpoints:[/#5599ff bold] {checkpoints} created"
    )
    console.print(Panel(execution_details, title="Execution Detail", expand=False))

    # ===== PANEL 5: Cost =====
    tokens_used = 0
    cost_so_far = 0.0
    estimated_cost = 0.0

    if plan.cost_metadata:
        tokens_used = plan.cost_metadata.total_tokens or 0
        cost_so_far = plan.cost_metadata.total_cost or 0.0

    if plan.estimation_result and plan.estimation_result.estimated_cost_usd:
        estimated_cost = plan.estimation_result.estimated_cost_usd

    cost_details = (
        f"[#5599ff bold]Tokens Used:[/#5599ff bold] {tokens_used:,}\n"
        f"[yellow bold]Cost So Far:[/yellow bold] ${cost_so_far:.3f}\n"
        f"[#5599ff bold]Estimated:[/#5599ff bold] ${estimated_cost:.3f}"
    )
    console.print(Panel(cost_details, title="Cost", expand=False))

    # ===== Footer =====
    console.print("[#66cc66 bold]✓ OK[/#66cc66 bold] Status refreshed")


@app.command("use")
def use_action(
    action_name: Annotated[
        str,
        typer.Argument(help="Action name to use"),
    ],
    projects: Annotated[
        list[str] | None,
        typer.Argument(help="Projects to apply the action on (one or more)"),
    ] = None,
    project: Annotated[
        list[str] | None,
        typer.Option(
            "--project",
            "-p",
            help=(
                "Project name to use the action on "
                "(can be repeated for multiple projects)"
            ),
        ),
    ] = None,
    arg: Annotated[
        list[str] | None,
        typer.Option(
            "--arg",
            "-a",
            help="Argument value (format: name=value)",
        ),
    ] = None,
    automation_profile: Annotated[
        str | None,
        typer.Option(
            "--automation-profile",
            help="Automation profile name to use for this plan",
        ),
    ] = None,
    invariant: Annotated[
        list[str] | None,
        typer.Option(
            "--invariant",
            help="Invariant constraint text (repeatable)",
        ),
    ] = None,
    strategy_actor: Annotated[
        str | None,
        typer.Option(
            "--strategy-actor",
            help="Override the strategy actor for this plan",
        ),
    ] = None,
    execution_actor: Annotated[
        str | None,
        typer.Option(
            "--execution-actor",
            help="Override the execution actor for this plan",
        ),
    ] = None,
    estimation_actor: Annotated[
        str | None,
        typer.Option(
            "--estimation-actor",
            help="Override the estimation actor for this plan",
        ),
    ] = None,
    invariant_actor: Annotated[
        str | None,
        typer.Option(
            "--invariant-actor",
            help="Override the invariant reconciliation actor for this plan",
        ),
    ] = None,
    execution_environment: Annotated[
        str | None,
        typer.Option(
            "--execution-environment",
            help="Execution environment: host or container",
        ),
    ] = None,
    execution_env_priority: Annotated[
        str | None,
        typer.Option(
            "--execution-env-priority",
            help="Priority semantics: fallback (default) or override",
        ),
    ] = None,
    fmt: Annotated[
        str,
        typer.Option(
            "--format",
            "-f",
            help=_FORMAT_HELP,
        ),
    ] = "rich",
) -> None:
    """Use an action on projects to create a plan in Strategize phase.

    The first positional argument is the ACTION name. Subsequent positional
    arguments are PROJECT names.  Projects can also be supplied via the
    repeatable ``--project`` / ``-p`` option.

    Examples:
        agents plan use local/code-coverage proj-1 proj-2 --arg target_coverage=80
        agents plan use local/lint --project proj-1 --invariant "No new warnings"
    """
    from cleveragents.application.services.plan_lifecycle_service import (
        ActionNotAvailableError,
    )
    from cleveragents.domain.models.core.plan import (
        AutomationProfileProvenance,
        AutomationProfileRef,
        InvariantSource,
        PlanInvariant,
        ProjectLink,
    )

    try:
        service = _get_lifecycle_service()

        # Merge positional projects and --project option
        all_projects: list[str] = list(projects or []) + list(project or [])

        # Parse arguments
        arguments: dict[str, str | int | float | bool | list[str]] = {}
        if arg:
            for arg_str in arg:
                if "=" not in arg_str:
                    console.print(
                        f"[red]Invalid argument format:[/red] {arg_str}. "
                        "Expected: name=value"
                    )
                    raise typer.Abort()
                name, value = arg_str.split("=", 1)
                # Try to parse value as int, float, bool, or keep as string
                try:
                    arguments[name] = int(value)
                except ValueError:
                    try:
                        arguments[name] = float(value)
                    except ValueError:
                        if value.lower() in ("true", "false"):
                            arguments[name] = value.lower() == "true"
                        else:
                            arguments[name] = value

        # Get action by name
        action = service.get_action_by_name(action_name)

        if automation_profile:
            from cleveragents.domain.models.core.automation_profile import (
                BUILTIN_PROFILES,
            )

            if automation_profile not in BUILTIN_PROFILES:
                console.print(
                    f"[red]Invalid automation profile:[/red] {automation_profile}. "
                    f"Available: {', '.join(sorted(BUILTIN_PROFILES))}"
                )
                raise typer.Abort()

        # Build project links from project names
        project_links = [ProjectLink(project_name=p) for p in all_projects]

        # Build plan-level invariants from --invariant flags
        plan_invariants: list[PlanInvariant] | None = None
        if invariant:
            plan_invariants = [
                PlanInvariant(text=inv_text, source=InvariantSource.PLAN)
                for inv_text in invariant
            ]

        # Use the action to create a plan
        plan = service.use_action(
            action_name=str(action.namespaced_name),
            project_links=project_links,
            arguments=arguments if arguments else None,
            invariants=plan_invariants,
            automation_profile=automation_profile,
        )

        # Notify A2A facade for protocol bookkeeping
        _notify_facade(
            "plan.create",
            {
                "plan_id": plan.identity.plan_id,
                "action_name": str(action.namespaced_name),
            },
        )

        # Track whether any CLI overrides were applied so we only
        # re-persist the plan when necessary.
        has_overrides = False

        # Preserve explicit CLI override locally as well so CLI output,
        # persistence refresh, and mocked lifecycle-service tests all
        # observe the plan-level profile consistently.
        if automation_profile:
            plan.automation_profile = AutomationProfileRef(
                profile_name=automation_profile,
                provenance=AutomationProfileProvenance.PLAN,
            )
            has_overrides = True

        # Propagate project-level execution env settings as defaults
        # when no plan-level CLI overrides are provided (AC3 of #1079).
        if not execution_environment or not execution_env_priority:
            try:
                from cleveragents.application.container import get_container
                from cleveragents.cli.commands.project_context import (
                    _load_policy_json,
                )

                container = get_container()
                sf = container.session_factory()
                # Use the first project link name if available
                proj_name = project_links[0].project_name if project_links else None
                if proj_name:
                    blob = _load_policy_json(sf, proj_name) or {}
                    proj_env = blob.get("execution_environment")
                    proj_priority = blob.get("execution_env_priority")
                    if not execution_environment and proj_env:
                        plan.execution_environment = proj_env
                        has_overrides = True
                    if not execution_env_priority and proj_priority:
                        from cleveragents.domain.models.core.plan import (
                            ExecutionEnvPriority,
                        )

                        plan.execution_env_priority = ExecutionEnvPriority(
                            proj_priority,
                        )
                        has_overrides = True
            except Exception:
                pass  # Project context not available; skip propagation

        # Validate and apply actor overrides if provided
        if strategy_actor:
            validate_namespaced_actor(strategy_actor, "--strategy-actor")
            plan.strategy_actor = strategy_actor
            has_overrides = True
        if execution_actor:
            validate_namespaced_actor(execution_actor, "--execution-actor")
            plan.execution_actor = execution_actor
            has_overrides = True
        if estimation_actor:
            validate_namespaced_actor(estimation_actor, "--estimation-actor")
            plan.estimation_actor = estimation_actor
            has_overrides = True
        if invariant_actor:
            validate_namespaced_actor(invariant_actor, "--invariant-actor")
            plan.invariant_actor = invariant_actor
            has_overrides = True
        if execution_environment:
            from cleveragents.domain.models.core.plan import ExecutionEnvironment

            # Validate value
            try:
                ExecutionEnvironment(execution_environment.lower())
            except ValueError as exc:
                valid = ", ".join(e.value for e in ExecutionEnvironment)
                console.print(
                    f"[red]Invalid execution environment:[/red] "
                    f"{execution_environment}. Valid values: {valid}"
                )
                raise typer.Abort() from exc
            plan.execution_environment = execution_environment.lower()
            has_overrides = True

        # Handle --execution-env-priority
        if execution_env_priority and not execution_environment:
            console.print(
                "[red]Error:[/red] --execution-env-priority requires "
                "--execution-environment to be set."
            )
            raise typer.Abort()

        from cleveragents.domain.models.core.plan import ExecutionEnvPriority

        if execution_env_priority:
            try:
                priority_val = ExecutionEnvPriority(execution_env_priority.lower())
            except ValueError as exc:
                valid = ", ".join(e.value for e in ExecutionEnvPriority)
                console.print(
                    f"[red]Invalid execution env priority:[/red] "
                    f"{execution_env_priority}. Valid values: {valid}"
                )
                raise typer.Abort() from exc
            plan.execution_env_priority = priority_val
            has_overrides = True
        elif execution_environment:
            # Default to fallback when environment is set but priority is not
            plan.execution_env_priority = ExecutionEnvPriority.FALLBACK
            has_overrides = True

        # Re-persist the plan so CLI overrides survive across sessions.
        # Only call save_plan when overrides were actually applied.
        if has_overrides:
            service.save_plan(plan)

        # Persist any post-creation overrides (profile, actors, env) so
        # that subsequent CLI invocations (separate processes) see them.
        if any(
            [
                automation_profile,
                strategy_actor,
                execution_actor,
                estimation_actor,
                invariant_actor,
                execution_environment,
            ]
        ):
            service.commit_plan(plan)

        if fmt != OutputFormat.RICH.value:
            data = _plan_spec_dict(plan)
            console.print(format_output(data, fmt))
        else:
            _print_lifecycle_plan(plan, title="Plan Created")
            if plan.is_terminal:
                console.print(
                    f"\n[dim]Plan completed with state: "
                    f"{plan.state.value if plan.state else 'unknown'}.[/dim]"
                )
            else:
                console.print(
                    "\n[dim]Plan is now in Strategize phase (queued). "
                    "Run 'agents plan execute <id>' when ready.[/dim]"
                )

    except ActionNotAvailableError as e:
        console.print(f"[red]Action not available:[/red] {e}")
        raise typer.Abort() from e
    except ValidationError as e:
        console.print(f"[red]Validation Error:[/red] {e.message}")
        raise typer.Abort() from e
    except CleverAgentsError as e:
        console.print(f"[red]Error:[/red] {e.message}")
        raise typer.Abort() from e


@app.command("execute")
def execute_plan(
    plan_id: Annotated[
        str | None,
        typer.Argument(help="Plan ID to execute (optional if only one plan)"),
    ] = None,
    execution_environment: Annotated[
        str | None,
        typer.Option(
            "--execution-environment",
            help="Execution environment: host or container",
        ),
    ] = None,
    fmt: Annotated[
        str,
        typer.Option(
            "--format",
            "-f",
            help=_FORMAT_HELP,
        ),
    ] = "rich",
) -> None:
    """Execute the current plan phase synchronously.

    Detects the plan's current phase and processes it:

    - **Strategize/queued**: runs the strategize phase to completion.
    - **Strategize/complete**: transitions to Execute and runs it.
    - **Execute/queued**: runs the execute phase to completion.

    When no plan ID is given, auto-selects the single eligible plan.
    """
    from cleveragents.application.services.plan_lifecycle_service import (
        InvalidPhaseTransitionError,
        PlanNotReadyError,
    )
    from cleveragents.application.services.plan_preflight_guardrail import (
        PreflightRejection,
    )

    sandbox_infos: list[_SandboxInfo] = []
    execute_succeeded = False
    try:
        from cleveragents.domain.models.core.plan import (
            PlanPhase,
            ProcessingState,
        )

        service = _get_lifecycle_service()

        # Track wall-clock start time for timing output
        execute_wall_start = datetime.now()

        if not plan_id:
            # Auto-discover: look for plans in strategize or execute
            plans_strat = service.list_plans(phase=PlanPhase.STRATEGIZE)
            plans_exec = service.list_plans(phase=PlanPhase.EXECUTE)
            eligible = [
                p
                for p in plans_strat
                if p.state in (ProcessingState.QUEUED, ProcessingState.COMPLETE)
            ] + [p for p in plans_exec if p.state == ProcessingState.QUEUED]
            if len(eligible) == 0:
                console.print(
                    "[yellow]No plans ready for execution.[/yellow]\n"
                    "Plans must be in Strategize or Execute phase."
                )
                raise typer.Abort()
            if len(eligible) > 1:
                console.print(
                    "[yellow]Multiple plans ready for execution. "
                    "Please specify a plan ID.[/yellow]"
                )
                for p in eligible:
                    console.print(
                        f"  * {p.identity.plan_id}: "
                        f"{p.namespaced_name} "
                        f"({p.phase.value}/{p.state.value})"
                    )
                raise typer.Abort()
            plan_id = eligible[0].identity.plan_id
        else:
            # Validate ULID format before querying v3 storage.  A non-ULID
            # identifier (e.g., a legacy plan name) will never be found in v3
            # storage; catching it here provides an actionable error message
            # instead of a generic "Plan not found".
            _validate_plan_ulid(plan_id)

        # Apply execution environment override if provided
        if execution_environment:
            from cleveragents.domain.models.core.plan import ExecutionEnvironment

            try:
                ExecutionEnvironment(execution_environment.lower())
            except ValueError as exc:
                valid = ", ".join(e.value for e in ExecutionEnvironment)
                console.print(
                    f"[red]Invalid execution environment:[/red] "
                    f"{execution_environment}. Valid values: {valid}"
                )
                raise typer.Abort() from exc
            pre = service.get_plan(plan_id)
            if pre is not None:
                pre.execution_environment = execution_environment.lower()
                service.commit_plan(pre)

        # Create per-resource sandboxes (spec §19310) and build the
        # executor with the sandbox path.
        sandbox_root, sandbox_infos = _create_sandbox_for_plan(plan_id, service)
        executor = _get_plan_executor(
            lifecycle_service=service,
            sandbox_root=sandbox_root,
        )

        # Determine current phase and run the appropriate processing
        current_plan = service.get_plan(plan_id)
        if current_plan is None:
            console.print(f"[red]Plan '{plan_id}' not found.[/red]")
            raise typer.Abort()

        # Fail-fast: read-only plans must not enter Execute phase
        if current_plan.read_only is True:
            console.print(
                f"[red]Cannot execute plan '{plan_id}': plan is read-only.[/red]"
            )
            raise typer.Abort()

        # If the plan is still in Strategize/queued, run the strategize
        # phase inline before transitioning to Execute.  This implements
        # the decompose_task behaviour described in the specification:
        # "When confidence >= threshold, Strategize begins immediately."
        if current_plan.phase == PlanPhase.STRATEGIZE and current_plan.state in (
            ProcessingState.QUEUED,
            ProcessingState.PROCESSING,
        ):
            # Run strategize phase synchronously
            executor.run_strategize(plan_id)
            # Re-fetch: auto_progress in complete_strategize may have
            # already advanced the plan to Execute.
            current_plan = service.get_plan(plan_id)
            if current_plan is None:
                console.print(f"[red]Plan '{plan_id}' not found.[/red]")
                raise typer.Abort()

        # If auto_progress already moved the plan to Execute, skip the
        # explicit transition and just report the current state.
        if current_plan.phase == PlanPhase.EXECUTE:
            plan = current_plan
        elif (
            current_plan.phase == PlanPhase.STRATEGIZE
            and current_plan.state == ProcessingState.COMPLETE
        ):
            # Normal path: plan is in Strategize/complete → transition
            plan = service.execute_plan(plan_id)
        else:
            console.print(
                f"[red]Plan '{plan_id}' is not in an executable state "
                f"(current: {current_plan.phase.value}/"
                f"{current_plan.state.value}).[/red]"
            )
            raise typer.Abort()

        _recover_errored_execute_plan(
            plan_id,
            service,
            executor,
            console,
        )

        # Run the execute phase inline so the plan progresses through
        # execute/queued → execute/processing → execute/complete in a
        # single CLI invocation.
        current_plan = service.get_plan(plan_id)
        if (
            current_plan is not None
            and current_plan.phase == PlanPhase.EXECUTE
            and current_plan.state == ProcessingState.QUEUED
        ):
            # Notify A2A facade BEFORE running execute so the facade
            # sees execute/queued, not execute/complete.  Notifying
            # after run_execute causes an "Invalid phase transition
            # from execute to execute" error because the plan has
            # already completed.
            _notify_facade("plan.execute", {"plan_id": plan_id})
            executor.run_execute(plan_id)
            plan = service.get_plan(plan_id)

            # Route files to correct per-resource worktrees (spec §19310)
            # then commit each worktree branch. Pass sandbox_root
            # (plan-output path) so it can copy files from the discoverable
            # location to worktrees.
            _route_sandbox_files_to_worktrees(
                sandbox_infos, plan_output_path=sandbox_root
            )
            for sinfo in sandbox_infos:
                _commit_worktree_changes(sinfo.sandbox_path, plan_id)

        # Notify A2A facade for protocol bookkeeping.
        # Use plan.status (read-only) instead of plan.execute (transition)
        # to avoid a duplicate execute→execute transition error when the
        # plan has already completed the execute phase.
        _notify_facade("plan.status", {"plan_id": plan_id})

        if fmt != OutputFormat.RICH.value:
            execute_elapsed_ms = int(
                (datetime.now() - execute_wall_start).total_seconds() * 1000
            )
            envelope = _execute_output_dict(
                plan,
                started_at=execute_wall_start,
                duration_ms=execute_elapsed_ms,
            )
            console.print(format_output(envelope, fmt))
        else:
            _print_lifecycle_plan(plan, title="Plan Executed")
            phase_label = f"{plan.phase.value}/{plan.state.value}"
            if plan.phase == PlanPhase.EXECUTE and plan.state in (
                ProcessingState.COMPLETE,
                ProcessingState.APPLIED,
            ):
                console.print(
                    f"[dim]Plan execution completed ({phase_label}). "
                    "Run 'agents plan apply <id>' when ready.[/dim]"
                )
                console.print(
                    f"[dim]Output files written to "
                    f"plan-output/{plan.identity.plan_id}/.[/dim]"
                )
            else:
                console.print(
                    f"\n[dim]Plan is now in {phase_label} state. "
                    "Run 'agents plan execute <id>' to continue.[/dim]"
                )

        # Mark execute as successful before any teardown — the worktree
        # branch survives until ``plan apply`` merges it into the project.
        execute_succeeded = True

    except PreflightRejection as e:
        console.print(f"[red]Pre-flight check failed:[/red] {e}")
        raise typer.Abort() from e
    except InvalidPhaseTransitionError as e:
        console.print(f"[red]Invalid transition:[/red] {e}")
        raise typer.Abort() from e
    except PlanNotReadyError as e:
        console.print(f"[red]Plan not ready:[/red] {e}")
        raise typer.Abort() from e
    except ValueError as e:
        # Provider/config resolution errors can occur during strategize/execute
        # when required model credentials are unavailable in the environment.
        # Surface as a controlled CLI abort instead of an INTERNAL 500.
        console.print(f"[red]Execution Error:[/red] {e}")
        raise typer.Abort() from e
    except CleverAgentsError as e:
        console.print(f"[red]Error:[/red] {e.message}")
        raise typer.Abort() from e
    except Exception as e:
        console.print(f"[red]Unexpected error:[/red] {e}")
        raise typer.Abort() from e
    finally:
        # Cleanup sandboxes only on failure — on success the worktree
        # branch must survive until ``plan apply`` merges it into the
        # project.  We use an explicit flag instead of re-reading the
        # plan from storage in-flight (which would be racy and fragile
        # if an earlier exception handler already mutated the plan).
        if not execute_succeeded:
            for _sinfo in sandbox_infos:
                try:
                    _sinfo.sandbox_obj.cleanup()
                except Exception:
                    structlog.get_logger(__name__).warning(
                        "sandbox_cleanup_failed",
                        sandbox_path=getattr(
                            _sinfo,
                            "sandbox_path",
                            "unknown",
                        ),
                    )


@app.command("apply")
def lifecycle_apply_plan(
    plan_id: Annotated[
        str | None,
        typer.Argument(help="Plan ID to apply (optional if only one plan)"),
    ] = None,
    yes: Annotated[
        bool,
        typer.Option(
            "--yes",
            "-y",
            help="Skip confirmation prompt",
        ),
    ] = False,
    fmt: Annotated[
        str,
        typer.Option(
            "--format",
            "-f",
            help=_FORMAT_HELP,
        ),
    ] = "rich",
) -> None:
    """Apply a plan, transitioning from Execute to Apply phase (v3 lifecycle).

    The plan must be in Execute phase with 'complete' state.
    This is different from the legacy 'apply' command which applies file changes.

    Use ``--yes`` / ``-y`` to skip the confirmation prompt in scripts.
    """
    from cleveragents.application.services.plan_lifecycle_service import (
        InvalidPhaseTransitionError,
        PlanNotReadyError,
    )

    try:
        service = _get_lifecycle_service()

        if not plan_id:
            # Try to find plans eligible for apply: Execute/complete or
            # Apply/queued (auto_progress may have already advanced).

            exec_plans = service.list_plans(phase=PlanPhase.EXECUTE)
            apply_plans = service.list_plans(phase=PlanPhase.APPLY)
            eligible_plans = [
                p for p in exec_plans if p.state == ProcessingState.COMPLETE
            ] + [p for p in apply_plans if p.state == ProcessingState.QUEUED]
            if len(eligible_plans) == 0:
                console.print(
                    "[yellow]No plans ready for apply.[/yellow]\n"
                    "Plans must be in Execute/complete or Apply/queued state."
                )
                raise typer.Abort()
            if len(eligible_plans) > 1:
                console.print(
                    "[yellow]Multiple plans ready for apply. "
                    "Please specify a plan ID.[/yellow]"
                )
                for p in eligible_plans:
                    console.print(f"  • {p.identity.plan_id}: {p.namespaced_name}")
                raise typer.Abort()
            plan_id = eligible_plans[0].identity.plan_id
        else:
            # Validate ULID format before querying v3 storage.  A non-ULID
            # identifier (e.g., a legacy plan name) will never be found in v3
            # storage; catching it here provides an actionable error message
            # instead of a generic "Plan not found".
            _validate_plan_ulid(plan_id)

        # Fail-fast: plan must exist
        pre_plan = service.get_plan(plan_id)
        if pre_plan is None:
            console.print(f"[red]Plan '{plan_id}' not found.[/red]")
            raise typer.Abort()

        # Fail-fast: read-only plans must not enter Apply phase
        if pre_plan.read_only is True:
            console.print(
                f"[red]Cannot apply plan '{plan_id}': plan is read-only.[/red]"
            )
            raise typer.Abort()

        # Confirmation prompt — Apply is destructive (merges sandbox into
        # real resources).  Skip when --yes / -y is provided.
        if not yes:
            confirm = typer.confirm(
                f"\nApply changes for plan {plan_id}?", default=False
            )
            if not confirm:
                console.print("[yellow]Apply cancelled.[/yellow]")
                raise typer.Exit(0)

        # If auto_progress already moved the plan to Apply, skip the
        # explicit transition and just report the current state.
        if pre_plan.phase == PlanPhase.APPLY:
            plan = pre_plan
        else:
            # Normal path: plan is in Execute/complete → transition
            plan = service.apply_plan(plan_id)

        # Apply changeset: merge the git worktree branch back into
        # the project, or fall back to flat file copy for non-git
        # projects.
        apply_ok = _apply_sandbox_changes(plan_id, service, console)
        if not apply_ok:
            # Transition plan to constrained state per spec §18334-18336
            with contextlib.suppress(Exception):
                service.constrain_apply(
                    plan_id,
                    "Merge conflict during apply. Resolve conflicts "
                    "manually or run 'agents plan execute' to re-plan.",
                )
            console.print(
                "[red]Apply failed — plan is constrained.[/red]\n"
                "[yellow]Resolve conflicts manually or run "
                "'agents plan execute' to re-plan.[/yellow]"
            )
            raise typer.Abort()

        # Complete the apply phase to terminal state.
        plan = service.get_plan(plan_id)
        if (
            plan.phase == PlanPhase.APPLY
            and plan.processing_state == ProcessingState.QUEUED
        ):
            plan = service._complete_apply_if_queued(plan_id)

        if fmt != OutputFormat.RICH.value:
            data = _plan_spec_dict(plan)
            console.print(format_output(data, fmt))
        else:
            title = "Plan Applied" if plan.is_terminal else "Plan Applying"
            _print_lifecycle_plan(plan, title=title)
            if plan.is_terminal:
                console.print(
                    f"\n[dim]Plan completed in Apply phase with state: "
                    f"{plan.state.value if plan.state else 'unknown'}.[/dim]"
                )
            else:
                console.print(
                    "\n[dim]Plan is now in Apply phase (queued). "
                    "Changes will be applied to the project(s).[/dim]"
                )

    except InvalidPhaseTransitionError as e:
        console.print(f"[red]Invalid transition:[/red] {e}")
        raise typer.Abort() from e
    except PlanNotReadyError as e:
        console.print(f"[red]Plan not ready:[/red] {e}")
        raise typer.Abort() from e
    except ValueError as e:
        # Provider/config resolution errors can occur during apply when
        # required model credentials are unavailable in the environment.
        # Surface as a controlled CLI abort instead of an unhandled traceback.
        console.print(f"[red]Execution Error:[/red] {e}")
        raise typer.Abort() from e
    except CleverAgentsError as e:
        console.print(f"[red]Error:[/red] {e.message}")
        raise typer.Abort() from e
    except Exception as e:
        if isinstance(e, (typer.Abort, typer.Exit)):
            raise
        console.print(f"[red]Unexpected error:[/red] {e}")
        raise typer.Abort() from e


@app.command("status")
def plan_status(
    plan_id: Annotated[
        str | None,
        typer.Argument(help="Plan ID to show status for"),
    ] = None,
    fmt: Annotated[
        str,
        typer.Option(
            "--format",
            "-f",
            help=_FORMAT_HELP,
        ),
    ] = "rich",
) -> None:
    """Show status of a v3 lifecycle plan.

    Displays the current phase, state, and other details.
    """
    try:
        service = _get_lifecycle_service()

        if not plan_id:
            # List all active plans
            plans = service.list_plans()
            if not plans:
                console.print("[yellow]No v3 lifecycle plans found.[/yellow]")
                console.print(
                    "Create one with 'agents plan use <action> --project <project>'"
                )
                return

            # Non-rich formats
            if fmt != OutputFormat.RICH.value:
                data = [_plan_spec_dict(p) for p in plans]
                console.print(format_output(data, fmt))
                return

            # Show summary table
            table = Table(title=f"Active Plans ({len(plans)} total)")
            table.add_column("ID", style="cyan")
            table.add_column("Name", style="white")
            table.add_column("Phase", style="yellow")
            table.add_column("State", style="magenta")
            table.add_column("Profile", style="blue")
            table.add_column("Invariants", style="dim")
            table.add_column("Terminal", justify="center")

            for plan in plans:
                profile = (
                    plan.automation_profile.profile_name
                    if plan.automation_profile
                    else ""
                )
                inv_count = str(len(plan.invariants)) if plan.invariants else ""
                table.add_row(
                    plan.identity.plan_id[:8] + "...",
                    str(plan.namespaced_name),
                    plan.phase.value,
                    plan.state.value if plan.state else "",
                    profile,
                    inv_count,
                    "✓" if plan.is_terminal else "",
                )

            console.print(table)
            return

        # Validate ULID format before querying v3 storage.  A non-ULID
        # identifier (e.g., a legacy plan name) will never be found in v3
        # storage; catching it here provides an actionable error message
        # instead of a generic "Plan not found".
        _validate_plan_ulid(plan_id)

        # Show single plan details
        plan = service.get_plan(plan_id)

        if fmt != OutputFormat.RICH.value:
            data = _plan_spec_dict(plan)
            console.print(format_output(data, fmt))
            return

        _print_lifecycle_plan(plan, title="Plan Status")

    except CleverAgentsError as e:
        console.print(f"[red]Error:[/red] {e.message}")
        raise typer.Abort() from e


@app.command("errors")
def plan_errors(
    plan_id: Annotated[
        str,
        typer.Argument(help="Plan ID to show errors for"),
    ],
    fmt: Annotated[
        str,
        typer.Option(
            "--format",
            "-f",
            help=_FORMAT_HELP,
        ),
    ] = "rich",
) -> None:
    """Show error decisions with recovery hints and retry history.

    Displays structured error metadata from the plan, including error
    category, phase, retry count, and recovery suggestions with CLI
    commands.
    """
    from cleveragents.domain.models.core.error_recovery import (
        ErrorCategory,
        get_recovery_hints,
    )

    try:
        _validate_plan_ulid(plan_id)
        service = _get_lifecycle_service()
        plan = service.get_plan(plan_id)

        details = plan.error_details or {}
        has_error_recovery = "error_category" in details

        # Build recovery hints from stored category
        hints: list[dict[str, Any]] = []
        if has_error_recovery:
            with suppress(ValueError):
                cat = ErrorCategory(details["error_category"])
                for h in get_recovery_hints(cat, plan_id):
                    entry: dict[str, Any] = {
                        "action": h.action.value,
                        "message": h.message,
                    }
                    if h.cli_command:
                        entry["cli_command"] = h.cli_command
                    hints.append(entry)

        if fmt != OutputFormat.RICH.value:
            data: dict[str, Any] = {
                "plan_id": plan_id,
                "phase": plan.phase.value,
                "state": plan.state.value if plan.state else "unknown",
            }
            if plan.error_message:
                data["error_message"] = plan.error_message
            if has_error_recovery:
                data["error_category"] = details.get("error_category")
                data["error_phase"] = details.get("error_phase")
                data["retry_count"] = details.get("retry_count")
                data["max_retries"] = details.get("max_retries")
                data["is_retriable"] = details.get("is_retriable")
            if details.get("error_actor"):
                data["error_actor"] = details["error_actor"]
            if details.get("error_tool_call"):
                data["error_tool_call"] = details["error_tool_call"]
            if details.get("stack_summary"):
                data["stack_summary"] = details["stack_summary"]
            if hints:
                data["recovery_hints"] = hints
            console.print(format_output(data, fmt))
            return

        # Rich output
        header = (
            f"[bold]Plan:[/bold] {plan_id}\n"
            f"[bold]Phase:[/bold] {plan.phase.value}\n"
            f"[bold]State:[/bold] "
            f"{plan.state.value if plan.state else 'unknown'}"
        )

        if plan.error_message:
            header += f"\n[bold red]Error:[/bold red] {plan.error_message}"

        if not has_error_recovery and not plan.error_message:
            header += "\n\n[green]No error recovery records for this plan.[/green]"
            console.print(Panel(header, title="Plan Errors", expand=False))
            console.print("[green]✓ OK[/green]")
            return

        if has_error_recovery:
            header += (
                f"\n\n[bold]Error Category:[/bold] "
                f"{details.get('error_category', 'unknown')}\n"
                f"[bold]Error Phase:[/bold] "
                f"{details.get('error_phase', 'unknown')}\n"
                f"[bold]Retry Count:[/bold] "
                f"{details.get('retry_count', '0')}"
                f"/{details.get('max_retries', '3')}\n"
                f"[bold]Retriable:[/bold] "
                f"{details.get('is_retriable', 'false')}"
            )
            if details.get("error_actor"):
                header += f"\n[bold]Actor:[/bold] {details['error_actor']}"
            if details.get("error_tool_call"):
                header += f"\n[bold]Tool Call:[/bold] {details['error_tool_call']}"

        if hints:
            header += "\n\n[bold]Recovery Suggestions:[/bold]"
            for hint in hints:
                header += f"\n  → {hint['message']}"
                if hint.get("cli_command"):
                    header += f"\n    [dim]$ {hint['cli_command']}[/dim]"

        if details.get("stack_summary"):
            summary = details["stack_summary"][:500]
            header += f"\n\n[bold]Stack Summary:[/bold]\n[dim]{summary}[/dim]"

        console.print(Panel(header, title="Plan Errors", expand=False))
        console.print("[green]✓ OK[/green]")

    except CleverAgentsError as e:
        console.print(f"[red]Error:[/red] {e.message}")
        raise typer.Abort() from e


@app.command("list")
def lifecycle_list_plans(
    regex: Annotated[
        str | None,
        typer.Argument(
            help="Optional regex pattern to filter plan names",
        ),
    ] = None,
    phase: Annotated[
        str | None,
        typer.Option(
            "--phase",
            help="Filter by phase (strategize, execute, apply, applied)",
        ),
    ] = None,
    state: Annotated[
        str | None,
        typer.Option(
            "--state",
            help=(
                "Filter by processing state "
                "(queued/processing/errored/complete/cancelled)"
            ),
        ),
    ] = None,
    processing_state: Annotated[
        str | None,
        typer.Option(
            "--processing-state",
            help="Filter by processing state (alias for --state)",
        ),
    ] = None,
    project_id: Annotated[
        str | None,
        typer.Option(
            "--project",
            "-p",
            help="Filter by project name",
        ),
    ] = None,
    action_filter: Annotated[
        str | None,
        typer.Option(
            "--action",
            help="Filter by action name",
        ),
    ] = None,
    fmt: Annotated[
        str,
        typer.Option(
            "--format",
            "-f",
            help=_FORMAT_HELP,
        ),
    ] = "rich",
) -> None:
    """List v3 lifecycle plans with optional filtering.

    This command lists plans created through the new plan lifecycle
    (Strategize -> Execute -> Apply -> Applied).

    Examples:
        agents plan list
        agents plan list --phase strategize
        agents plan list --state processing
        agents plan list "^myteam/"
    """
    try:
        import re

        from cleveragents.domain.models.core.plan import (
            PlanPhase,
            ProcessingState,
        )

        service = _get_lifecycle_service()

        # Parse phase filter
        phase_filter = None
        if phase:
            try:
                phase_filter = PlanPhase(phase.lower())
            except ValueError as exc:
                console.print(
                    f"[red]Invalid phase:[/red] {phase}. "
                    "Valid values: strategize, execute, apply, applied"
                )
                raise typer.Abort() from exc

        # Resolve --state to processing_state (--state maps to processing_state)
        resolved_state_str = state or processing_state
        state_filter: ProcessingState | None = None
        if resolved_state_str:
            try:
                state_filter = ProcessingState(resolved_state_str.lower())
            except ValueError as exc:
                console.print(
                    f"[red]Invalid state:[/red] {resolved_state_str}. "
                    "Valid values: queued, processing, errored, complete, cancelled"
                )
                raise typer.Abort() from exc

        plans = service.list_plans(phase=phase_filter, project_name=project_id)

        # Apply processing state filter
        if state_filter:
            plans = [p for p in plans if p.processing_state == state_filter]

        # Apply action name filter
        if action_filter:
            plans = [p for p in plans if p.action_name == action_filter]

        # Apply regex name filter
        if regex:
            try:
                pattern = re.compile(regex)
            except re.error as exc:
                console.print(f"[red]Invalid regex pattern:[/red] {regex}")
                raise typer.Abort() from exc
            plans = [
                p
                for p in plans
                if pattern.search(str(p.namespaced_name))
                or pattern.search(p.namespaced_name.name)
            ]

        if not plans:
            console.print("[yellow]No plans found.[/yellow]")
            console.print(
                "Create one with 'agents plan use <action> --project <project>'"
            )
            return

        # Non-rich formats
        if fmt != OutputFormat.RICH.value:
            data = [_plan_spec_dict(p) for p in plans]
            console.print(format_output(data, fmt))
            return

        # Display plans table with spec-required columns
        table = Table(title="Plans")
        table.add_column("ID", style="cyan")
        table.add_column("Name", style="white")
        table.add_column("Phase", style="yellow")
        table.add_column("State", style="magenta")
        table.add_column("Action", style="blue")
        table.add_column("Invariants", style="dim")
        table.add_column("Project", style="green")
        table.add_column("Updated", style="dim")
        table.add_column("Elapsed", style="dim")

        for plan in plans:
            # Get projects with truncation for display
            link_names = [link.project_name for link in plan.project_links]
            if not link_names:
                project_display = "(none)"
            elif len(link_names) <= 2:
                project_display = ", ".join(link_names)
            else:
                project_display = (
                    f"{link_names[0]}, {link_names[1]}\n+{len(link_names) - 2} more"
                )

            # Calculate elapsed time
            created = plan.timestamps.created_at
            now = datetime.now(created.tzinfo) if created.tzinfo else datetime.now()
            elapsed = now - created
            hours = int(elapsed.total_seconds() // 3600)
            minutes = int((elapsed.total_seconds() % 3600) // 60)
            seconds = int(elapsed.total_seconds() % 60)
            elapsed_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}"

            # Invariant count
            invariant_count = str(len(plan.invariants)) if plan.invariants else "0"

            # Last-updated timestamp.
            # PlanTimestamps.updated_at is typed as datetime (never None),
            # so we call strftime directly.  The value is stored as-is
            # (naive local time by default); no timezone conversion is
            # applied here, consistent with how created_at is stored.
            updated_str = plan.timestamps.updated_at.strftime("%Y-%m-%d %H:%M")

            table.add_row(
                plan.identity.plan_id[:8],
                str(plan.namespaced_name),
                plan.phase.value,
                plan.state.value if plan.state else "",
                plan.action_name,
                invariant_count,
                project_display,
                updated_str,
                elapsed_str,
            )

        console.print(table)

        # Display Filters panel (only if any filter is active)
        active_filters = []
        if phase_filter:
            active_filters.append(f"[yellow]Phase:[/yellow] {phase_filter}")
        else:
            active_filters.append("[yellow]Phase:[/yellow] (any)")

        if state_filter:
            active_filters.append(f"[yellow]State:[/yellow] {state_filter.value}")
        else:
            active_filters.append("[yellow]State:[/yellow] (any)")

        if project_id:
            active_filters.append(f"[yellow]Project:[/yellow] {project_id}")
        else:
            active_filters.append("[yellow]Project:[/yellow] (any)")

        if action_filter:
            active_filters.append(f"[yellow]Action:[/yellow] {action_filter}")
        else:
            active_filters.append("[yellow]Action:[/yellow] (any)")

        # Only show Filters panel if at least one filter is active
        if phase_filter or state_filter or project_id or action_filter:
            filters_text = "\n".join(active_filters)
            filters_panel = Panel(filters_text, title="Filters", border_style="dim")
            console.print(filters_panel)

        # Display Summary panel
        total_plans = len(plans)
        processing_count = sum(
            1 for p in plans if p.processing_state.value == "processing"
        )
        completed_count = sum(
            1 for p in plans if p.processing_state.value == "complete"
        )
        errored_count = sum(1 for p in plans if p.processing_state.value == "errored")

        summary_text = (
            f"[yellow bold]Total:[/yellow bold] {total_plans}\n"
            f"[blue bold]Processing:[/blue bold] {processing_count}\n"
            f"[green bold]Completed:[/green bold] {completed_count}\n"
            f"[red bold]Errored:[/red bold] {errored_count}"
        )
        summary_panel = Panel(summary_text, title="Summary", border_style="dim")
        console.print(summary_panel)

        # Print success message
        plan_word = "plan" if total_plans == 1 else "plans"
        console.print(f"[green bold]✓ OK[/green bold] {total_plans} {plan_word} listed")

    except CleverAgentsError as e:
        console.print(f"[red]Error:[/red] {e.message}")
        raise typer.Abort() from e


@app.command("cancel")
def cancel_plan(
    plan_id: Annotated[
        str,
        typer.Argument(help="Plan ID to cancel"),
    ],
    reason: Annotated[
        str | None,
        typer.Option(
            "--reason",
            "-r",
            help="Reason for cancellation",
        ),
    ] = None,
    fmt: Annotated[
        str,
        typer.Option(
            "--format",
            "-f",
            help=_FORMAT_HELP,
        ),
    ] = "rich",
) -> None:
    """Cancel a v3 lifecycle plan.

    Plans in terminal state cannot be cancelled.
    """
    try:
        _validate_plan_ulid(plan_id)
        service = _get_lifecycle_service()

        plan = service.cancel_plan(plan_id, reason=reason)

        # Clean up any worktree sandbox left by a previous execute
        _cleanup_sandbox_for_plan(plan_id, service)

        if fmt != OutputFormat.RICH.value:
            data = _plan_spec_dict(plan)
            if reason:
                data["cancel_reason"] = reason
            console.print(format_output(data, fmt))
        else:
            console.print(f"[green]✓[/green] Plan cancelled: {plan.namespaced_name}")
            if reason:
                console.print(f"[dim]Reason: {reason}[/dim]")

    except PlanError as e:
        console.print(f"[red]Cannot cancel:[/red] {e.message}")
        raise typer.Abort() from e
    except CleverAgentsError as e:
        console.print(f"[red]Error:[/red] {e.message}")
        raise typer.Abort() from e


@app.command("revert")
def revert_plan(
    plan_id: Annotated[
        str,
        typer.Argument(help="Plan ID to revert"),
    ],
    to_phase: Annotated[
        str,
        typer.Option(
            "--to-phase",
            help="Target phase to revert to (e.g., strategize)",
        ),
    ] = "strategize",
    reason: Annotated[
        str | None,
        typer.Option(
            "--reason",
            "-r",
            help="Reason for the reversion",
        ),
    ] = None,
    fmt: Annotated[
        str,
        typer.Option(
            "--format",
            "-f",
            help=_FORMAT_HELP,
        ),
    ] = "rich",
) -> None:
    """Revert a plan to a previous phase.

    This command reverts a plan back to a previous phase (typically
    Strategize) so it can be re-planned with different constraints.

    Examples:
        agents plan revert PLAN123 --to-phase strategize
        agents plan revert PLAN123 --to-phase strategize \
            --reason "constraints too strict"
    """
    from cleveragents.application.services.plan_lifecycle_service import (
        InvalidPhaseTransitionError,
    )
    from cleveragents.domain.models.core.plan import PlanPhase

    try:
        service = _get_lifecycle_service()

        # Parse the target phase
        try:
            target_phase = PlanPhase(to_phase.lower())
        except ValueError as exc:
            valid_phases = ", ".join(p.value for p in PlanPhase)
            console.print(
                f"[red]Invalid phase:[/red] {to_phase}. Valid phases: {valid_phases}"
            )
            raise typer.Abort() from exc

        plan = service.revert_plan(plan_id, target_phase, reason=reason)

        if fmt != OutputFormat.RICH.value:
            data = _plan_spec_dict(plan)
            data["reversion_reason"] = reason
            console.print(format_output(data, fmt))
        else:
            _print_lifecycle_plan(plan, title="Plan Reverted")
            console.print(
                "\n[dim]Plan reverted to "
                f"{target_phase.value} phase. "
                f"Reversion count: {plan.reversion_count}/{plan.MAX_REVERSIONS}[/dim]"
            )

    except InvalidPhaseTransitionError as e:
        console.print(f"[red]Invalid reversion:[/red] {e}")
        raise typer.Abort() from e
    except PlanError as e:
        console.print(f"[red]Cannot revert:[/red] {e.message}")
        raise typer.Abort() from e
    except CleverAgentsError as e:
        console.print(f"[red]Error:[/red] {e.message}")
        raise typer.Abort() from e


_diff_logger = structlog.get_logger("cleveragents.cli.commands.plan.diff")


def _get_worktree_diff(
    plan_id: str,
    service: PlanLifecycleService,
) -> str | None:
    """Return a worktree-branch diff for a plan, or ``None``.

    Resolves the plan's linked git-checkout resource and delegates to
    :meth:`GitWorktreeSandbox.diff_against_head` in the Infrastructure
    layer.  Returns ``None`` when no worktree branch exists so the
    caller can fall back to changeset-based diff.
    """
    if not plan_id or not plan_id.strip():
        return None

    try:
        plan = service.get_plan(plan_id)
    except (NotFoundError, CleverAgentsError):
        return None

    container = get_container()
    project_names = [pl.project_name for pl in getattr(plan, "project_links", [])]

    for project_name in project_names:
        try:
            project = container.namespaced_project_repo().get(project_name)
        except (NotFoundError, CleverAgentsError) as exc:
            _diff_logger.debug(
                "worktree_diff.project_lookup_failed",
                project_name=project_name,
                error=str(exc),
            )
            continue
        if project is None:
            continue
        for lr in getattr(project, "linked_resources", []):
            try:
                resource = container.resource_registry_service().show_resource(
                    lr.resource_id,
                )
            except (NotFoundError, CleverAgentsError) as exc:
                _diff_logger.debug(
                    "worktree_diff.resource_lookup_failed",
                    resource_id=lr.resource_id,
                    error=str(exc),
                )
                continue
            if (
                resource.resource_type_name not in ("git-checkout", "git")
                or not resource.location
            ):
                continue

            diff = GitWorktreeSandbox.diff_against_head(
                resource.location,
                plan_id,
            )
            if diff is not None:
                return diff

    return None


def _get_apply_service() -> PlanApplyService:
    """Get the PlanApplyService from the lifecycle service."""
    from cleveragents.application.container import get_container
    from cleveragents.application.services.plan_apply_service import (
        PlanApplyService,
    )

    container = get_container()
    lifecycle = _get_lifecycle_service()
    return PlanApplyService(
        lifecycle_service=lifecycle,
        unit_of_work=container.unit_of_work(),
    )


@app.command("diff")
def plan_diff(
    plan_id: Annotated[
        str,
        typer.Argument(help="Plan ID to show diff for"),
    ],
    correction: Annotated[
        str | None,
        typer.Option(
            "--correction",
            help="Show diff for a specific correction attempt ID",
        ),
    ] = None,
    fmt: Annotated[
        str,
        typer.Option(
            "--format",
            "-f",
            help="Output format: rich, plain, json, yaml",
        ),
    ] = "rich",
) -> None:
    """Show ChangeSet as a unified diff for a plan.

    Displays per-resource diffs grouped by resource name, with
    file create/modify/delete operations annotated.

    When ``--correction`` is provided, shows the diff for a specific
    correction attempt instead of the whole plan.
    """
    try:
        service = _get_apply_service()
        _fmt = cast(
            Literal["rich", "plain", "json", "yaml"],
            fmt if fmt in ("rich", "plain", "json", "yaml") else "rich",
        )

        if correction:
            # Show correction-specific diff
            output = service.correction_diff(plan_id, correction, fmt=_fmt)
            console.print(output)
            return

        # Try worktree branch diff first — this is where LLM output
        # lives after plan execute (spec §13225).
        try:
            worktree_diff = _get_worktree_diff(
                plan_id,
                _get_lifecycle_service(),
            )
            if worktree_diff is not None:
                console.print(worktree_diff)
                return
        except Exception:
            pass  # Container unavailable; fall through to changeset diff

        # Fall back to changeset-based diff from plan metadata
        output = service.diff(plan_id, fmt=_fmt)
        console.print(output)

    except PlanError as e:
        console.print(f"[red]Diff Error:[/red] {e.message}")
        raise typer.Abort() from e
    except CleverAgentsError as e:
        console.print(f"[red]Error:[/red] {e.message}")
        raise typer.Abort() from e


@app.command("artifacts")
def plan_artifacts(
    plan_id: Annotated[
        str,
        typer.Argument(help="Plan ID to show artifacts for"),
    ],
    fmt: Annotated[
        str,
        typer.Option(
            "--format",
            "-f",
            help="Output format: rich, plain, json, yaml",
        ),
    ] = "rich",
) -> None:
    """Show plan artifacts: ChangeSet ID, sandbox refs, apply summary.

    Displays the ChangeSet summary, list of files changed with operation
    types, and validation results if available.
    """
    try:
        service = _get_apply_service()
        output = service.artifacts(plan_id, fmt=fmt)
        console.print(output)

    except PlanError as e:
        console.print(f"[red]Artifacts Error:[/red] {e.message}")
        raise typer.Abort() from e
    except CleverAgentsError as e:
        console.print(f"[red]Error:[/red] {e.message}")
        raise typer.Abort() from e


# =============================================================================
# Plan Prompt Command
# =============================================================================


@app.command("prompt")
def prompt_plan_cmd(
    plan_id: Annotated[
        str,
        typer.Argument(help="Plan ID (ULID)"),
    ],
    guidance: Annotated[
        str,
        typer.Argument(help="Guidance text"),
    ],
    fmt: Annotated[
        str,
        typer.Option(
            "--format",
            "-f",
            help=(
                "Output format: rich, color, table, plain, json, yaml (default: rich)"
            ),
        ),
    ] = "rich",
) -> None:
    """Provide user guidance to a plan in the Execute phase.

    The guidance is injected as a ``user_intervention`` decision and is
    queued for the next execution step.
    """
    started_at = datetime.now(UTC)
    started = time.monotonic()
    try:
        service = _get_lifecycle_service()
        prompt_data = service.prompt_plan(plan_id, guidance)
        elapsed_ms = int((time.monotonic() - started) * 1000)

        _notify_facade(
            "_cleveragents/plan/prompt",
            {"plan_id": plan_id, "guidance": guidance},
        )

        if fmt in (OutputFormat.JSON.value, OutputFormat.YAML.value):
            # Spec-required envelope: command/status/data/timing.started populated
            # at the JSON/YAML root via format_output's envelope builder.
            console.print(
                format_output(
                    prompt_data,
                    fmt,
                    command="plan prompt",
                    status="ok",
                    messages=["Guidance queued"],
                    started_at=started_at,
                )
            )
            return

        if fmt != OutputFormat.RICH.value:
            # Legacy envelope-wrapping for table/plain/color formats, which
            # render the envelope dict directly (no separate envelope builder).
            envelope: dict[str, object] = {
                "command": "plan prompt",
                "status": "ok",
                "exit_code": 0,
                "data": prompt_data,
                "timing": {
                    "started": started_at.isoformat(),
                    "duration_ms": elapsed_ms,
                },
                "messages": ["Guidance queued"],
            }
            console.print(format_output(envelope, fmt))
            return

        guidance_added = prompt_data.get("guidance_added", {})
        decision_created = prompt_data.get("decision_created", {})
        queue = prompt_data.get("queue", {})

        console.print(
            Panel(
                f"[bold]Plan:[/bold] {guidance_added.get('plan', plan_id)}\n"
                f"[bold]Guidance:[/bold] {guidance_added.get('guidance', guidance)}\n"
                f"[bold]Scope:[/bold] "
                f"{guidance_added.get('scope', 'next execution step')}\n"
                f"[bold]Phase:[/bold] {guidance_added.get('phase', 'execute')}\n"
                f"[bold]State:[/bold] "
                f"{guidance_added.get('state_transition', 'processing -> processing')}",
                title="Guidance Added",
                expand=False,
            )
        )
        console.print(
            Panel(
                f"[bold]Type:[/bold] "
                f"{decision_created.get('type', 'user_intervention')}\n"
                f"[bold]ID:[/bold] {decision_created.get('id', '')}\n"
                f"[bold]Parent:[/bold] {decision_created.get('parent', '')}",
                title="Decision Created",
                expand=False,
            )
        )
        console.print(
            Panel(
                f"[bold]Pending:[/bold] {queue.get('pending', 0)}\n"
                f"[bold]Applied:[/bold] {queue.get('applied', 0)}",
                title="Queue",
                expand=False,
            )
        )
        console.print("[green]✓ OK[/green] Guidance queued")
    except ValidationError as e:
        console.print(f"[red]Validation Error:[/red] {e.message}")
        raise typer.Abort() from e
    except PlanError as e:
        console.print(f"[red]Prompt Error:[/red] {e.message}")
        raise typer.Abort() from e
    except CleverAgentsError as e:
        console.print(f"[red]Error:[/red] {e.message}")
        raise typer.Abort() from e


# =============================================================================
# Correction Commands
# =============================================================================


@app.command("correct")
def correct_decision(
    identifier: Annotated[
        str,
        typer.Argument(
            help="Plan ID (auto-selects root decision) or Decision ID to correct"
        ),
    ],
    mode: Annotated[
        str,
        typer.Option(
            ...,
            "--mode",
            "-m",
            help="Correction mode: revert or append",
        ),
    ],
    guidance: Annotated[
        str,
        typer.Option(
            ...,
            "--guidance",
            "-g",
            help="Guidance text for the correction",
        ),
    ],
    dry_run: Annotated[
        bool,
        typer.Option(
            "--dry-run",
            help="Only analyze impact, do not execute",
        ),
    ] = False,
    yes: Annotated[
        bool,
        typer.Option(
            "--yes",
            "-y",
            help="Skip confirmation prompt",
        ),
    ] = False,
    plan_id: Annotated[
        str | None,
        typer.Option(
            "--plan",
            "-p",
            help="Plan ID (uses latest active plan if omitted)",
        ),
    ] = None,
    fmt: Annotated[
        str,
        typer.Option(
            "--format",
            "-f",
            help=_FORMAT_HELP,
        ),
    ] = "rich",
) -> None:
    """Correct a decision in a plan's decision tree.

    The positional *identifier* can be either a **plan ID** or a
    **decision ID**.  When a plan ID is given the root decision of that
    plan is automatically selected as the correction target.

    Supports two modes:

    * **revert** -- undo a decision and recompute affected subtrees
    * **append** -- add new guidance at a decision point

    Use ``--dry-run`` to preview what would change without executing.

    Examples::

        agents plan correct --mode revert -g "Use FastAPI instead" PLAN-001
        agents plan correct --mode revert -g "Use FastAPI instead" DEC-001
        agents plan correct --mode append -g "Add caching layer" --dry-run DEC-002
    """
    from cleveragents.core.exceptions import ResourceNotFoundError as RNF
    from cleveragents.domain.models.core.correction import CorrectionMode

    try:
        # Validate mode
        try:
            correction_mode = CorrectionMode(mode)
        except ValueError as exc:
            console.print(
                f"[red]Invalid mode:[/red] {mode}. Must be 'revert' or 'append'."
            )
            raise typer.Abort() from exc

        # Validate guidance is not blank
        if not guidance.strip():
            console.print("[red]Error:[/red] --guidance / -g must not be blank.")
            raise typer.Abort()

        # Resolve identifier: try as plan_id first, then fall back to
        # decision_id for backward compatibility (issue #969).
        from cleveragents.application.container import get_container
        from cleveragents.domain.models.core.plan import Plan

        container = get_container()
        decision_svc = container.decision_service()

        target_decision_id: str
        resolved_plan_id: str
        _is_plan = False
        try:
            service = container.plan_lifecycle_service()
            plan_obj = service.get_plan(identifier)
            if isinstance(plan_obj, Plan):
                _is_plan = True
        except RNF:
            # Lookup failed - identifier is not a plan_id
            pass

        if _is_plan:
            # identifier is a valid plan_id - auto-select root decision
            resolved_plan_id = identifier
            decisions = decision_svc.list_decisions(resolved_plan_id)
            root_decisions = [d for d in decisions if d.parent_decision_id is None]
            if not root_decisions:
                console.print(
                    f"[red]Error:[/red] Plan '{identifier}' has no root decision."
                )
                raise typer.Abort()
            target_decision_id = root_decisions[0].decision_id
        else:
            # Not a plan_id - treat as decision_id (backward compat)
            target_decision_id = identifier
            resolved_plan_id = plan_id or _resolve_active_plan_id()

        # Build structural tree adjacency list (parent -> children)
        decisions = decision_svc.list_decisions(resolved_plan_id)

        decision_tree: dict[str, list[str]] = {}
        for d in decisions:
            if d.parent_decision_id is not None:
                decision_tree.setdefault(d.parent_decision_id, []).append(d.decision_id)

        # Fetch influence DAG edges
        influence_edges = decision_svc.get_influence_edges(resolved_plan_id)

        svc = container.correction_service()

        # Create the correction request
        request = svc.request_correction(
            plan_id=resolved_plan_id,
            target_decision_id=target_decision_id,
            mode=correction_mode,
            guidance=guidance,
            dry_run=dry_run,
        )

        if dry_run:
            # Analyze and display impact
            impact = svc.analyze_impact(
                request.correction_id,
                decision_tree=decision_tree,
                influence_edges=influence_edges,
            )
            if fmt != OutputFormat.RICH.value:
                data = {
                    "correction_id": request.correction_id,
                    "mode": request.mode.value,
                    "target_decision": request.target_decision_id,
                    "affected_decisions": impact.affected_decisions,
                    "affected_files": impact.affected_files,
                    "estimated_cost": impact.estimated_cost,
                    "risk_level": impact.risk_level,
                }
                console.print(format_output(data, fmt))
            else:
                console.print(
                    Panel(
                        f"[bold]Correction ID:[/bold] {request.correction_id}\n"
                        f"[bold]Mode:[/bold] {request.mode.value}\n"
                        f"[bold]Target Decision:[/bold] "
                        f"{request.target_decision_id}\n"
                        f"[bold]Guidance:[/bold] {request.guidance}\n\n"
                        f"[bold]Affected Decisions:[/bold] "
                        f"{', '.join(impact.affected_decisions) or '(none)'}\n"
                        f"[bold]Affected Files:[/bold] "
                        f"{', '.join(impact.affected_files) or '(none)'}\n"
                        f"[bold]Risk Level:[/bold] {impact.risk_level}\n"
                        f"[bold]Estimated Cost:[/bold] "
                        f"{impact.estimated_cost or 'N/A'}",
                        title="Correction Impact (Dry Run)",
                        expand=False,
                    )
                )
            return

        # Confirm before execution
        if not yes:
            console.print(
                f"\n[bold]Correction:[/bold] {correction_mode.value} "
                f"decision {target_decision_id}"
            )
            console.print(f"[bold]Guidance:[/bold] {guidance}")
            confirm = typer.confirm("\nProceed with correction?")
            if not confirm:
                console.print("[yellow]Cancelled.[/yellow]")
                raise typer.Exit(0)

        # Execute the correction
        result = svc.execute_correction(
            request.correction_id,
            decision_tree=decision_tree,
            influence_edges=influence_edges,
        )

        if fmt != OutputFormat.RICH.value:
            data = {
                "correction": {
                    "mode": correction_mode.value,
                },
                "correction_id": result.correction_id,
                "status": result.status.value,
                "new_decisions": result.new_decisions,
                "reverted_decisions": result.reverted_decisions,
            }
            console.print(
                format_output(
                    data,
                    fmt,
                    command="plan correct",
                    status="ok",
                    exit_code=0,
                    messages=[
                        {
                            "level": "ok",
                            "text": f"Correction applied ({result.correction_id})",
                        }
                    ],
                )
            )
        else:
            console.print(
                f"[green]✓[/green] Correction applied: {result.correction_id}"
            )
            if result.reverted_decisions:
                console.print(f"  Reverted: {', '.join(result.reverted_decisions)}")
            if result.new_decisions:
                console.print(f"  New decisions: {', '.join(result.new_decisions)}")

    except RNF as e:
        console.print(f"[red]Not found:[/red] {e.message}")
        raise typer.Abort() from e
    except ValidationError as e:
        console.print(f"[red]Validation Error:[/red] {e.message}")
        raise typer.Abort() from e
    except CleverAgentsError as e:
        console.print(f"[red]Error:[/red] {e.message}")
        raise typer.Abort() from e


@app.command("resume")
def resume_plan_cmd(
    plan_id: Annotated[
        str,
        typer.Argument(help="Plan ID to resume"),
    ],
    dry_run: Annotated[
        bool,
        typer.Option(
            "--dry-run",
            help="Show where execution will resume without changing state",
        ),
    ] = False,
    fmt: Annotated[
        str,
        typer.Option(
            "--format",
            "-f",
            help=_FORMAT_HELP,
        ),
    ] = "rich",
) -> None:
    """Resume a plan from its last checkpoint.

    Shows a resume summary (phase, step, decision_id) before executing.
    Use ``--dry-run`` to preview the resume point without changing state.

    Only non-terminal plans (not applied, cancelled, or constrained)
    can be resumed.

    Examples::

        agents plan resume PLAN123
        agents plan resume PLAN123 --dry-run
        agents plan resume PLAN123 --format json
    """
    from cleveragents.application.services.plan_resume_service import (
        PlanResumeService,
    )

    try:
        service = _get_lifecycle_service()
        resume_service = PlanResumeService(lifecycle_service=service)

        summary = resume_service.resume_plan(plan_id, dry_run=dry_run)

        if fmt != OutputFormat.RICH.value:
            console.print(format_output(summary.as_cli_dict(), fmt))
        else:
            mode_label = "[dim](dry-run)[/dim] " if dry_run else ""
            console.print(
                Panel(
                    f"[bold]Plan ID:[/bold] {summary.plan_id}\n"
                    f"[bold]Phase:[/bold] {summary.phase}\n"
                    f"[bold]State:[/bold] {summary.processing_state}\n"
                    f"[bold]Last Completed Step:[/bold] "
                    f"{summary.last_completed_step}\n"
                    f"[bold]Next Step:[/bold] {summary.next_step_index}"
                    f" / {summary.total_steps}\n"
                    + (
                        f"[bold]Decision ID:[/bold] {summary.decision_id}\n"
                        if summary.decision_id
                        else ""
                    )
                    + (
                        f"[bold]Checkpoint:[/bold] {summary.last_checkpoint_id}\n"
                        if summary.last_checkpoint_id
                        else ""
                    )
                    + (
                        f"[bold]Sandbox:[/bold] {summary.sandbox_ref}\n"
                        if summary.sandbox_ref
                        else ""
                    ),
                    title=f"{mode_label}Resume Summary",
                    expand=False,
                )
            )
            if dry_run:
                console.print("[dim]No state changes made (dry-run mode).[/dim]")
            else:
                console.print(
                    "[green]Plan resumed.[/green] "
                    "Execution will continue from "
                    f"step {summary.next_step_index}."
                )

    except PlanError as e:
        console.print(f"[red]Cannot resume:[/red] {e.message}")
        raise typer.Abort() from e
    except CleverAgentsError as e:
        console.print(f"[red]Error:[/red] {e.message}")
        raise typer.Abort() from e


def _resolve_active_plan_id() -> str:
    """Resolve the active plan ID when none is explicitly provided.

    Returns:
        The plan ID of the most recent non-terminal plan.

    Raises:
        typer.Abort: If no suitable plan is found.
    """

    def _resolve_from_cleveragents_home() -> str | None:
        """Best-effort fallback lookup using ``CLEVERAGENTS_HOME`` storage.

        In isolated subprocess environments some commands run from a project
        workspace while persistent state lives under ``CLEVERAGENTS_HOME``.
        When the default DB lookup returns no active plans, this fallback
        attempts to read lifecycle plans from ``$CLEVERAGENTS_HOME``.
        """

        explicit_db_override = any(
            os.environ.get(key, "").strip()
            for key in (
                "CLEVERAGENTS_DATABASE_URL",
                "CLEVERAGENTS_TEST_DATABASE_URL",
            )
        )
        if explicit_db_override:
            return None

        home_raw = os.environ.get("CLEVERAGENTS_HOME", "").strip()
        if not home_raw:
            return None

        home_db = (Path(home_raw).expanduser() / ".cleveragents" / "db.sqlite").resolve(
            strict=False
        )
        current_db = (Path.cwd() / ".cleveragents" / "db.sqlite").resolve(strict=False)
        if home_db == current_db:
            return None

        try:
            from cleveragents.infrastructure.database.unit_of_work import UnitOfWork

            home_url = f"sqlite:///{home_db}"
            uow = UnitOfWork(home_url, require_confirmation=False)
            with uow.transaction() as ctx:
                plans = ctx.lifecycle_plans.list_all()

            active = [p for p in plans if not p.is_terminal]
            if not active:
                return None
            return active[0].identity.plan_id
        except (CleverAgentsError, OSError, SQLAlchemyError, ValueError):
            return None

    try:
        service = _get_lifecycle_service()
        plans = service.list_plans()
        active = [p for p in plans if not p.is_terminal]
        if not active:
            fallback_plan_id = _resolve_from_cleveragents_home()
            if fallback_plan_id:
                return fallback_plan_id
            console.print(
                "[red]Error:[/red] No active plan found. "
                "Specify --plan <PLAN_ID> explicitly."
            )
            raise typer.Abort()
        return active[0].identity.plan_id
    except CleverAgentsError as exc:
        # Fall back to a placeholder when lifecycle service isn't available
        console.print(
            "[red]Error:[/red] Could not resolve active plan. "
            "Specify --plan <PLAN_ID> explicitly."
        )
        raise typer.Abort() from exc


# =============================================================================
# Checkpoint / Rollback Commands
# =============================================================================


@app.command("rollback")
def rollback_plan(
    plan_id: Annotated[
        str,
        typer.Argument(help="Plan ID to rollback"),
    ],
    checkpoint_id: Annotated[
        str | None,
        typer.Argument(help="Checkpoint ID to restore (positional)"),
    ] = None,
    to_checkpoint: Annotated[
        str | None,
        typer.Option(
            "--to-checkpoint",
            help="Checkpoint ID to restore (named option)",
        ),
    ] = None,
    yes: Annotated[
        bool,
        typer.Option(
            "--yes",
            "-y",
            help="Skip confirmation prompt",
        ),
    ] = False,
    fmt: Annotated[
        str,
        typer.Option(
            "--format",
            "-f",
            help=_FORMAT_HELP,
        ),
    ] = "rich",
) -> None:
    """Restore sandbox state to a named checkpoint.

    Rolls back the sandbox for the given plan to the state captured in
    the specified checkpoint.  The plan must not be applied, and the
    sandbox must still exist.

    The checkpoint can be specified either as a positional argument or
    via the ``--to-checkpoint`` named option.

    Examples:
        agents plan rollback 01ARZ3NDEK... 01BRZ4NFEK... --yes
        agents plan rollback 01ARZ3NDEK... --to-checkpoint 01BRZ4NFEK...
    """
    from cleveragents.application.container import get_container
    from cleveragents.core.exceptions import (
        BusinessRuleViolation,
        CleverAgentsError,
    )
    from cleveragents.core.exceptions import (
        ResourceNotFoundError as RNF,
    )

    # Resolve checkpoint from positional arg or --to-checkpoint option
    resolved_checkpoint_id = checkpoint_id or to_checkpoint
    if not resolved_checkpoint_id:
        # List mode: no checkpoint ID given — show available checkpoints
        container = get_container()
        svc = container.checkpoint_service()
        try:
            checkpoints = svc.list_checkpoints(plan_id)
        except RNF as exc:
            console.print(f"[red]Not found:[/red] {exc.message}")
            raise typer.Abort() from exc
        except CleverAgentsError as exc:
            console.print(f"[red]Error:[/red] {exc.message}")
            raise typer.Abort() from exc
        checkpoints = sorted(checkpoints, key=lambda cp: cp.created_at)
        if fmt != OutputFormat.RICH.value:
            data = {
                "checkpoints": [
                    {
                        "id": cp.checkpoint_id,
                        "created_at": cp.created_at.isoformat(),
                        "reason": cp.metadata.reason,
                    }
                    for cp in checkpoints
                ]
            }
            console.print(format_output(data, fmt))
        elif not checkpoints:
            console.print(f"[dim]No checkpoints available for plan {plan_id}.[/dim]")
        else:
            table = Table(show_header=True, expand=False)
            table.add_column("ID", style="cyan")
            table.add_column("Created", style="green")
            table.add_column("Reason")
            for cp in checkpoints:
                table.add_row(
                    cp.checkpoint_id,
                    cp.created_at.isoformat(),
                    cp.metadata.reason or "",
                )
            console.print(table)
        return

    container = get_container()
    # Use PlanLifecycleService for the actual rollback (service layer pattern).
    # CheckpointService is still used for UI metadata enrichment (confirmation
    # prompt) since it owns checkpoint metadata queries.
    lifecycle_svc = _get_lifecycle_service()
    svc = container.checkpoint_service()
    # Wire CheckpointService into PlanLifecycleService so rollback_plan can
    # delegate to it.  The container creates them independently to avoid a
    # circular-dependency at construction time; we join them here at the
    # call site where both are available.
    lifecycle_svc.checkpoint_service = svc

    if not yes:
        # Default prompt (used as fallback if metadata fetch fails)
        prompt_text = (
            f"\nRollback plan {plan_id} to checkpoint {resolved_checkpoint_id}?"
        )

        # Fetch checkpoint metadata to enrich the confirmation prompt
        try:
            checkpoint = svc.get_checkpoint(resolved_checkpoint_id)
            label = checkpoint.metadata.reason
            relative_time = _format_relative_time(checkpoint.created_at)
            label_display = f"'{label}'" if label else resolved_checkpoint_id

            prompt_text = (
                f"\nRoll back plan {plan_id} to checkpoint "
                f"{label_display} (created {relative_time})?"
            )

            # Count decisions and child plans that would be invalidated
            decisions_count = 0
            child_plans_count = 0
            try:
                from datetime import UTC

                from cleveragents.domain.models.core.decision import DecisionType

                decision_svc = container.decision_service()
                all_decisions = decision_svc.list_decisions(plan_id)
                cp_time = checkpoint.created_at
                if cp_time.tzinfo is None:
                    cp_time = cp_time.replace(tzinfo=UTC)
                for d in all_decisions:
                    d_time = d.created_at
                    if d_time.tzinfo is None:
                        d_time = d_time.replace(tzinfo=UTC)
                    if d_time > cp_time:
                        decisions_count += 1
                        if d.decision_type in (
                            DecisionType.SUBPLAN_SPAWN,
                            DecisionType.SUBPLAN_PARALLEL_SPAWN,
                        ):
                            child_plans_count += 1
            except CleverAgentsError:
                # Best-effort: if the decision service is unavailable, skip
                # the side-effects line rather than blocking the confirmation.
                pass

            # Build side-effects message including only non-zero counts
            side_effects_parts: list[str] = []
            if decisions_count > 0:
                side_effects_parts.append(
                    f"invalidate {decisions_count} "
                    f"decision{'s' if decisions_count != 1 else ''}"
                )
            if child_plans_count > 0:
                side_effects_parts.append(
                    f"cancel {child_plans_count} child "
                    f"plan{'s' if child_plans_count != 1 else ''}"
                )
            if side_effects_parts:
                console.print("This will " + " and ".join(side_effects_parts) + ".")

        except CleverAgentsError:
            # If checkpoint metadata fetch fails (e.g. ResourceNotFoundError),
            # fall back to the default prompt.
            pass

        confirm = typer.confirm(prompt_text)
        if not confirm:
            console.print("[yellow]Rollback cancelled.[/yellow]")
            raise typer.Abort()

    try:
        t0 = time.monotonic()
        # Route through PlanLifecycleService to enforce state validation
        # and emit PLAN_ROLLED_BACK domain events (issue #3677).
        result = lifecycle_svc.rollback_plan(plan_id, resolved_checkpoint_id)
        elapsed = time.monotonic() - t0

        # Spec-aligned output envelope (lines 15760-15797)
        data = {
            "rollback_summary": {
                "plan_id": plan_id,
                "from_checkpoint_id": result.from_checkpoint_id,
                "restored_files_count": result.restored_files_count,
            },
            "changes_reverted": result.changed_paths,
            "impact": {
                "files_affected": result.restored_files_count,
            },
            "post_rollback_state": {
                "active_checkpoint": result.from_checkpoint_id,
                "plan_id": plan_id,
            },
            "timing": {
                "elapsed_seconds": round(elapsed, 3),
            },
            "messages": ["Rollback completed successfully."],
        }

        if fmt != OutputFormat.RICH.value:
            console.print(format_output(data, fmt))
        else:
            # Rollback Summary panel
            label = getattr(result, "label", None) or ""
            label_line = f"\n[bold]Label:[/bold] {label}" if label else ""
            console.print(
                Panel(
                    f"[bold]Plan:[/bold] {plan_id}\n"
                    f"[bold]Checkpoint:[/bold] {result.from_checkpoint_id}"
                    f"{label_line}\n"
                    f"[bold]Files:[/bold] {result.restored_files_count} reverted",
                    title="Rollback Summary",
                    expand=False,
                )
            )

            # Changes Reverted table
            changes_table = Table(show_header=True, expand=False)
            changes_table.add_column("File", style="cyan")
            changes_table.add_column("Action", style="green")
            changes_reverted = getattr(result, "changes_reverted", None)
            if changes_reverted and isinstance(changes_reverted, list):
                for entry in changes_reverted:
                    if isinstance(entry, dict):
                        changes_table.add_row(
                            str(entry.get("file", "")),
                            str(entry.get("action", "restored")),
                        )
                    else:
                        changes_table.add_row(str(entry), "restored")
            else:
                for path in result.changed_paths:
                    changes_table.add_row(str(path), "restored")
            console.print(Panel(changes_table, title="Changes Reverted", expand=False))

            # Impact panel
            child_plans_invalidated = getattr(result, "child_plans_invalidated", None)
            sandbox_state = getattr(result, "sandbox", None) or (
                f"restored to {result.from_checkpoint_id}"
            )
            decisions_after_cp = getattr(result, "decisions_after_cp", None)
            tool_calls_after_cp = getattr(result, "tool_calls_after_cp", None)
            impact_lines = []
            if child_plans_invalidated is not None:
                impact_lines.append(
                    f"[bold]Child Plans Invalidated:[/bold] {child_plans_invalidated}"
                )
            impact_lines.append(f"[bold]Sandbox:[/bold] {sandbox_state}")
            if decisions_after_cp is not None:
                impact_lines.append(
                    f"[bold]Decisions After CP:[/bold] {decisions_after_cp} discarded"
                )
            if tool_calls_after_cp is not None:
                impact_lines.append(
                    f"[bold]Tool Calls After CP:[/bold] {tool_calls_after_cp} undone"
                )
            console.print(
                Panel(
                    "\n".join(impact_lines),
                    title="Impact",
                    expand=False,
                )
            )

            # Post-Rollback State panel
            phase = getattr(result, "phase", None) or "execute"
            state = getattr(result, "state", None) or "queued"
            checkpoints_remaining = getattr(result, "checkpoints_remaining", None)
            post_rollback_lines = [
                f"[bold]Phase:[/bold] {phase}",
                f"[bold]State:[/bold] {state}",
            ]
            if checkpoints_remaining is not None:
                post_rollback_lines.append(
                    f"[bold]Checkpoints Remaining:[/bold] {checkpoints_remaining}"
                )
            console.print(
                Panel(
                    "\n".join(post_rollback_lines),
                    title="Post-Rollback State",
                    expand=False,
                )
            )

            console.print("[green]\u2713 OK[/green] Rollback complete")

    except BusinessRuleViolation as e:
        console.print(f"[red]Rollback blocked:[/red] {e.message}")
        raise typer.Abort() from e
    except RNF as e:
        console.print(f"[red]Not found:[/red] {e.message}")
        raise typer.Abort() from e
    except ValidationError as e:
        console.print(f"[red]Validation Error:[/red] {e.message}")
        raise typer.Abort() from e
    except CleverAgentsError as e:
        console.print(f"[red]Error:[/red] {e.message}")
        raise typer.Abort() from e


# ---------------------------------------------------------------------------
# plan explain
# ---------------------------------------------------------------------------


def _build_explain_dict(
    decision: Decision,
    *,
    show_context: bool = False,
    show_reasoning: bool = False,
) -> dict[str, object]:
    """Build a dict for ``plan explain`` output from a Decision."""
    data: dict[str, object] = {
        "decision_id": decision.decision_id,
        "plan_id": decision.plan_id,
        "type": str(decision.decision_type),
        "sequence": decision.sequence_number,
        "question": decision.question,
        "chosen": decision.chosen_option,
        "confidence": decision.confidence_score,
        "parent": decision.parent_decision_id or "(root)",
        "is_correction": decision.is_correction,
        "superseded": decision.is_superseded,
        "created_at": decision.created_at.isoformat(),
        "alternatives": [
            {
                "index": i + 1,
                "description": alt,
                "chosen": alt == decision.chosen_option,
            }
            for i, alt in enumerate(decision.alternatives_considered)
        ],
    }
    if show_reasoning:
        data["rationale"] = decision.rationale
        data["actor_reasoning"] = decision.actor_reasoning or ""
    if show_context:
        snap = decision.context_snapshot
        data["context_snapshot"] = {
            "hot_context_hash": snap.hot_context_hash,
            "hot_context_ref": snap.hot_context_ref,
            "actor_state_ref": snap.actor_state_ref,
            "relevant_resources": [
                {"resource_id": r.resource_id, "path": r.path}
                for r in snap.relevant_resources
            ],
        }
    return data


@app.command("explain")
def explain_decision_cmd(
    identifier: Annotated[
        str,
        typer.Argument(help="Decision ULID to explain"),
    ],
    fmt: Annotated[
        str,
        typer.Option("--format", "-f", help=_FORMAT_HELP),
    ] = "rich",
    show_context: Annotated[
        bool,
        typer.Option("--show-context", help="Include context snapshot"),
    ] = False,
    show_reasoning: Annotated[
        bool,
        typer.Option("--show-reasoning", help="Include rationale and actor reasoning"),
    ] = False,
) -> None:
    """Explain a single decision in a plan."""
    from cleveragents.application.container import get_container
    from cleveragents.application.services.decision_service import (
        DecisionNotFoundError,
    )

    container = get_container()
    svc = container.decision_service()

    # Look up the decision by its ULID.
    try:
        decision = svc.get_decision(identifier)
    except DecisionNotFoundError:
        console.print(f"[red]Error:[/red] '{identifier}' not found as a decision.")
        raise typer.Exit(1) from None

    data = _build_explain_dict(
        decision,
        show_context=show_context,
        show_reasoning=show_reasoning,
    )

    if fmt in (OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TABLE):
        console.print(format_output(data, fmt))
    else:
        # rich / plain
        table = Table(title="Decision Details", show_header=False, expand=False)
        table.add_column("Field", style="bold")
        table.add_column("Value")
        import json as _json

        for key, val in data.items():
            if isinstance(val, dict):
                table.add_row(key, _json.dumps(val, indent=2, default=str))
            elif isinstance(val, list):
                table.add_row(key, _json.dumps(val, default=str))
            else:
                table.add_row(key, str(val))
        console.print(Panel(table, expand=False))


# ---------------------------------------------------------------------------
# plan tree
# ---------------------------------------------------------------------------


def build_decision_tree(
    decisions: list[Decision],
    *,
    show_superseded: bool = False,
    max_depth: int = 0,
) -> list[dict[str, object]]:
    """Build a nested tree structure from a flat list of decisions.

    Uses ``collections.deque`` for BFS traversal (never ``list.pop(0)``).

    Args:
        decisions: Flat list of Decision objects.
        show_superseded: If False, filter out superseded decisions.
        max_depth: Maximum tree depth (0 = unlimited).

    Returns:
        A list of root-level tree-node dicts, each with a ``children`` key.
    """
    from collections import deque

    filtered = (
        decisions if show_superseded else [d for d in decisions if not d.is_superseded]
    )

    # Index by decision_id (only non-superseded when show_superseded=False)
    by_id: dict[str, Decision] = {d.decision_id: d for d in filtered}
    # Map parent_id -> children from ALL decisions so that the BFS
    # orphan guard (``child_id not in by_id``) correctly skips
    # superseded children instead of silently losing them.
    children_map: dict[str | None, list[str]] = {}
    for d in decisions:
        children_map.setdefault(d.parent_decision_id, []).append(d.decision_id)

    # Find roots (parent_decision_id is None or parent not in by_id)
    roots: list[str] = []
    for d in filtered:
        if d.parent_decision_id is None or d.parent_decision_id not in by_id:
            roots.append(d.decision_id)

    def _node_dict(d: Decision) -> dict[str, object]:
        return {
            "decision_id": d.decision_id,
            "type": str(d.decision_type),
            "sequence": d.sequence_number,
            "question": d.question,
            "chosen": d.chosen_option,
            "confidence": d.confidence_score,
            "superseded": d.is_superseded,
            "children": [],
        }

    # BFS to build tree
    result: list[dict[str, object]] = []
    # queue entries: (decision_id, parent_children_list, current_depth)
    queue: deque[tuple[str, list[dict[str, object]], int]] = deque()
    for rid in roots:
        node = _node_dict(by_id[rid])
        result.append(node)
        queue.append((rid, node["children"], 1))  # type: ignore[arg-type]  # children value is list at runtime; dict[str, object] prevents narrowing

    while queue:
        did, parent_list, depth_val = queue.popleft()
        if max_depth > 0 and depth_val >= max_depth:
            continue
        for child_id in children_map.get(did, []):
            if child_id not in by_id:
                continue
            child_node = _node_dict(by_id[child_id])
            parent_list.append(child_node)
            queue.append(
                (child_id, child_node["children"], depth_val + 1)  # type: ignore[arg-type]  # children value is list at runtime; dict[str, object] prevents narrowing
            )

    return result


def _get_decision_label(decision_type: str, per_type_ordinal: int = 0) -> str:
    """Generate a human-readable label for a decision type.

    Args:
        decision_type: The type of decision (e.g., "strategy_choice",
            "invariant_enforced")
        per_type_ordinal: The ordinal number within this decision type
            (1st invariant, 2nd invariant, etc., restarting per type).
            Defaults to 0 for non-repeating types.

    Returns:
        A human-readable label for the decision, formatted as
        "{TypeName}" or "{TypeName} {ordinal}" for repeating types.
    """
    type_map = {
        "prompt_definition": "Root",
        "invariant_enforced": "Invariant",
        "strategy_choice": "Strategy",
        "implementation_choice": "Implementation",
        "subplan_spawn": "Spawn",
        "subplan_parallel_spawn": "Parallel",
    }

    base_label = type_map.get(decision_type, str(decision_type))

    # For types that can repeat multiple times, add per-type ordinal
    if decision_type in (
        "invariant_enforced",
        "subplan_spawn",
        "subplan_parallel_spawn",
    ):
        return f"{base_label} {per_type_ordinal}"

    return base_label


def _build_tree_data(
    plan_id: str,
    tree_data: list[dict[str, object]],
    decisions: list[Decision],
    show_superseded: bool = False,
    started_at: datetime | None = None,
) -> dict[str, object]:
    """Build the data payload for ``agents plan tree --format json/yaml``.

    Returns the ``data`` dict that will be wrapped in the spec-required
    command envelope by ``format_output``.
    """
    filtered = (
        decisions if show_superseded else [d for d in decisions if not d.is_superseded]
    )

    def count_nodes(nodes: list[dict[str, object]]) -> int:
        count = 0
        for node in nodes:
            count += 1
            children = node.get("children", [])
            if isinstance(children, list):
                count += count_nodes(children)
        return count

    def compute_depth(nodes: list[dict[str, object]]) -> int:
        if not nodes:
            return 0
        max_depth = 0
        for node in nodes:
            children = node.get("children", [])
            if isinstance(children, list) and children:
                max_depth = max(max_depth, 1 + compute_depth(children))
        return max_depth

    nodes_count = count_nodes(tree_data)
    tree_depth = compute_depth(tree_data)

    child_plan_ids: set[str] = set()
    for d in filtered:
        if d.decision_type in ("subplan_spawn", "subplan_parallel_spawn") and d.plan_id:
            child_plan_ids.add(d.plan_id)

    child_plans_count = len(child_plan_ids)
    child_plans_str = f"{child_plans_count}+" if child_plans_count > 0 else "0"

    invariants_count = sum(
        1 for d in filtered if d.decision_type == "invariant_enforced"
    )

    superseded_count = sum(1 for d in decisions if d.is_superseded)

    summary = {
        "nodes": nodes_count,
        "depth": tree_depth,
        "child_plans": child_plans_str,
        "invariants": invariants_count,
        "superseded": superseded_count,
    }

    type_counts: dict[str, int] = {}
    decision_ids: dict[str, str] = {}

    for d in filtered:
        type_counts[d.decision_type] = type_counts.get(d.decision_type, 0) + 1
        ordinal = type_counts[d.decision_type]

        if d.decision_type == "prompt_definition":
            key = "root"
        elif d.decision_type == "invariant_enforced":
            key = f"invariant_{ordinal}"
        elif d.decision_type == "strategy_choice":
            key = "strategy"
        elif d.decision_type == "implementation_choice":
            key = f"implementation_{ordinal}"
        elif d.decision_type == "subplan_spawn":
            key = f"spawn_{ordinal}"
        elif d.decision_type == "subplan_parallel_spawn":
            key = f"parallel_{ordinal}"
        else:
            key = f"{d.decision_type}_{ordinal}"

        decision_ids[key] = d.decision_id

    child_plans_list: list[dict[str, object]] = []
    for d in filtered:
        if d.decision_type in ("subplan_spawn", "subplan_parallel_spawn") and d.plan_id:
            child_plans_list.append(
                {
                    "id": d.plan_id,
                    "phase": "execute",
                    "state": "queued",
                }
            )

    def convert_tree_node(node: dict[str, object]) -> dict[str, object]:
        """Convert internal tree node format to spec format."""
        spec_node: dict[str, object] = {
            "type": node.get("type"),
            "description": node.get("question") or node.get("description"),
        }

        if node.get("confidence") is not None:
            spec_node["confidence"] = node.get("confidence")

        if node.get("type") in ("subplan_spawn", "subplan_parallel_spawn"):
            spec_node["plan_id"] = node.get("plan_id", "")

        children = node.get("children", [])
        if isinstance(children, list) and children:
            spec_node["children"] = [convert_tree_node(child) for child in children]

        return spec_node

    spec_tree = convert_tree_node(tree_data[0]) if tree_data else None

    return {
        "plan_id": plan_id,
        "tree": spec_tree,
        "summary": summary,
        "child_plans": child_plans_list,
        "decision_ids": decision_ids,
    }


@app.command("tree")
def tree_decisions_cmd(
    plan_id: Annotated[
        str,
        typer.Argument(help="Plan ULID to display decision tree for"),
    ],
    fmt: Annotated[
        str,
        typer.Option("--format", "-f", help=_FORMAT_HELP),
    ] = "rich",
    show_superseded: Annotated[
        bool,
        typer.Option("--show-superseded", help="Include superseded decisions in tree"),
    ] = False,
    depth: Annotated[
        int,
        typer.Option("--depth", help="Maximum tree depth (0 = unlimited)"),
    ] = 0,
) -> None:
    """Display the decision tree for a plan."""
    from cleveragents.application.container import get_container

    _tree_cmd_start = datetime.now(UTC)
    container = get_container()
    svc = container.decision_service()
    decisions = svc.list_decisions(plan_id)
    if not decisions:
        console.print(f"No decisions found for plan '{plan_id}'.")
        return

    tree_data = build_decision_tree(
        decisions,
        show_superseded=show_superseded,
        max_depth=depth,
    )

    if fmt in (OutputFormat.JSON, OutputFormat.YAML):
        tree_data_dict = _build_tree_data(
            plan_id, tree_data, decisions, show_superseded, started_at=_tree_cmd_start
        )
        console.print(
            format_output(
                tree_data_dict,
                fmt,
                command="plan tree",
                messages=[{"level": "ok", "text": "Decision tree rendered"}],
            )
        )
    elif fmt == OutputFormat.TABLE:
        # Flatten for table view
        filtered = (
            decisions
            if show_superseded
            else [d for d in decisions if not d.is_superseded]
        )
        by_id = {d.decision_id: d for d in filtered}
        table = Table(title="Decision Tree", show_header=True)
        table.add_column("ID", max_width=26)
        table.add_column("Type")
        table.add_column("Seq")
        table.add_column("Question", max_width=40)
        table.add_column("Chosen", max_width=30)
        table.add_column("Confidence")
        table.add_column("Superseded")
        for d in filtered:
            if depth > 0:
                # Compute depth of this node
                node_depth = 0
                pid = d.parent_decision_id
                seen: set[str] = set()
                while pid and pid not in seen:
                    seen.add(pid)
                    node_depth += 1
                    parent = by_id.get(pid)
                    pid = parent.parent_decision_id if parent else None
                if node_depth >= depth:
                    continue
            table.add_row(
                d.decision_id,
                str(d.decision_type),
                str(d.sequence_number),
                d.question[:40] if len(d.question) > 40 else d.question,
                (
                    d.chosen_option[:30]
                    if len(d.chosen_option) > 30
                    else d.chosen_option
                ),
                str(d.confidence_score) if d.confidence_score is not None else "-",
                str(d.is_superseded),
            )
        console.print(table)
    else:
        # rich / plain - use Rich Tree
        from rich.tree import Tree as RichTree

        rich_tree = RichTree(f"[bold]Plan {plan_id}[/bold]")

        filtered = (
            decisions
            if show_superseded
            else [d for d in decisions if not d.is_superseded]
        )

        # Build per-type ordinals (count restarts for each decision type)
        type_counts: dict[str, int] = {}
        decision_ordinals: dict[str, int] = {}
        for d in filtered:
            type_counts[d.decision_type] = type_counts.get(d.decision_type, 0) + 1
            decision_ordinals[d.decision_id] = type_counts[d.decision_type]

        from collections import deque as _deque

        filt_ids = {d.decision_id for d in filtered}
        roots = [
            d
            for d in filtered
            if d.parent_decision_id is None or d.parent_decision_id not in filt_ids
        ]
        tree_queue: _deque[tuple[Decision, RichTree, int]] = _deque()
        for root_d in roots:
            label = (
                f"[bold]{root_d.decision_id}[/bold] "
                f"({root_d.decision_type}) "
                f"Q: {root_d.question[:40]}"
            )
            branch = rich_tree.add(label)
            tree_queue.append((root_d, branch, 1))

        children_map: dict[str | None, list[Decision]] = {}
        for d in filtered:
            children_map.setdefault(d.parent_decision_id, []).append(d)

        while tree_queue:
            current, parent_branch, cur_depth = tree_queue.popleft()
            if depth > 0 and cur_depth >= depth:
                continue
            for child in children_map.get(current.decision_id, []):
                clabel = (
                    f"[bold]{child.decision_id}[/bold] "
                    f"({child.decision_type}) "
                    f"Q: {child.question[:40]}"
                )
                cbranch = parent_branch.add(clabel)
                tree_queue.append((child, cbranch, cur_depth + 1))

        # Output tree
        if fmt == OutputFormat.PLAIN:
            console.print(rich_tree)
            # Add "Decision IDs (for correction)" section for plain format
            console.print("\nDecision IDs (for correction)")
            for d in filtered:
                type_label = _get_decision_label(
                    d.decision_type, decision_ordinals[d.decision_id]
                )
                console.print(f"  {type_label}: {d.decision_id}")
        else:
            # Rich format - output tree and add Decision IDs panel
            console.print(rich_tree)

            from rich.panel import Panel

            decision_ids_content = []
            for d in filtered:
                type_label = _get_decision_label(
                    d.decision_type, decision_ordinals[d.decision_id]
                )
                decision_ids_content.append(
                    f"[bold]{type_label}:[/bold] {d.decision_id}"
                )

            if decision_ids_content:
                console.print(
                    Panel(
                        "\n".join(decision_ids_content),
                        title="Decision IDs (for correction)",
                        expand=False,
                    )
                )


# ---------------------------------------------------------------------------
# plan checkpoint-list / checkpoint-delete
# ---------------------------------------------------------------------------


@app.command("checkpoint-list")
def checkpoint_list_cmd(
    plan_id: Annotated[
        str,
        typer.Argument(help="Plan ID (ULID) to list checkpoints for"),
    ],
    sort: Annotated[
        str,
        typer.Option(
            "--sort",
            help="Sort order: asc (oldest first) or desc (newest first)",
        ),
    ] = "asc",
    checkpoint_type: Annotated[
        str | None,
        typer.Option(
            "--type",
            help="Filter by type: pre_write, post_step, manual, pre_decision",
        ),
    ] = None,
    fmt: Annotated[
        str,
        typer.Option(
            "--format",
            "-f",
            help=_FORMAT_HELP,
        ),
    ] = "rich",
) -> None:
    """List all checkpoints for a plan.

    Displays checkpoint ID, timestamp, type, and state summary for each
    checkpoint associated with the given plan.

    Examples::

        agents plan checkpoint-list PLAN123
        agents plan checkpoint-list PLAN123 --sort desc
        agents plan checkpoint-list PLAN123 --type manual
        agents plan checkpoint-list PLAN123 --format json
    """
    from cleveragents.application.container import get_container
    from cleveragents.core.exceptions import ResourceNotFoundError as RNF

    try:
        container = get_container()
        svc = container.checkpoint_service()

        checkpoints = svc.list_checkpoints(plan_id)

        # Apply type filter
        if checkpoint_type is not None:
            checkpoints = [
                cp for cp in checkpoints if cp.checkpoint_type == checkpoint_type
            ]

        # Apply sort order
        reverse = sort.lower() == "desc"
        checkpoints = sorted(checkpoints, key=lambda cp: cp.created_at, reverse=reverse)

        if fmt != OutputFormat.RICH.value:
            data: list[dict[str, object]] = [
                {
                    "checkpoint_id": cp.checkpoint_id,
                    "plan_id": cp.plan_id,
                    "checkpoint_type": cp.checkpoint_type,
                    "sandbox_ref": cp.sandbox_ref,
                    "created_at": cp.created_at.isoformat(),
                    "reason": cp.metadata.reason,
                    "phase": cp.metadata.phase,
                    "decision_id": cp.decision_id,
                }
                for cp in checkpoints
            ]
            console.print(format_output(data, fmt))
            return

        if not checkpoints:
            console.print(f"[dim]No checkpoints found for plan {plan_id}.[/dim]")
            return

        table = Table(title=f"Checkpoints for Plan {plan_id}", show_header=True)
        table.add_column("Checkpoint ID", style="cyan", max_width=26)
        table.add_column("Checkpoint Type", style="yellow")
        table.add_column("Created", style="green")
        table.add_column("Reason")
        table.add_column("Phase")
        table.add_column("Decision ID", style="dim", max_width=26)

        for cp in checkpoints:
            table.add_row(
                cp.checkpoint_id,
                cp.checkpoint_type,
                _format_relative_time(cp.created_at),
                cp.metadata.reason or "(none)",
                cp.metadata.phase or "(none)",
                cp.decision_id or "(none)",
            )

        console.print(table)
        console.print(
            "[dim]Fields: checkpoint_id, checkpoint_type, created_at, reason, "
            "phase, decision_id[/dim]"
        )
        cp_word = "checkpoint" if len(checkpoints) == 1 else "checkpoints"
        console.print(
            f"[green bold]✓ OK[/green bold] {len(checkpoints)} {cp_word} listed"
        )

    except RNF as e:
        console.print(f"[red]Not found:[/red] {e.message}")
        raise typer.Abort() from e
    except CleverAgentsError as e:
        console.print(f"[red]Error:[/red] {e.message}")
        raise typer.Abort() from e


@app.command("checkpoint-delete")
def checkpoint_delete_cmd(
    checkpoint_ids: Annotated[
        list[str] | None,
        typer.Argument(
            help="One or more checkpoint IDs to delete",
            metavar="CHECKPOINT_ID",
        ),
    ] = None,
    yes: Annotated[
        bool,
        typer.Option(
            "--yes",
            "-y",
            help="Skip confirmation prompt",
        ),
    ] = False,
    fmt: Annotated[
        str,
        typer.Option(
            "--format",
            "-f",
            help=_FORMAT_HELP,
        ),
    ] = "rich",
) -> None:
    """Delete one or more checkpoints by ID.

    Accepts one or more checkpoint IDs as positional arguments.
    Prompts for confirmation unless --yes is supplied.

    Examples::

        agents plan checkpoint-delete CP123
        agents plan checkpoint-delete CP123 CP456 --yes
        agents plan checkpoint-delete CP123 --format json
    """
    from cleveragents.application.container import get_container
    from cleveragents.core.exceptions import ResourceNotFoundError as RNF

    ids: list[str] = list(checkpoint_ids or [])
    if not ids:
        console.print("[red]Error:[/red] At least one checkpoint ID is required.")
        raise typer.Abort()

    if not yes:
        cp_word = "checkpoint" if len(ids) == 1 else "checkpoints"
        ids_display = ", ".join(ids)
        confirm = typer.confirm(f"Delete {len(ids)} {cp_word}: {ids_display}?")
        if not confirm:
            console.print("[yellow]Deletion cancelled.[/yellow]")
            raise typer.Abort()

    container = get_container()
    svc = container.checkpoint_service()

    deleted: list[str] = []
    errors: list[dict[str, str]] = []

    for cp_id in ids:
        try:
            svc.delete_checkpoint(cp_id)
            deleted.append(cp_id)
        except RNF:
            errors.append({"checkpoint_id": cp_id, "error": "not found"})
        except CleverAgentsError as e:
            errors.append({"checkpoint_id": cp_id, "error": e.message})

    if fmt != OutputFormat.RICH.value:
        result_data: dict[str, object] = {
            "deleted": deleted,
            "errors": errors,
            "deleted_count": len(deleted),
            "error_count": len(errors),
        }
        console.print(format_output(result_data, fmt))
        return

    if deleted:
        cp_word = "checkpoint" if len(deleted) == 1 else "checkpoints"
        console.print(f"[green bold]✓ OK[/green bold] {len(deleted)} {cp_word} deleted")
        for cp_id in deleted:
            console.print(f"  [dim]Deleted:[/dim] {cp_id}")

    if errors:
        for err in errors:
            cp_id_val = err["checkpoint_id"]
            err_val = err["error"]
            console.print(f"[red]Error:[/red] {cp_id_val} — {err_val}")
        if not deleted:
            raise typer.Abort()
