"""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 os
import re
import shutil
import time
import warnings
from contextlib import suppress
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Any

import typer
from rich.console import Console
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.table import Table
from sqlalchemy.exc import SQLAlchemyError

from cleveragents.a2a.models import A2aRequest
from cleveragents.cli.formatting import OutputFormat, format_output
from cleveragents.core.exceptions import (
    CleverAgentsError,
    PlanError,
    ValidationError,
)
from cleveragents.domain.models.core.plan import PlanPhase, ProcessingState

# 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."
)


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


_LEGACY_DEPRECATION_MSG = (
    "This command uses the legacy plan workflow and is deprecated.\n"
    "WARNING: The legacy and v3 plan workflows are INCOMPATIBLE and cannot\n"
    "be mixed. Plans created with legacy commands ('agents tell', 'agents build')\n"
    "exist only in the legacy storage system and cannot be referenced by v3\n"
    "commands ('agents plan execute', 'agents plan apply').\n\n"
    "To migrate to the v3 workflow:\n"
    "  1. Use 'agents plan use <action> <project>' to create a new v3 plan.\n"
    "  2. Use 'agents plan execute <PLAN_ID>' to execute it.\n"
    "  3. Use 'agents plan apply <PLAN_ID>' to apply changes.\n\n"
    "Do NOT attempt to use a legacy plan name with v3 commands — it will fail."
)

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

# Create sub-app for plan commands
app = typer.Typer(
    help=(
        "Plan management commands (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.
    """
    from cleveragents.domain.models.core.plan import Plan as LifecyclePlan

    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 = {
            # TODO: derive strategy from plan's actual sandbox configuration
            # once the plan model exposes it; "git_worktree" is the current
            # default strategy used in all local execution environments.
            "strategy": "git_worktree",
            "path": primary_ref,
            "branch": f"cleveragents/plan-{plan_id[:8]}",
            "status": "active",
        }
    else:
        sandbox = {
            # TODO: derive strategy from plan's actual sandbox configuration
            # once the plan model exposes it; "git_worktree" is the current
            # default strategy used in all local execution environments.
            "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"],
    }


# Programmatic wrapper functions for testing and scripting
def tell_command(prompt: str, name: str | None = None) -> None:
    """Programmatic interface for creating a plan from instructions.

    .. deprecated::
        Use ``PlanLifecycleService.use_action`` instead.

    Args:
        prompt: Instructions for what you want the AI to do
        name: Optional name for the plan
    """
    warnings.warn(_LEGACY_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
    from cleveragents.application.container import get_container
    from cleveragents.application.services.plan_service import PlanService
    from cleveragents.application.services.project_service import ProjectService

    container = get_container()
    plan_service: PlanService = container.plan_service()
    project_service: ProjectService = container.project_service()

    # Get current project
    project = project_service.get_current_project()
    if not project:
        raise CleverAgentsError("No project found. Run 'agents init' first.")

    # Create the plan
    plan_service.create_plan(project=project, prompt=prompt, name=name)


def build_command(
    verbose: bool = False,
    actor: str | None = None,
) -> list[Change]:
    """Programmatic interface for building the current plan.

    .. deprecated::
        Use ``PlanLifecycleService`` execute phase instead.

    Args:
        verbose: Whether to show detailed output
        actor: Optional actor name override

    Returns:
        List of generated changes
    """
    warnings.warn(_LEGACY_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)

    from cleveragents.application.container import get_container
    from cleveragents.application.services.plan_service import PlanService
    from cleveragents.application.services.project_service import ProjectService

    container = get_container()
    plan_service: PlanService = container.plan_service()
    project_service: ProjectService = container.project_service()

    # Get current project
    project = project_service.get_current_project()
    if not project:
        raise CleverAgentsError("No project found. Run 'agents init' first.")

    # Build the plan
    changes = plan_service.build_plan(
        project=project,
        actor=actor,
    )
    return changes if changes else []


def apply_command(confirm: bool = True) -> int:
    """Programmatic interface for applying plan changes.

    .. deprecated::
        Use ``PlanLifecycleService`` apply phase instead.

    Args:
        confirm: Whether to skip confirmation (for testing)

    Returns:
        Number of changes applied
    """
    warnings.warn(_LEGACY_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
    from cleveragents.application.container import get_container
    from cleveragents.application.services.plan_service import PlanService
    from cleveragents.application.services.project_service import ProjectService

    container = get_container()
    plan_service: PlanService = container.plan_service()
    project_service: ProjectService = container.project_service()

    # Get current project
    project = project_service.get_current_project()
    if not project:
        raise CleverAgentsError("No project found. Run 'agents init' first.")

    # Apply changes
    return plan_service.apply_changes(project=project)


def new_command(name: str) -> None:
    """Programmatic interface for creating a new empty plan.

    .. deprecated::
        Use ``PlanLifecycleService.use_action`` instead.

    Args:
        name: Name for the new plan
    """
    warnings.warn(_LEGACY_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
    from cleveragents.application.container import get_container
    from cleveragents.application.services.plan_service import PlanService
    from cleveragents.application.services.project_service import ProjectService

    container = get_container()
    plan_service: PlanService = container.plan_service()
    project_service: ProjectService = container.project_service()

    # Get current project
    project = project_service.get_current_project()
    if not project:
        raise CleverAgentsError("No project found. Run 'agents init' first.")

    # Create new plan
    plan_service.new_plan(project=project, name=name)


def current_command() -> Plan | None:
    """Programmatic interface for getting the current plan.

    .. deprecated::
        Use ``PlanLifecycleService.get_plan`` or ``list_plans`` instead.

    Returns:
        Current plan or None if no current plan
    """
    warnings.warn(_LEGACY_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
    from cleveragents.application.container import get_container
    from cleveragents.application.services.plan_service import PlanService
    from cleveragents.application.services.project_service import ProjectService

    container = get_container()
    plan_service: PlanService = container.plan_service()
    project_service: ProjectService = container.project_service()

    # Get current project
    project = project_service.get_current_project()
    if not project:
        raise CleverAgentsError("No project found. Run 'agents init' first.")

    # Get current plan
    return plan_service.get_current_plan(project=project)


def list_command() -> list[Plan]:
    """Programmatic interface for listing all plans.

    .. deprecated::
        Use ``PlanLifecycleService.list_plans`` instead.

    Returns:
        List of all plans in the current project
    """
    warnings.warn(_LEGACY_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
    from cleveragents.application.container import get_container
    from cleveragents.application.services.plan_service import PlanService
    from cleveragents.application.services.project_service import ProjectService

    container = get_container()
    plan_service: PlanService = container.plan_service()
    project_service: ProjectService = container.project_service()

    # Get current project
    project = project_service.get_current_project()
    if not project:
        raise CleverAgentsError("No project found. Run 'agents init' first.")

    # Get all plans
    plans = plan_service.list_plans(project=project)
    return plans if plans else []


def cd_command(name: str) -> None:
    """Programmatic interface for switching to a different plan.

    .. deprecated::
        Use ``PlanLifecycleService.get_plan`` instead.

    Args:
        name: Name of the plan to switch to
    """
    warnings.warn(_LEGACY_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
    from cleveragents.application.container import get_container
    from cleveragents.application.services.plan_service import PlanService
    from cleveragents.application.services.project_service import ProjectService

    container = get_container()
    plan_service: PlanService = container.plan_service()
    project_service: ProjectService = container.project_service()

    # Get current project
    project = project_service.get_current_project()
    if not project:
        raise CleverAgentsError("No project found. Run 'agents init' first.")

    # Switch to plan
    plan_service.switch_to_plan(project=project, name=name)


def continue_command(prompt: str | None = None) -> None:
    """Programmatic interface for continuing work on the current plan.

    .. deprecated::
        Use ``PlanLifecycleService`` phase methods instead.

    Args:
        prompt: Optional additional instructions
    """
    warnings.warn(_LEGACY_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
    from cleveragents.application.container import get_container
    from cleveragents.application.services.plan_service import PlanService
    from cleveragents.application.services.project_service import ProjectService

    container = get_container()
    plan_service: PlanService = container.plan_service()
    project_service: ProjectService = container.project_service()

    # Get current project
    project = project_service.get_current_project()
    if not project:
        raise CleverAgentsError("No project found. Run 'agents init' first.")

    # Continue the plan
    if prompt:
        plan_service.continue_plan(project=project, prompt=prompt)
    else:
        # Just verify there's a current plan
        plan = plan_service.get_current_plan(project=project)
        if not plan:
            raise CleverAgentsError("No current plan to continue.")


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


async def _tell_streaming(
    project: Project,
    description: str,
    name: str | None,
    plan_service: Any,
    actor: str | None = None,
) -> None:
    """Handle streaming plan generation with real-time progress display.

    Args:
        project: The project to create the plan in
        description: Instructions for the plan
        name: Optional plan name
        plan_service: PlanService instance
        actor: Optional actor override for streaming generation
    """
    from rich.live import Live
    from rich.text import Text

    # Node display names for better UX
    node_names = {
        "load_context": "Loading context files",
        "analyze_requirements": "Analyzing requirements",
        "generate_plan": "Generating plan",
        "validate": "Validating plan",
    }

    # Track timing for each node
    node_times: dict[str, float] = {}
    current_node: str | None = None
    start_time = time.time()

    # Create status display
    status = Text()
    status.append("Starting plan generation...\n\n", style="bold cyan")

    with Live(status, console=console, refresh_per_second=4) as live:
        try:
            async for event in plan_service.generate_plan_streaming(
                project,
                description,
                name,
                actor=actor,
            ):  # type: ignore[arg-type]
                # Extract node name from event
                for key in event:
                    if key != "__end__" and key in node_names:
                        # Node started
                        if current_node and current_node in node_times:
                            elapsed = time.time() - node_times[current_node]
                            status.append(
                                f"  [green]✓[/green] {node_names[current_node]} "
                                f"[dim]({elapsed:.1f}s)[/dim]\n"
                            )

                        current_node = key
                        node_times[key] = time.time()
                        status.append(f"  [cyan]⏳[/cyan] {node_names[key]}...\n")
                        live.update(status)

                # Check for completion
                if "__end__" in event:
                    if current_node and current_node in node_times:
                        elapsed = time.time() - node_times[current_node]
                        status.append(
                            f"  [green]✓[/green] {node_names[current_node]} "
                            f"[dim]({elapsed:.1f}s)[/dim]\n"
                        )

                    total_time = time.time() - start_time
                    status.append(
                        f"\n[green]✓[/green] Plan generated successfully! "
                        f"[dim]Total: {total_time:.1f}s[/dim]\n"
                    )
                    live.update(status)

        except Exception as e:
            # Get user-friendly error message (without "Exception" class name)
            error_msg = str(e) if str(e) else "An unknown error occurred"

            # If we were in the middle of a node, show it failed
            if current_node and current_node in node_names:
                elapsed = time.time() - node_times.get(current_node, time.time())
                status.append(
                    f"  [red]✗[/red] {node_names[current_node]} failed "
                    f"[dim]({elapsed:.1f}s)[/dim]\n"
                )

            status.append(f"\n[red]Error:[/red] {error_msg}\n")
            live.update(status)
            # Re-raise the exception so callers can handle errors properly
            raise

    # Show completion message (only if no exception occurred)
    console.print(
        Panel(
            "[green]✓[/green] Plan created and built\n\n"
            f"Description: {description[:100]}"
            f"{'...' if len(description) > 100 else ''}\n\n"
            "Next steps:\n"
            "  1. Review changes with 'agents status'\n"
            "  2. Run 'agents apply' to apply changes",
            title="Plan Ready",
            expand=False,
        )
    )


@app.command()
def tell(
    prompt: Annotated[
        str,
        typer.Argument(help="Instructions for what you want the AI to do"),
    ],
    name: Annotated[
        str | None,
        typer.Option("--name", "-n", help="Name for the plan"),
    ] = None,
    actor: Annotated[
        str | None,
        typer.Option(
            "--actor",
            help=(
                "Actor to use for generation (defaults to the configured default actor)"
            ),
        ),
    ] = None,
    stream: Annotated[
        bool,
        typer.Option("--stream", help="Show real-time progress during plan generation"),
    ] = False,
) -> None:
    """Create a new plan from natural language instructions.

    This command takes your instructions and creates a plan for code changes
    that can be built and applied.

    Use --stream to see real-time progress as the AI generates the plan.

    .. deprecated::
        Use ``agents plan use`` for the v3 lifecycle.
    """
    import asyncio

    from cleveragents.application.container import get_container
    from cleveragents.application.services.plan_service import PlanService

    console.print(
        "[yellow]Warning:[/yellow] 'tell' is a legacy command and is deprecated.\n"
        "[yellow]WARNING:[/yellow] The legacy and v3 plan workflows are INCOMPATIBLE "
        "and cannot be mixed.\n"
        "Plans created here cannot be referenced by v3 commands "
        "('agents plan execute', 'agents plan apply').\n"
        "To use the v3 workflow: 'agents plan use <action> <project>'"
    )

    try:
        container = get_container()
        plan_service: PlanService = container.plan_service()
        actor_registry = (
            container.actor_registry() if hasattr(container, "actor_registry") else None
        )
        testing_mode = os.getenv("CLEVERAGENTS_TESTING_USE_MOCK_AI", "").lower() in (
            "true",
            "yes",
            "1",
        )
        with suppress(Exception):
            if actor_registry:
                actor_registry.ensure_built_in_actors()
            if testing_mode:
                container.actor_service().ensure_default_mock_actor()

        # Get current project
        project = _get_current_project()

        if stream:
            # Use streaming mode for real-time progress
            asyncio.run(
                _tell_streaming(
                    project,
                    prompt,
                    name,
                    plan_service,
                    actor,
                )
            )

        else:
            # Use non-streaming mode (original behavior)
            with Progress(
                SpinnerColumn(),
                TextColumn("[progress.description]{task.description}"),
                console=console,
            ) as progress:
                progress.add_task("Creating plan...", total=None)
                plan = plan_service.create_plan(
                    project=project, prompt=prompt, name=name
                )

            console.print(
                Panel(
                    f"[green]✓[/green] Plan created: {plan.name}\n\n"
                    f"Prompt: {plan.prompt[:100] if plan.prompt else ''}"
                    f"{'...' if plan.prompt and len(plan.prompt) > 100 else ''}\n\n"
                    f"Next steps:\n"
                    f"  1. Run 'agents build' to generate changes\n"
                    f"  2. Run 'agents apply' to apply changes",
                    title="Plan Created",
                    expand=False,
                )
            )

    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]Plan 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()
def build(
    verbose: Annotated[
        bool, typer.Option("--verbose", "-v", help="Show detailed output")
    ] = False,
    actor: Annotated[
        str | None,
        typer.Option(
            "--actor",
            help=(
                "Actor to use for building (defaults to the configured default actor)"
            ),
        ),
    ] = None,
) -> None:
    """Build the current plan to generate code changes.

    This command sends the plan and context to the selected actor
    (using that actor's stored provider/model metadata) to generate
    the actual code changes.

    .. deprecated::
        Use ``agents plan execute`` for the v3 lifecycle.
    """
    from cleveragents.application.container import get_container
    from cleveragents.application.services.plan_service import PlanService

    console.print(
        "[yellow]Warning:[/yellow] 'build' is a legacy command and is deprecated.\n"
        "[yellow]WARNING:[/yellow] The legacy and v3 plan workflows are INCOMPATIBLE "
        "and cannot be mixed.\n"
        "Plans created here cannot be referenced by v3 commands.\n"
        "To use the v3 workflow: 'agents plan use <action> <project>'"
    )

    try:
        container = get_container()
        plan_service: PlanService = container.plan_service()
        actor_registry = (
            container.actor_registry() if hasattr(container, "actor_registry") else None
        )
        testing_mode = os.getenv("CLEVERAGENTS_TESTING_USE_MOCK_AI", "").lower() in (
            "true",
            "yes",
            "1",
        )
        with suppress(Exception):
            if actor_registry:
                actor_registry.ensure_built_in_actors()
            if testing_mode:
                container.actor_service().ensure_default_mock_actor()

        # Get current project
        project = _get_current_project()

        # Build the plan
        with Progress(
            SpinnerColumn(),
            TextColumn("[progress.description]{task.description}"),
            console=console,
        ) as progress:
            task = progress.add_task("Building plan with AI...", total=100)

            # Build with progress updates
            changes = plan_service.build_plan(
                project=project,
                progress_callback=lambda p: progress.update(task, completed=p),
                actor=actor,
            )

        if changes:
            console.print(
                Panel(
                    f"[green]✓[/green] Plan built successfully!\n\n"
                    f"Generated {len(changes)} change(s):\n"
                    + "\n".join(
                        f"  • {c.file_path} ({c.operation})" for c in changes[:5]
                    )
                    + (
                        f"\n  ... and {len(changes) - 5} more"
                        if len(changes) > 5
                        else ""
                    )
                    + "\n\nRun 'agents apply' to apply these changes.",
                    title="Build Complete",
                    expand=False,
                )
            )
        else:
            console.print("[yellow]No changes generated.[/yellow]")

    except PlanError as e:
        console.print(f"[red]Build 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


def _lifecycle_apply_with_id(plan_id: str, fmt: str = "rich") -> None:
    """Run the v3 lifecycle apply for a specific plan.

    Transitions the plan through:
      Execute/complete -> Apply/queued -> Apply/processing -> Apply/applied.
    """
    from cleveragents.application.services.plan_lifecycle_service import (
        InvalidPhaseTransitionError,
        PlanNotReadyError,
    )

    try:
        # 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)

        service = _get_lifecycle_service()

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

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

        # Determine current phase and drive through apply
        if (
            pre_plan.phase == PlanPhase.EXECUTE
            and pre_plan.state == ProcessingState.COMPLETE
        ):
            # Transition Execute/complete -> Apply/queued
            service.apply_plan(plan_id)

        current = service.get_plan(plan_id)
        if current.phase == PlanPhase.APPLY and current.state == ProcessingState.QUEUED:
            service.start_apply(plan_id)

        current = service.get_plan(plan_id)
        if (
            current.phase == PlanPhase.APPLY
            and current.state == ProcessingState.PROCESSING
        ):
            service.complete_apply(plan_id)

        plan = service.get_plan(plan_id)

        # Notify A2A facade for protocol bookkeeping
        _notify_facade("plan.apply", {"plan_id": plan_id})

        if fmt != OutputFormat.RICH.value:
            data = _plan_spec_dict(plan)
            console.print(format_output(data, fmt))
        else:
            _print_lifecycle_plan(plan, title="Plan Applied")
            console.print("\n[dim]Plan apply completed successfully.[/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-resolution failures (e.g. missing API key/config) should be
        # reported as a controlled CLI error instead of bubbling to a 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


@app.command()
def new(
    name: Annotated[
        str,
        typer.Argument(help="Name for the new plan"),
    ],
) -> None:
    """Create a new empty plan and switch to it.

    .. deprecated::
        Use ``agents plan use`` for the v3 lifecycle.
    """
    from cleveragents.application.container import get_container
    from cleveragents.application.services.plan_service import PlanService

    console.print(
        "[yellow]Warning:[/yellow] 'new' is a legacy command. "
        "Use 'agents plan use <action> [project]' for the v3 lifecycle."
    )

    try:
        container = get_container()
        plan_service: PlanService = container.plan_service()

        # Create new plan
        # Get the current project first
        from cleveragents.application.services.project_service import ProjectService

        project_service: ProjectService = container.project_service()
        current_project = project_service.get_current_project()

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

        plan = plan_service.new_plan(project=current_project, name=name)

        console.print(f"[green]✓[/green] Created and switched to plan: {plan.name}")
        console.print("Use 'agents tell' to add instructions to this plan.")

    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()
def current() -> None:
    """Show the current active plan.

    .. deprecated::
        Use ``agents plan status`` for the v3 lifecycle.
    """
    from cleveragents.application.container import get_container
    from cleveragents.application.services.plan_service import PlanService

    console.print(
        "[yellow]Warning:[/yellow] 'current' is a legacy command. "
        "Use 'agents plan status [plan_id]' for the v3 lifecycle."
    )

    try:
        container = get_container()
        plan_service: PlanService = container.plan_service()

        # Get current project
        project = _get_current_project()

        # Get current plan
        plan = plan_service.get_current_plan(project=project)

        if not plan:
            console.print("[yellow]No current plan.[/yellow]")
            console.print(
                "Create one with 'agents new <name>' or 'agents tell <prompt>'."
            )
            raise typer.Exit(0)

        # Display plan info
        info_text = f"""
[bold]Current Plan:[/bold] {plan.name}
[bold]Status:[/bold] {plan.status}
[bold]Created:[/bold] {plan.created_at}
[bold]Prompt:[/bold] {plan.prompt[:200] if plan.prompt else "No prompt set"}"
"{" ... " if plan.prompt and len(plan.prompt) > 200 else ""}"
        """

        console.print(Panel(info_text.strip(), title="Current Plan", expand=False))

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


@app.command()
def cd(
    name: Annotated[
        str,
        typer.Argument(help="Name of the plan to switch to"),
    ],
) -> None:
    """Switch to a different plan.

    .. deprecated::
        Use ``agents plan status <plan_id>`` for the v3 lifecycle.
    """
    from cleveragents.application.container import get_container
    from cleveragents.application.services.plan_service import PlanService

    console.print(
        "[yellow]Warning:[/yellow] 'cd' is a legacy command. "
        "Use 'agents plan status <plan_id>' for the v3 lifecycle."
    )

    try:
        container = get_container()
        plan_service: PlanService = container.plan_service()

        # Get current project
        project = _get_current_project()

        # Switch to plan
        plan = plan_service.switch_to_plan(project=project, name=name)

        console.print(f"[green]✓[/green] Switched to plan: {plan.name}")

    except ValidationError as e:
        console.print(f"[red]Plan 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("continue")
def continue_plan(
    prompt: Annotated[
        str | None,
        typer.Argument(help="Additional instructions to continue with"),
    ] = None,
) -> None:
    """Continue working on the current plan.

    .. deprecated::
        Use ``agents plan use`` for the v3 lifecycle.
    """
    from cleveragents.application.container import get_container
    from cleveragents.application.services.plan_service import PlanService

    console.print(
        "[yellow]Warning:[/yellow] 'continue' is a legacy command. "
        "Use 'agents plan use <action> [project]' for the v3 lifecycle."
    )

    try:
        container = get_container()
        plan_service: PlanService = container.plan_service()

        # Get current project
        project = _get_current_project()

        # Continue the plan
        if prompt:
            plan_service.continue_plan(project=project, prompt=prompt)
            console.print("[green]✓[/green] Added instructions to current plan.")
            console.print("Run 'agents build' to generate new changes.")
        else:
            # Just continue with existing plan
            plan = plan_service.get_current_plan(project=project)
            if not plan:
                console.print("[yellow]No current plan to continue.[/yellow]")
                raise typer.Abort()

            console.print(f"[green]✓[/green] Continuing with plan: {plan.name}")
            console.print("Run 'agents build' to continue building.")

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


# =============================================================================
# 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()


def _create_sandbox_for_plan(
    plan_id: str,
    service: PlanLifecycleService,
) -> tuple[str | None, Any]:
    """Create a git worktree sandbox for a plan's linked project.

    Returns:
        A ``(sandbox_root, sandbox_object)`` tuple.  When the plan's
        project has a git-checkout resource, *sandbox_object* is a
        :class:`GitWorktreeSandbox` and *sandbox_root* is the worktree
        path.  Otherwise falls back to a flat directory under
        ``.cleveragents/sandbox/`` and *sandbox_object* is ``None``.
    """
    from cleveragents.application.container import get_container
    from cleveragents.infrastructure.sandbox.git_worktree import (
        GitWorktreeSandbox,
    )

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

    # Try to find a git-checkout resource for the first linked project
    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 in ("git-checkout", "git")
                and resource.location
                and os.path.isdir(os.path.join(resource.location, ".git"))
            ):
                sandbox = GitWorktreeSandbox(
                    resource_id=resource.resource_id,
                    original_path=resource.location,
                )
                ctx = sandbox.create(plan_id)
                return ctx.sandbox_path, sandbox

    # Fallback: flat directory sandbox
    flat_root = os.path.join(os.getcwd(), ".cleveragents", "sandbox")
    os.makedirs(flat_root, exist_ok=True)
    return flat_root, None


def _apply_sandbox_changes(
    plan_id: str,
    service: PlanLifecycleService,
    console: Console,
) -> None:
    """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
    ``.cleveragents/sandbox/``.

    Spec reference: ``specification.md`` §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
    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()
            # Last line is summary; file lines above it
            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.CalledProcessError as merge_err:
                console.print(f"[red]Merge failed:[/red] {merge_err.stderr.strip()}")
                return

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

            # ── Apply Summary panel (spec §13241-13247) ──
            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

            # Find and remove the worktree directory
            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

            # Delete the branch
            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))

            # ── Next Steps panel (spec §13271-13274) ──
            console.print(
                Panel(
                    "- Review git diff\n- Commit changes",
                    title="Next Steps",
                    expand=False,
                )
            )

            # ── Footer (spec §13276) ──
            console.print("[green]✓ OK[/green] Changes applied")
            return  # Done — merged successfully

    # Fallback: flat file copy from .cleveragents/sandbox/
    sandbox_root = os.path.join(os.getcwd(), ".cleveragents", "sandbox")
    project_root = os.getcwd()
    _skip_dirs = frozenset({".cleveragents", ".git", ".hg", ".svn"})

    if not os.path.isdir(sandbox_root):
        return

    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")


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 ``LLMStrategizeActor`` /
    ``LLMExecuteActor`` so that ``plan execute`` invocations drive real
    LLM calls instead of the local-only stub actors.

    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,
        LLMStrategizeActor,
    )
    from cleveragents.application.services.plan_executor import PlanExecutor

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

    strategize_actor = LLMStrategizeActor(
        provider_registry=registry,
        lifecycle_service=lifecycle_service,
    )
    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(),
    )

    return PlanExecutor(
        lifecycle_service=lifecycle_service,
        strategize_actor=strategize_actor,
        execute_actor=execute_actor,
        sandbox_root=sandbox_root,
    )


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

    Args:
        plan: A v3 Plan object from plan.py (not plan_legacy.py)
        title: Panel title
    """
    from cleveragents.domain.models.core.plan import (
        ExecutionEnvPriority,
    )
    from cleveragents.domain.models.core.plan import (
        Plan as LifecyclePlan,
    )

    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)"

    description_preview = plan.description[:200]
    if len(plan.description) > 200:
        description_preview = f"{description_preview}..."

    details = (
        f"[bold]ID:[/bold] {plan.identity.plan_id}\n"
        f"[bold]Name:[/bold] {plan.namespaced_name}\n"
        f"[bold]Action:[/bold] {plan.action_name}\n"
        f"[bold]Phase:[/bold] {plan.phase.value}\n"
        f"[bold]Processing State:[/bold] {state_display}\n"
        f"[bold]Projects:[/bold] {projects}\n"
        f"[bold]Description:[/bold]\n  {description_preview}\n"
        f"[bold]Strategy Actor:[/bold] {plan.strategy_actor or '(not set)'}\n"
        f"[bold]Execution Actor:[/bold] {plan.execution_actor or '(not set)'}\n"
    )

    # Definition-of-Done evaluation summary
    if plan.definition_of_done:
        dod_preview = plan.definition_of_done[:200]
        if len(plan.definition_of_done) > 200:
            dod_preview += "..."
        details += f"[bold]Definition of Done:[/bold]\n  {dod_preview}\n"
    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"
        details += (
            f"[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 ''})\n"
        )

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

    # Optional actors
    if plan.estimation_actor:
        details += f"[bold]Estimation Actor:[/bold] {plan.estimation_actor}\n"
    if plan.invariant_actor:
        details += f"[bold]Invariant Actor:[/bold] {plan.invariant_actor}\n"

    # Estimation results
    # TODO: Migrate to EstimationResult.as_display_dict() to reduce duplication
    if plan.estimation_result is not None:
        est = plan.estimation_result
        details += "[bold]Estimation:[/bold]\n"
        if est.summary:
            details += f"  Summary: {est.summary}\n"
        if est.estimated_cost_usd is not None:
            details += f"  Est. Cost: ${est.estimated_cost_usd:.4f}\n"
        if est.estimated_tokens is not None:
            details += f"  Est. Tokens: {est.estimated_tokens}\n"
        if est.estimated_steps is not None:
            details += f"  Est. Steps: {est.estimated_steps}\n"
        if est.estimated_child_plans is not None:
            details += f"  Est. Child Plans: {est.estimated_child_plans}\n"
        if est.estimated_time_seconds is not None:
            details += f"  Est. Time: {est.estimated_time_seconds:.1f}s\n"
        if est.risk_level is not None:
            details += f"  Risk Level: {est.risk_level}\n"
        if est.risk_factors:
            details += "  Risk Factors:\n"
            for factor in est.risk_factors:
                details += f"    - {factor}\n"

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

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

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

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

    # Multi-project changeset summaries (#199)
    if (
        plan.multi_project_metadata is not None
        and plan.multi_project_metadata.project_scopes
    ):
        details += "[bold]Multi-Project Scopes:[/bold]\n"
        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]"
            details += f"  {label}\n"
            if scope.changeset_summary is not None:
                cs = scope.changeset_summary
                details += (
                    f"    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"
                    details += f"  Validation: [{v_color}]{v_label}[/{v_color}]"
                details += "\n"

    details += f"[bold]Terminal:[/bold] {'yes' if plan.is_terminal else 'no'}\n"

    # Timestamps
    details += f"[bold]Created:[/bold] {plan.timestamps.created_at}\n"
    details += f"[bold]Updated:[/bold] {plan.timestamps.updated_at}"
    if plan.timestamps.strategize_started_at:
        details += (
            f"\n[bold]Strategize Started:[/bold] "
            f"{plan.timestamps.strategize_started_at}"
        )
    if plan.timestamps.strategize_completed_at:
        details += (
            f"\n[bold]Strategize Completed:[/bold] "
            f"{plan.timestamps.strategize_completed_at}"
        )
    if plan.timestamps.execute_started_at:
        details += (
            f"\n[bold]Execute Started:[/bold] {plan.timestamps.execute_started_at}"
        )
    if plan.timestamps.execute_completed_at:
        details += (
            f"\n[bold]Execute Completed:[/bold] {plan.timestamps.execute_completed_at}"
        )
    if plan.timestamps.applied_at:
        details += f"\n[bold]Applied At:[/bold] {plan.timestamps.applied_at}"

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

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


@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,
    )

    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 sandbox for this plan (git worktree or flat fallback)
        # and build the executor with the sandbox path.
        sandbox_root, sandbox_obj = _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()

        # Run the execute phase inline so the plan progresses through
        # execute/queued → execute/processing → execute/complete in a
        # single CLI invocation.  Without this, `plan execute` would
        # leave the plan in execute/queued and `apply` would
        # fail because it requires execute/complete.
        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)

            # Stage and commit LLM-generated files in the worktree
            # branch WITHOUT merging — the merge happens at apply time.
            if sandbox_obj is not None and sandbox_obj.context is not None:
                _commit_worktree_changes(
                    sandbox_obj.context.sandbox_path,
                    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"\n[dim]Plan execution completed ({phase_label}). "
                    "Run 'agents plan apply <id>' when ready.[/dim]"
                )
            else:
                console.print(
                    f"\n[dim]Plan is now in {phase_label} state. "
                    "Run 'agents plan execute <id>' to continue.[/dim]"
                )

    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


@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_sandbox_changes(plan_id, service, console)

        # 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))
            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))

    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)

        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


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

    lifecycle = _get_lifecycle_service()
    return PlanApplyService(lifecycle_service=lifecycle)


@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:
        if correction:
            # Show correction-specific diff (stub: shows info panel)
            console.print(
                Panel(
                    f"[bold]Correction Attempt:[/bold] {correction}\n"
                    f"[bold]Plan:[/bold] {plan_id}\n\n"
                    "[dim]Correction diff will be available after M4.2.[/dim]",
                    title="Correction Diff",
                    expand=False,
                )
            )
            return

        service = _get_apply_service()
        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 = 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},
        )

        envelope: dict[str, object] = {
            "command": "plan prompt",
            "status": "ok",
            "exit_code": 0,
            "data": prompt_data,
            "timing": {"duration_ms": elapsed_ms},
            "messages": ["Guidance queued"],
        }

        if fmt != OutputFormat.RICH.value:
            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_id": result.correction_id,
                "status": result.status.value,
                "mode": correction_mode.value,
                "new_decisions": result.new_decisions,
                "reverted_decisions": result.reverted_decisions,
            }
            console.print(format_output(data, fmt))
        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 --yes 01ARZ3NDEK... 01BRZ4NFEK...
        agents plan rollback --to-checkpoint 01BRZ4NFEK... 01ARZ3NDEK...
    """
    from cleveragents.application.container import get_container
    from cleveragents.core.exceptions import (
        BusinessRuleViolation,
    )
    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:
        console.print(
            "[red]Error:[/red] checkpoint ID required. "
            "Provide as positional argument or via --to-checkpoint."
        )
        raise typer.Abort()

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

    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()
        result = svc.selective_rollback(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_considered": 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 or Plan 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 or the root decision of a plan."""
    from cleveragents.application.container import get_container
    from cleveragents.application.services.decision_service import (
        DecisionNotFoundError,
    )

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

    # First, try treating the identifier as a decision_id (backward compat).
    decision = None
    with suppress(DecisionNotFoundError):
        decision = svc.get_decision(identifier)

    # If not found as a decision, try as a plan_id.
    if decision is None:
        decisions = svc.list_decisions(identifier)
        if decisions:
            # Find root decision (parent_decision_id is None)
            root_decisions = [d for d in decisions if d.parent_decision_id is None]
            decision = root_decisions[0] if root_decisions else decisions[0]

    if decision is None:
        console.print(
            f"[red]Error:[/red] '{identifier}' not found as a decision or plan."
        )
        raise typer.Exit(1)

    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


@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

    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):
        console.print(format_output(tree_data, fmt))
    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,
                    )
                )
