"""Click CLI entry point for Loom."""

from __future__ import annotations

import asyncio
import json
import socket
import subprocess
import sys
import time
import uuid
from pathlib import Path

import click
import yaml

from loom import __version__


def generate_agent_prompt(
    task_id: str,
    title: str,
    branch: str | None = None,
    instructions: str = "",
) -> str:
    """Generate a subagent prompt with self-reporting instructions baked in.

    Use this when launching Task agents that don't have MCP access.
    The generated prompt includes CLI commands for done/fail/heartbeat.
    """
    branch_flag = f" --branch-name {branch}" if branch else ""
    lines = [
        f"# Task: {title}",
        f"Task ID: {task_id}",
        "",
    ]
    if instructions:
        lines.extend([instructions, ""])
    lines.extend([
        "## Self-Reporting (REQUIRED)",
        "",
        "You do NOT have MCP access. Report status via CLI:",
        f"- When COMPLETE: `uv run python -m loom done {task_id} --output '{{\"summary\": \"...\"}}'{branch_flag}`",
        f"- If FAIL: `uv run python -m loom fail {task_id} --reason '...'`",
        f"- Every 10 min: `uv run python -m loom heartbeat {task_id}`",
    ])
    return "\n".join(lines)


def _is_port_available(port: int) -> bool:
    """Check if a TCP port is available for binding on localhost."""
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.bind(("127.0.0.1", port))
            return True
    except OSError:
        return False


def _find_available_port(default: int, max_attempts: int = 100) -> int:
    """Find an available port starting from default, incrementing on conflict."""
    for offset in range(max_attempts):
        port = default + offset
        if _is_port_available(port):
            return port
    raise RuntimeError(f"No available port found in range {default}-{default + max_attempts - 1}")


@click.group()
@click.version_option(version=__version__)
@click.option("-v", "--verbose", is_flag=True, help="Enable DEBUG logging")
def cli(verbose: bool):
    """Loom — Multi-agent project orchestration."""
    from loom.logging import setup_logging
    setup_logging("DEBUG" if verbose else "INFO")


@cli.command()
@click.option("--name", default=None, help="Project name (defaults to directory name)")
def init(name: str | None):
    """Initialize a Loom project in the current directory."""
    project_dir = Path.cwd()
    loom_dir = project_dir / ".loom"

    if loom_dir.exists():
        click.echo("Loom already initialized in this directory.")
        return

    project_name = name or project_dir.name
    project_id = str(uuid.uuid4())

    # Create directory structure
    loom_dir.mkdir()
    (loom_dir / "skills").mkdir()
    (loom_dir / "workflows").mkdir()
    (loom_dir / "logs").mkdir()

    # Find available ports (avoids conflicts with other Loom projects)
    pg_port = _find_available_port(5432)
    redis_port = _find_available_port(6379)
    mcp_port = _find_available_port(8765)

    if pg_port != 5432:
        click.echo(f"Port 5432 in use, using {pg_port} for Postgres")
    if redis_port != 6379:
        click.echo(f"Port 6379 in use, using {redis_port} for Redis")
    if mcp_port != 8765:
        click.echo(f"Port 8765 in use, using {mcp_port} for MCP server")

    # Write config.yaml
    config = {
        "loom": {
            "project_name": project_name,
            "project_id": project_id,
        },
        "database": {
            "url": f"postgresql://loom:loom_local@localhost:{pg_port}/loom",
        },
        "redis": {
            "url": f"redis://localhost:{redis_port}",
        },
        "mcp": {
            "port": mcp_port,
            "host": "0.0.0.0",
        },
        "logging": {
            "level": "INFO",
        },
    }
    with open(loom_dir / "config.yaml", "w") as f:
        yaml.dump(config, f, default_flow_style=False, sort_keys=False)

    # Write docker-compose.loom.yml
    compose = {
        "services": {
            "postgres": {
                "image": "postgres:16-alpine",
                "environment": {
                    "POSTGRES_DB": "loom",
                    "POSTGRES_USER": "loom",
                    "POSTGRES_PASSWORD": "loom_local",
                },
                "ports": [f"{pg_port}:5432"],
                "volumes": ["postgres-data:/var/lib/postgresql/data"],
                "healthcheck": {
                    "test": ["CMD-SHELL", "pg_isready -U loom"],
                    "interval": "2s",
                    "timeout": "5s",
                    "retries": 10,
                },
            },
            "redis": {
                "image": "redis:7-alpine",
                "ports": [f"{redis_port}:6379"],
                "volumes": ["redis-data:/data"],
                "command": "redis-server --appendonly yes",
                "healthcheck": {
                    "test": ["CMD", "redis-cli", "ping"],
                    "interval": "2s",
                    "timeout": "5s",
                    "retries": 10,
                },
            },
            "loom-mcp": {
                "image": "loom-mcp:latest",
                "build": {"context": ".", "dockerfile": "Dockerfile"},
                "ports": [f"{mcp_port}:8765"],
                "environment": {
                    "LOOM_DATABASE_URL": "postgresql://loom:loom_local@postgres:5432/loom",
                    "LOOM_REDIS_URL": "redis://redis:6379",
                    "LOOM_PROJECT_ID": project_id,
                },
                "depends_on": {
                    "postgres": {"condition": "service_healthy"},
                    "redis": {"condition": "service_healthy"},
                },
                "profiles": ["mcp"],
            },
        },
        "volumes": {
            "postgres-data": None,
            "redis-data": None,
        },
    }
    with open(project_dir / "docker-compose.loom.yml", "w") as f:
        yaml.dump(compose, f, default_flow_style=False, sort_keys=False)

    # Write .mcp.json
    _write_mcp_json(project_dir)

    # Write AGENTS.md
    agents_md = f"""# Loom Agents — {project_name}

## Project ID
`{project_id}`

## Getting Started

1. `loom up` — Start Postgres + Redis
2. Open Claude Code in this directory — Loom tools auto-register via `.mcp.json`
3. Use `loom_ready` to see available tasks
4. Use `loom_claim` to claim a task
5. Use `loom_done` to mark it complete

## Available MCP Tools

| Tool | Description |
|------|-------------|
| `loom_ready` | List tasks with no blockers, sorted by priority |
| `loom_claim` | Atomically claim a task |
| `loom_done` | Mark a task complete with output, trigger dep resolution |
| `loom_fail` | Mark a task failed, trigger retry or dead letter |
| `loom_escalate` | Flag a task as blocked with escalation |
| `loom_create` | Create a new task |
| `loom_status` | Task detail or project overview |
| `loom_message` | Send a message to another agent |
| `loom_decompose` | Decompose a goal or epic into a task graph using AI |
| `loom_graph` | View the dependency graph (JSON, Mermaid, or summary) |
| `loom_update` | Update task fields (title, context, priority, deps, status) |
| `loom_heartbeat` | Extend claim TTL with optional progress tracking |
| `loom_reset` | Reset a stuck or failed task back to pending |
| `loom_batch_done` | Mark multiple tasks done or dead-lettered at once |
| `loom_verify_paths` | Pre-flight check that file paths in tasks exist on disk |
| `loom_round_summary` | Post-round summary: run tests, snapshot project status |
| `loom_orchestrate` | Run orchestrator tick or check status |
| `loom_workflow` | Run, resume, or check workflow status |
| `loom_dead_letter` | List permanently failed tasks |
| `loom_projects` | List all projects with task count summaries |
| `loom_create_project` | Create a new project |
| `loom_switch_project` | Switch the active project context |
| `loom_archive_project` | Archive a project (soft-delete) |
| `loom_idea` | Capture an idea for the backlog (excluded from orchestration) |
| `loom_recover` | Classify orphaned tasks and optionally recover them |

## CLI Commands

| Command | Description |
|---------|-------------|
| `loom init` | Scaffold a new Loom project |
| `loom up` | Start Postgres + Redis, run migrations |
| `loom down` | Stop containers |
| `loom status [task_id]` | Project overview or task detail |
| `loom decompose <goal>` | Decompose a goal into tasks |
| `loom graph` | Show the dependency graph |
| `loom done TASK_ID` | Mark a task complete (with --output, --branch-name) |
| `loom fail TASK_ID` | Mark a task failed (with --reason) |
| `loom heartbeat TASK_ID` | Extend claim TTL (with --progress, --percent) |
| `loom idea "title"` | Capture an idea for the backlog |
| `loom ideas` | List ideas or drop one (--drop ID) |
| `loom scan` | Process completion markers from .loom/completions/ |
| `loom recover` | Classify and recover orphaned tasks |
| `loom workflow list` | List available workflows |
| `loom workflow run <name>` | Run a workflow |
| `loom orchestrate --daemon` | Start the orchestrator loop |
| `loom dead-letter` | List or retry dead letter tasks |
| `loom digest` | Write daily digest to Obsidian vault |
| `loom deploy` | Deploy to GCP Cloud Run |

## Subagent Self-Reporting (IMPORTANT)

Subagents spawned via the Task tool do NOT have MCP access. They MUST self-report
via CLI commands. Include these instructions in every subagent prompt:

```
When COMPLETE: uv run python -m loom done TASK_ID --output '{{"summary": "..."}}'  --branch-name BRANCH
If FAIL:       uv run python -m loom fail TASK_ID --reason '...'
Every 10 min:  uv run python -m loom heartbeat TASK_ID
```

The CLI commands also write backup markers to `.loom/completions/` automatically.
If the orchestrator dies, run `loom recover` to reconstruct state from markers.
"""
    with open(project_dir / "AGENTS.md", "w") as f:
        f.write(agents_md)

    # Append Loom instructions to CLAUDE.md
    loom_claude_snippet = """
## Loom Task Management (IMPORTANT)

When Loom MCP tools are available, **always use Loom instead of EnterPlanMode** for planning and decomposing work. Do NOT fall back to the built-in plan mode.

### When the user asks to plan, decompose, or break down work:
- Call `loom_decompose(goal="...", confirm=True)` to generate a task graph
- To break down an existing epic: `loom_decompose(epic_id="loom-xxx")`
- Review the proposed graph with the user, then call with `confirm=False` to write it

### Task workflow:
- `loom_ready` → see available tasks
- `loom_claim` → claim a task before starting work
- `loom_heartbeat` → extend claim TTL during long tasks
- `loom_done` → mark complete with output
- `loom_fail` → mark failed with reason
- `loom_status` → project overview or task detail

### Key rules:
- **Never use EnterPlanMode when Loom is available.** Loom decompose IS the planning step — it produces a persistent, trackable task graph instead of a throwaway plan.
- Always create/switch to a project first (`loom_create_project` + `loom_switch_project`) before decomposing.
- When decomposing creates epics, use `loom_decompose(epic_id="loom-xxx")` to break them into subtasks — don't manually create tasks.

### Recovery after context compaction:
If your context was compacted and you lost track of in-progress work:
1. Call `loom_recover()` → review the dry-run classification
2. Call `loom_recover(execute=True)` → complete marker tasks, reset stale/expired
3. Call `loom_status()` → verify project state is consistent
"""
    claude_md = project_dir / "CLAUDE.md"
    if claude_md.exists():
        existing = claude_md.read_text()
        if "Loom Task Management" not in existing:
            with open(claude_md, "a") as f:
                f.write(loom_claude_snippet)
            click.echo("Appended Loom instructions to existing CLAUDE.md")
        else:
            click.echo("CLAUDE.md already has Loom instructions — skipped")
    else:
        with open(claude_md, "w") as f:
            f.write(f"# {project_name}\n" + loom_claude_snippet)
        click.echo("Created CLAUDE.md with Loom instructions")

    click.echo(f"Initialized Loom project: {project_name}")
    click.echo(f"Project ID: {project_id}")
    click.echo("Run 'loom up' to start Postgres + Redis.")


def _is_source_install(project_dir: Path) -> bool:
    """Detect if loom is being run from a source checkout (editable install)."""
    # If the loom package lives inside the project directory, it's a source install
    import loom as _loom_pkg
    pkg_path = Path(_loom_pkg.__file__).resolve().parent
    return pkg_path.is_relative_to(project_dir.resolve())


def _write_mcp_json(project_dir: Path):
    """Write or update .mcp.json for Claude Code auto-registration."""
    mcp_json_path = project_dir / ".mcp.json"
    existing = {}
    if mcp_json_path.exists():
        with open(mcp_json_path) as f:
            existing = json.load(f)

    existing.setdefault("mcpServers", {})

    if _is_source_install(project_dir):
        args = ["run", "python", "-m", "loom.mcp"]
    else:
        args = ["run", "--with", "loom-agents", "python", "-m", "loom.mcp"]

    existing["mcpServers"]["loom"] = {
        "command": "uv",
        "args": args,
        "env": {"LOOM_PROJECT_DIR": str(project_dir)},
    }

    with open(mcp_json_path, "w") as f:
        json.dump(existing, f, indent=2)
        f.write("\n")


def _protect_venv(project_dir: Path):
    """Set xattr on .venv so Google Drive skips it.

    Google Drive for Desktop cannot sync symlinks and will silently
    delete .venv/bin/ and .venv/lib/, breaking the virtualenv.
    Setting com.apple.fileprovider.ignore tells it to leave the
    directory alone.  Safe no-op on Linux or when Drive isn't running.
    """
    venv_dir = project_dir / ".venv"
    if not venv_dir.is_dir() or sys.platform != "darwin":
        return
    try:
        subprocess.run(
            ["xattr", "-w", "com.apple.fileprovider.ignore", "1", str(venv_dir)],
            capture_output=True,
        )
    except FileNotFoundError:
        pass


def _parse_host_port(port_mapping: str) -> int | None:
    """Extract the host port from a docker-compose port mapping like '5433:5432'."""
    parts = port_mapping.split(":")
    if len(parts) == 2:
        try:
            return int(parts[0])
        except ValueError:
            return None
    return None


@cli.command()
def up():
    """Start Postgres + Redis, run migrations, write .mcp.json."""
    project_dir = Path.cwd()
    compose_file = project_dir / "docker-compose.loom.yml"

    if not compose_file.exists():
        click.echo("No docker-compose.loom.yml found. Run 'loom init' first.")
        sys.exit(1)

    # Pre-flight: check that mapped host ports are available
    with open(compose_file) as f:
        compose_data = yaml.safe_load(f) or {}
    conflicts = []
    for svc_name in ("postgres", "redis"):
        svc = compose_data.get("services", {}).get(svc_name, {})
        for mapping in svc.get("ports", []):
            host_port = _parse_host_port(str(mapping))
            if host_port and not _is_port_available(host_port):
                conflicts.append((svc_name, host_port))
    if conflicts:
        click.echo("Port conflict detected:", err=True)
        for svc_name, port in conflicts:
            click.echo(f"  {svc_name}: port {port} is already in use", err=True)
        click.echo(
            "\nAnother Loom project (or other service) is using these ports.\n"
            "Options:\n"
            "  1. Run 'loom down' in the other project first\n"
            "  2. Re-run 'loom init' here to pick new available ports\n"
            "  3. Edit docker-compose.loom.yml and .loom/config.yaml manually",
            err=True,
        )
        sys.exit(1)

    # Start containers
    click.echo("Starting Postgres + Redis...")
    subprocess.run(
        ["docker", "compose", "-f", str(compose_file), "up", "-d", "postgres", "redis"],
        check=True,
    )

    # Wait for healthchecks
    click.echo("Waiting for services to be healthy...")
    subprocess.run(
        ["docker", "compose", "-f", str(compose_file), "up", "--wait", "postgres", "redis"],
        check=True,
        capture_output=True,
    )

    # Run migrations
    click.echo("Running migrations...")
    from loom.config import load_config
    config = load_config(project_dir)
    asyncio.run(_run_migrations_async(config.database.url))

    # Create project in DB if needed
    loom_config_path = project_dir / ".loom" / "config.yaml"
    if loom_config_path.exists():
        with open(loom_config_path) as f:
            loom_cfg = yaml.safe_load(f) or {}
        pid = loom_cfg.get("loom", {}).get("project_id", "")
        pname = loom_cfg.get("loom", {}).get("project_name", "default")
        if pid:
            asyncio.run(_ensure_project(config.database.url, pid, pname))

    # Write .mcp.json
    _write_mcp_json(project_dir)

    # Prevent Google Drive from evicting .venv symlinks/binaries
    _protect_venv(project_dir)

    click.echo("Loom is running. Start Claude Code in this directory to use Loom tools.")


async def _run_migrations_async(dsn: str):
    import asyncpg
    from loom.db.migrations import run_migrations
    pool = await asyncpg.create_pool(dsn, min_size=1, max_size=2)
    applied = await run_migrations(pool)
    if applied:
        click.echo(f"Applied migrations: {', '.join(applied)}")
    else:
        click.echo("Migrations up to date.")
    await pool.close()


async def _ensure_project(dsn: str, project_id: str, project_name: str):
    import asyncpg
    pool = await asyncpg.create_pool(dsn, min_size=1, max_size=2)
    async with pool.acquire() as conn:
        exists = await conn.fetchval(
            "SELECT 1 FROM projects WHERE id = $1::uuid", project_id
        )
        if not exists:
            await conn.execute(
                "INSERT INTO projects (id, name) VALUES ($1::uuid, $2)",
                project_id, project_name,
            )
            click.echo(f"Created project: {project_name}")
    await pool.close()


@cli.command()
def down():
    """Stop Postgres + Redis containers."""
    project_dir = Path.cwd()
    compose_file = project_dir / "docker-compose.loom.yml"

    if not compose_file.exists():
        click.echo("No docker-compose.loom.yml found.")
        sys.exit(1)

    subprocess.run(
        ["docker", "compose", "-f", str(compose_file), "down"],
        check=True,
    )
    click.echo("Loom services stopped.")


@cli.command("status")
@click.argument("task_id", required=False)
@click.option("--filter", "status_filter", default=None,
              type=click.Choice(["pending", "claimed", "done", "failed", "blocked", "epic", "idea"]),
              help="List tasks matching a status")
def status_cmd(task_id: str | None, status_filter: str | None):
    """Show project overview, task detail, or filtered task list."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)
    asyncio.run(_show_status(config.database.url, config.project_id, task_id,
                             status_filter=status_filter))


def _status_badge(status: str) -> str:
    """Return a rich-markup colored badge for a task status."""
    colors = {
        "done": "green",
        "claimed": "yellow",
        "blocked": "red",
        "pending": "blue",
        "failed": "bold red",
        "epic": "magenta",
        "idea": "cyan",
    }
    color = colors.get(status, "white")
    return f"[{color}]{status}[/{color}]"


def _format_elapsed(seconds: float) -> str:
    """Format elapsed seconds as a human-readable string (e.g. '2h 15m')."""
    total_minutes = int(seconds // 60)
    hours = total_minutes // 60
    minutes = total_minutes % 60
    if hours > 0:
        return f"{hours}h {minutes}m"
    return f"{minutes}m"


async def _show_status(dsn: str, project_id: str, task_id: str | None,
                       status_filter: str | None = None):
    import asyncpg
    from datetime import datetime, timezone
    from rich.console import Console, Group
    from rich.panel import Panel
    from rich.progress_bar import ProgressBar
    from rich.table import Table
    from rich.text import Text

    console = Console()
    pool = await asyncpg.create_pool(dsn, min_size=1, max_size=2)

    if status_filter:
        from loom.graph.store import get_tasks_by_status
        tasks = await get_tasks_by_status(pool, project_id, status_filter)
        await pool.close()
        if not tasks:
            console.print(f"[dim]No {status_filter} tasks.[/dim]")
            return
        table = Table(title=f"{status_filter.capitalize()} Tasks ({len(tasks)})")
        table.add_column("ID", style="cyan")
        table.add_column("Title")
        table.add_column("Priority")
        table.add_column("Assignee")
        table.add_column("Deps", justify="right")
        for t in tasks:
            table.add_row(
                t.id, t.title, t.priority,
                t.assignee or "[dim]-[/dim]",
                str(len(t.depends_on)) if t.depends_on else "[dim]0[/dim]",
            )
        console.print(table)
        return

    if task_id:
        from loom.graph.store import get_task
        task = await get_task(pool, task_id)

        table = Table(show_header=False, box=None, padding=(0, 2))
        table.add_column("Field", style="bold cyan")
        table.add_column("Value")
        table.add_row("ID", task.id)
        table.add_row("Title", task.title)
        table.add_row("Status", _status_badge(task.status))
        table.add_row("Priority", task.priority)
        table.add_row("Assignee", task.assignee or "[dim](none)[/dim]")
        table.add_row("Depends", ", ".join(task.depends_on) if task.depends_on else "[dim](none)[/dim]")
        if task.done_when:
            table.add_row("Done when", task.done_when)
        if task.context:
            table.add_row("Context", json.dumps(task.context, indent=2))
        if task.output:
            table.add_row("Output", json.dumps(task.output, indent=2))

        console.print(Panel(table, title=f"[bold]Task {task.id}[/bold]", border_style="cyan"))
    else:
        from loom.graph.project import get_project, get_project_status
        try:
            ps = await get_project_status(pool, project_id)
        except Exception:
            console.print("[red]Could not load project status. Is Postgres running?[/red]")
            await pool.close()
            return

        # Fetch project created_at for elapsed time
        elapsed_str = ""
        try:
            project = await get_project(pool, project_id)
            created_at = project.get("created_at")
            if created_at is not None:
                now = datetime.now(timezone.utc)
                if created_at.tzinfo is None:
                    created_at = created_at.replace(tzinfo=timezone.utc)
                elapsed_seconds = (now - created_at).total_seconds()
                elapsed_str = _format_elapsed(elapsed_seconds)
        except Exception:
            pass

        # Progress bar
        actionable = ps.total - ps.epic - ps.idea  # epics and ideas are not actionable
        completed = ps.done
        pct = (completed / actionable * 100) if actionable > 0 else 0

        # Summary line: "12/20 tasks complete (60%) | Elapsed: 2h 15m"
        summary = Text()
        summary.append(f"  {completed}/{actionable} tasks complete ", style="bold")
        summary.append(f"({pct:.0f}%)", style="dim")
        if elapsed_str:
            summary.append(f" | Elapsed: {elapsed_str}", style="dim")

        bar = ProgressBar(total=max(actionable, 1), completed=completed, width=40)

        # Status counts table
        counts = Table(show_header=True, box=None, padding=(0, 2))
        counts.add_column("Pending", style="blue", justify="center")
        counts.add_column("Claimed", style="yellow", justify="center")
        counts.add_column("Done", style="green", justify="center")
        counts.add_column("Blocked", style="red", justify="center")
        counts.add_column("Failed", style="bold red", justify="center")
        counts.add_column("Epic", style="magenta", justify="center")
        counts.add_column("Idea", style="cyan", justify="center")
        counts.add_column("Ready", style="bold blue", justify="center")
        counts.add_row(
            str(ps.pending), str(ps.claimed), str(ps.done),
            str(ps.blocked), str(ps.failed), str(ps.epic),
            str(ps.idea), str(ps.ready_count),
        )

        # Compose the panel content
        parts: list = [summary, bar, "", counts]

        if ps.blocked_tasks:
            parts.append("")
            blocked_list = Text("Blocked: ", style="bold red")
            blocked_list.append(", ".join(ps.blocked_tasks), style="red")
            parts.append(blocked_list)

        if ps.open_escalations:
            esc = Text(f"Open escalations: {ps.open_escalations}", style="bold yellow")
            parts.append(esc)

        console.print(Panel(
            Group(*parts),
            title=f"[bold]{ps.project_name}[/bold]",
            subtitle=f"[dim]{ps.project_id}[/dim]",
            border_style="cyan",
        ))

    await pool.close()


@cli.command("reset")
@click.argument("task_id")
@click.option("--clear-output", is_flag=True, help="Also clear task output")
def reset_cmd(task_id: str, clear_output: bool):
    """Reset a stuck or failed task back to pending."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)
    asyncio.run(_reset_task(config, task_id, clear_output))


async def _reset_task(config, task_id, clear_output):
    import asyncpg
    import redis.asyncio as aioredis
    from loom.graph import cache, store
    from loom.bus.events import EventType
    from loom.bus.publisher import publish_event

    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=2)
    redis_conn = aioredis.from_url(config.redis.url, decode_responses=True)
    try:
        task = await store.reset_task(pool, task_id, clear_output=clear_output)
        await cache.sync_task(redis_conn, task)
        await cache.add_to_ready_queue(redis_conn, task)
        await publish_event(redis_conn, config.project_id, EventType.TASK_RESET, task_id)
        click.echo(f"Reset task {task_id} → pending")
    except Exception as e:
        click.echo(f"Error: {e}")
        sys.exit(1)
    finally:
        await redis_conn.aclose()
        await pool.close()


# ---------------------------------------------------------------------------
# Idea backlog commands
# ---------------------------------------------------------------------------


@cli.command("idea")
@click.argument("title")
@click.option("--context", "context_json", default=None, help="JSON string of context dict")
def idea_cmd(title: str, context_json: str | None):
    """Capture an idea for the backlog."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)

    if not config.project_id:
        click.echo("No project_id found. Run 'loom init' and 'loom up' first.")
        sys.exit(1)

    context = {}
    if context_json:
        try:
            context = json.loads(context_json)
        except json.JSONDecodeError:
            click.echo("Error: --context must be valid JSON")
            sys.exit(1)

    asyncio.run(_create_idea(config, title, context))


async def _create_idea(config, title, context):
    import asyncpg
    import redis.asyncio as aioredis
    from loom.graph import cache, store
    from loom.bus.events import EventType
    from loom.bus.publisher import publish_event

    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=2)
    redis_conn = aioredis.from_url(config.redis.url, decode_responses=True)
    try:
        task = await store.create_idea(pool, config.project_id, title, context)
        await cache.sync_task(redis_conn, task)
        await publish_event(redis_conn, config.project_id, EventType.IDEA_CREATED, task.id)
        click.echo(f"{task.id}")
    except Exception as e:
        click.echo(f"Error: {e}")
        sys.exit(1)
    finally:
        await redis_conn.aclose()
        await pool.close()


@cli.command("ideas")
@click.option("--drop", "drop_id", default=None, help="Drop an idea by ID")
def ideas_cmd(drop_id: str | None):
    """List all ideas, or drop one."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)

    if not config.project_id:
        click.echo("No project_id found. Run 'loom init' and 'loom up' first.")
        sys.exit(1)

    asyncio.run(_ideas(config, drop_id))


async def _ideas(config, drop_id):
    import asyncpg
    import redis.asyncio as aioredis
    from loom.graph import store

    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=2)
    redis_conn = aioredis.from_url(config.redis.url, decode_responses=True)
    try:
        if drop_id:
            await store.drop_idea(pool, drop_id)
            click.echo(f"Dropped idea {drop_id}")
            return

        ideas = await store.list_ideas(pool, config.project_id)
        if not ideas:
            click.echo("No ideas in backlog.")
            return

        from rich.console import Console
        from rich.table import Table
        console = Console()
        table = Table(title=f"Ideas ({len(ideas)})")
        table.add_column("ID", style="cyan")
        table.add_column("Title")
        table.add_column("Created", style="dim")
        for idea in ideas:
            created = idea.created_at.strftime("%Y-%m-%d %H:%M") if idea.created_at else ""
            table.add_row(idea.id, idea.title, created)
        console.print(table)
    except Exception as e:
        click.echo(f"Error: {e}")
        sys.exit(1)
    finally:
        await redis_conn.aclose()
        await pool.close()


# ---------------------------------------------------------------------------
# Task lifecycle commands: create, claim, done, fail
# ---------------------------------------------------------------------------


@cli.command("create")
@click.argument("title")
@click.option("--priority", type=click.Choice(["p0", "p1", "p2"]), default="p1", help="Task priority")
@click.option("--depends-on", multiple=True, help="Task IDs this depends on (repeatable)")
@click.option("--parent", default=None, help="Parent epic task ID")
@click.option("--done-when", default=None, help="Acceptance criteria")
@click.option("--context", "context_json", default=None, help="JSON string of context dict")
def create_cmd(title: str, priority: str, depends_on: tuple[str, ...], parent: str | None,
               done_when: str | None, context_json: str | None):
    """Create a new task."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)

    if not config.project_id:
        click.echo("No project_id found. Run 'loom init' and 'loom up' first.")
        sys.exit(1)

    context = {}
    if context_json:
        try:
            context = json.loads(context_json)
        except json.JSONDecodeError:
            click.echo("Error: --context must be valid JSON")
            sys.exit(1)

    asyncio.run(_create_task(config, title, priority, list(depends_on), parent, done_when, context))


async def _create_task(config, title, priority, depends_on, parent_id, done_when, context):
    import asyncpg
    import redis.asyncio as aioredis
    from loom.graph import cache, deps, store
    from loom.graph.task import Task, TaskStatus
    from loom.bus.events import EventType
    from loom.bus.publisher import publish_event
    from loom.ids import task_id as gen_task_id

    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=2)
    redis_conn = aioredis.from_url(config.redis.url, decode_responses=True)
    try:
        dep_list = depends_on or []
        new_id = gen_task_id()
        if dep_list:
            await deps.detect_cycle(pool, new_id, dep_list)
        status = await deps.compute_initial_status(pool, dep_list)
        task = Task(
            id=new_id, project_id=config.project_id, title=title,
            status=status, priority=priority, parent_id=parent_id,
            context=context, done_when=done_when, depends_on=dep_list,
        )
        task = await store.create_task(pool, task)
        await cache.sync_task(redis_conn, task)
        if status == TaskStatus.PENDING:
            await cache.add_to_ready_queue(redis_conn, task)
        await publish_event(redis_conn, config.project_id, EventType.TASK_CREATED, task.id)
        click.echo(f"Created task {task.id}: {title}")
    except Exception as e:
        click.echo(f"Error: {e}")
        sys.exit(1)
    finally:
        await redis_conn.aclose()
        await pool.close()


@cli.command("claim")
@click.argument("task_id")
@click.option("--agent", default="cli-user", help="Agent ID (default: cli-user)")
def claim_cmd(task_id: str, agent: str):
    """Claim a pending task."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)
    asyncio.run(_claim_task(config, task_id, agent))


async def _claim_task(config, task_id, agent_id):
    import asyncpg
    import redis.asyncio as aioredis
    from loom.graph import cache, store
    from loom.bus.events import EventType
    from loom.bus.publisher import publish_event

    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=2)
    redis_conn = aioredis.from_url(config.redis.url, decode_responses=True)
    try:
        ttl = config.orchestration.claim_ttl_seconds
        task = await store.claim_task(pool, task_id, agent_id, ttl_seconds=ttl)
        await cache.sync_task(redis_conn, task)
        await cache.remove_from_ready_queue(redis_conn, config.project_id, task_id)
        await publish_event(redis_conn, config.project_id, EventType.TASK_CLAIMED, task_id, agent_id)
        click.echo(f"Claimed task {task_id} as {agent_id}")
    except Exception as e:
        click.echo(f"Error: {e}")
        sys.exit(1)
    finally:
        await redis_conn.aclose()
        await pool.close()


@cli.command("done")
@click.argument("task_id")
@click.option("--output", "output_json", default="{}", help="JSON output (default: {})")
@click.option("--branch-name", default=None, help="Branch name (triggers merge task creation)")
@click.option("--skip-verify", is_flag=True, help="Skip verify_command execution")
def done_cmd(task_id: str, output_json: str, branch_name: str | None, skip_verify: bool):
    """Mark a claimed task as done."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)

    try:
        output = json.loads(output_json)
    except json.JSONDecodeError:
        click.echo("Error: --output must be valid JSON")
        sys.exit(1)

    asyncio.run(_done_task(config, task_id, output, branch_name, skip_verify,
                           str(project_dir)))


async def _done_task(config, task_id, output, branch_name, skip_verify, project_dir):
    import asyncpg
    import redis.asyncio as aioredis
    from loom.graph import cache, deps, store
    from loom.bus.events import EventType
    from loom.bus.publisher import publish_event
    from loom.observability.metrics import Metrics

    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=2)
    redis_conn = aioredis.from_url(config.redis.url, decode_responses=True)
    try:
        # Run verify_command if present (matches MCP loom_done behavior)
        if not skip_verify:
            task_pre = await cache.get_task(redis_conn, pool, config.project_id, task_id)
            if task_pre.verify_command:
                import asyncio as _asyncio
                proc = await _asyncio.create_subprocess_shell(
                    task_pre.verify_command,
                    stdout=_asyncio.subprocess.PIPE,
                    stderr=_asyncio.subprocess.PIPE,
                )
                stdout, stderr = await _asyncio.wait_for(proc.communicate(), timeout=120)
                if proc.returncode != 0:
                    click.echo(f"verify_command failed (exit {proc.returncode})")
                    click.echo(stderr.decode(errors="replace")[-500:])
                    sys.exit(1)

        result = await store.complete_task(pool, task_id, output, branch_name=branch_name)
        task, merge_task = result if isinstance(result, tuple) else (result, None)
        await cache.sync_task(redis_conn, task)
        await cache.remove_from_ready_queue(redis_conn, config.project_id, task_id)
        await publish_event(redis_conn, config.project_id, EventType.TASK_DONE, task_id)
        await store.record_event(pool, config.project_id, EventType.TASK_DONE, task_id)
        await deps.check_and_unblock(pool, redis_conn, task_id, config.project_id)
        Metrics().inc("tasks_completed_total", labels={"project": config.project_id})

        if merge_task:
            await cache.sync_task(redis_conn, merge_task)
            await cache.add_to_ready_queue(redis_conn, merge_task)
            await publish_event(redis_conn, config.project_id, EventType.TASK_CREATED, merge_task.id)
            click.echo(f"Completed task {task_id} — merge task created: {merge_task.id}")
        else:
            click.echo(f"Completed task {task_id}")

        # Belt-and-suspenders: write completion marker (non-fatal)
        try:
            from loom.markers import write_completion_marker
            write_completion_marker(
                project_dir, task_id, output=output,
                branch_name=branch_name, agent_id=task.assignee,
            )
        except Exception:
            pass  # Marker write is best-effort
    except Exception as e:
        click.echo(f"Error: {e}")
        sys.exit(1)
    finally:
        await redis_conn.aclose()
        await pool.close()


@cli.command("fail")
@click.argument("task_id")
@click.option("--reason", required=True, help="Failure reason")
def fail_cmd(task_id: str, reason: str):
    """Mark a task as failed."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)
    asyncio.run(_fail_task(config, task_id, reason))


async def _fail_task(config, task_id, reason):
    import asyncpg
    import redis.asyncio as aioredis
    from loom.graph import cache, store
    from loom.bus.events import EventType
    from loom.bus.publisher import publish_escalation, publish_event
    from loom.observability.metrics import Metrics

    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=2)
    redis_conn = aioredis.from_url(config.redis.url, decode_responses=True)
    try:
        task = await store.fail_task(pool, task_id, reason)
        await cache.sync_task(redis_conn, task)
        await cache.remove_from_ready_queue(redis_conn, config.project_id, task_id)
        await publish_event(redis_conn, config.project_id, EventType.TASK_FAILED, task_id)
        await store.record_event(pool, config.project_id, EventType.TASK_FAILED, task_id)
        if config.orchestration.enable_escalation:
            await publish_escalation(redis_conn, config.project_id, task_id, reason)
        # Retry or dead letter
        from loom.orchestration.retry import process_failed_task
        task = await process_failed_task(
            pool, redis_conn, task, config.orchestration, config.project_id
        )
        Metrics().inc("tasks_failed_total", labels={"project": config.project_id})
        click.echo(f"Failed task {task_id}: {reason}")

        # Belt-and-suspenders: write failure marker (non-fatal)
        try:
            from loom.markers import write_failure_marker
            write_failure_marker(
                Path.cwd(), task_id, reason=reason, agent_id=task.assignee,
            )
        except Exception:
            pass  # Marker write is best-effort
    except Exception as e:
        click.echo(f"Error: {e}")
        sys.exit(1)
    finally:
        await redis_conn.aclose()
        await pool.close()


@cli.command("heartbeat")
@click.argument("task_id")
@click.option("--progress", default=None, help="Progress description")
@click.option("--percent", type=int, default=None, help="Completion percentage (0-100)")
def heartbeat_cmd(task_id: str, progress: str | None, percent: int | None):
    """Extend claim TTL for an in-progress task."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)
    asyncio.run(_heartbeat_task(config, task_id, progress, percent))


async def _heartbeat_task(config, task_id, progress, percent):
    import asyncpg
    import redis.asyncio as aioredis
    from datetime import datetime as dt, timedelta, timezone as tz
    from loom.graph import cache, store

    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=2)
    redis_conn = aioredis.from_url(config.redis.url, decode_responses=True)
    try:
        ttl = config.orchestration.claim_ttl_seconds
        expires_at = dt.now(tz.utc) + timedelta(seconds=ttl)
        task = await store.set_claim_expiry(pool, task_id, expires_at)
        await cache.sync_task(redis_conn, task)
        if progress is not None or percent is not None:
            await cache.set_task_progress(
                redis_conn, config.project_id, task_id, progress, percent,
            )
        remaining = int((expires_at - dt.now(tz.utc)).total_seconds())
        click.echo(f"Heartbeat {task_id} — TTL extended ({remaining}s remaining)")
    except Exception as e:
        click.echo(f"Error: {e}")
        sys.exit(1)
    finally:
        await redis_conn.aclose()
        await pool.close()


# ---------------------------------------------------------------------------
# Recovery commands: scan + recover
# ---------------------------------------------------------------------------


@cli.command("scan")
@click.option("--dry-run", is_flag=True, help="Show what would be processed (default)")
@click.option("--execute", is_flag=True, help="Actually process markers")
def scan_cmd(dry_run: bool, execute: bool):
    """Process .loom/completions/ markers — complete or fail tasks from markers."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)

    if not execute:
        dry_run = True

    asyncio.run(_scan_markers(config, str(project_dir), dry_run))


async def _scan_markers(config, project_dir, dry_run):
    from loom.markers import read_markers
    markers = read_markers(project_dir)

    if not markers:
        click.echo("No completion markers found in .loom/completions/")
        return

    click.echo(f"Found {len(markers)} marker(s):")
    for m in markers:
        status = m.get("status", "?")
        tid = m.get("task_id", "?")
        branch = m.get("branch_name", "")
        branch_str = f" (branch: {branch})" if branch else ""
        click.echo(f"  [{status}] {tid}{branch_str}")

    if dry_run:
        click.echo("\nDry run — use --execute to process markers.")
        return

    import asyncpg
    import redis.asyncio as aioredis
    from loom.recovery import RecoveryPlan, execute_recovery_plan

    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=2)
    redis_conn = aioredis.from_url(config.redis.url, decode_responses=True)
    try:
        # Build a plan with only marker tasks (delegate to recovery module)
        plan = RecoveryPlan()
        for m in markers:
            plan.marker_tasks.append({
                "task_id": m["task_id"],
                "title": "",
                "assignee": m.get("agent_id", ""),
                "marker_status": m.get("status", "done"),
                "marker_output": m.get("output", {}),
                "marker_branch": m.get("branch_name", ""),
                "marker_reason": m.get("reason", ""),
            })

        results = await execute_recovery_plan(
            pool, redis_conn, config.project_id, config, plan, project_dir,
        )
        if results["completed_from_markers"]:
            click.echo(f"Completed: {', '.join(results['completed_from_markers'])}")
        if results["failed_from_markers"]:
            click.echo(f"Failed: {', '.join(results['failed_from_markers'])}")
        if results["errors"]:
            for err in results["errors"]:
                click.echo(f"Error: {err['task_id']}: {err['error']}")
    finally:
        await redis_conn.aclose()
        await pool.close()


@cli.command("recover")
@click.option("--execute", is_flag=True, help="Apply recovery plan (default: dry-run)")
def recover_cmd(execute: bool):
    """Classify and recover orphaned tasks after orchestrator crash."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)
    asyncio.run(_recover(config, str(project_dir), execute))


async def _recover(config, project_dir, execute):
    import asyncpg
    import redis.asyncio as aioredis
    from loom.recovery import build_recovery_plan, execute_recovery_plan

    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=2)
    redis_conn = aioredis.from_url(config.redis.url, decode_responses=True)
    try:
        plan = await build_recovery_plan(
            pool, redis_conn, config.project_id, config, project_dir,
        )

        # Display the plan
        if plan.marker_tasks:
            click.echo(f"Marker tasks ({len(plan.marker_tasks)}) — will auto-complete:")
            for t in plan.marker_tasks:
                click.echo(f"  {t['task_id']}: {t.get('marker_status', '?')}")
        if plan.stale_tasks:
            click.echo(f"Stale tasks ({len(plan.stale_tasks)}) — will re-queue:")
            for t in plan.stale_tasks:
                click.echo(f"  {t['task_id']}: {t.get('title', '?')}")
        if plan.expired_tasks:
            click.echo(f"Expired tasks ({len(plan.expired_tasks)}) — will re-queue:")
            for t in plan.expired_tasks:
                click.echo(f"  {t['task_id']}: {t.get('title', '?')}")
        if plan.active_tasks:
            click.echo(f"Active tasks ({len(plan.active_tasks)}) — leaving alone:")
            for t in plan.active_tasks:
                pct = t.get("percent")
                pct_str = f" ({pct}%)" if pct is not None else ""
                click.echo(f"  {t['task_id']}: {t.get('title', '?')}{pct_str}")

        if plan.total_recoverable == 0:
            click.echo("Nothing to recover.")
            return

        click.echo(f"\nTotal recoverable: {plan.total_recoverable}")

        if not execute:
            click.echo("Dry run — use --execute to apply recovery plan.")
            return

        results = await execute_recovery_plan(
            pool, redis_conn, config.project_id, config, plan, project_dir,
        )
        completed = len(results["completed_from_markers"])
        failed = len(results["failed_from_markers"])
        reset = len(results["reset_stale"]) + len(results["reset_expired"])
        errors = len(results["errors"])
        click.echo(f"Recovery complete: {completed} completed, {failed} failed, "
                    f"{reset} reset, {errors} errors")
    finally:
        await redis_conn.aclose()
        await pool.close()


def _validate_min_complexity(ctx, param, value):
    """Click callback to validate --min-complexity is >= 1."""
    if value is not None and value < 1:
        raise click.BadParameter("must be >= 1")
    return value


@cli.command("decompose")
@click.argument("goal", required=False)
@click.option("--from", "from_file", type=click.Path(exists=True), help="Read goal from file")
@click.option("--depth", default=3, help="Max hierarchy depth")
@click.option("--no-enrich", is_flag=True, help="Skip context/criteria enrichment")
@click.option("-y", "--yes", is_flag=True, help="Skip confirmation, write directly")
@click.option("--scan-root", type=click.Path(), default=None,
              help="Scan project directory for existing code (default: None)")
@click.option("--min-complexity", type=int, default=None, callback=_validate_min_complexity,
              expose_value=True, is_eager=False,
              help="Minimum complexity threshold for task merging (default: 2)")
@click.option("--no-validate", is_flag=True, help="Skip codebase validation of tasks")
@click.option("--no-merge", is_flag=True, help="Skip merging of trivial tasks")
@click.option("--optimize-parallel", is_flag=True, help="Optimize task graph for parallelism")
@click.option("--enrich-concurrency", type=int, default=None,
              help="Max parallel enrichment calls (default: from config, usually 5)")
@click.option("--parent-epic-id", default=None,
              help="Link generated tasks/epics to an existing parent epic")
@click.option("--epic-id", default=None,
              help="Decompose an existing epic into subtasks (reads title/context as goal)")
@click.option("--force-leaf", is_flag=True,
              help="Convert childless epics to pending tasks (auto-enabled with --epic-id)")
def decompose_cmd(
    goal: str | None,
    from_file: str | None,
    depth: int,
    no_enrich: bool,
    yes: bool,
    scan_root: str | None,
    min_complexity: int | None,
    no_validate: bool,
    no_merge: bool,
    optimize_parallel: bool,
    enrich_concurrency: int | None,
    parent_epic_id: str | None,
    epic_id: str | None,
    force_leaf: bool,
):
    """Decompose a goal into a task graph."""
    if from_file:
        goal = Path(from_file).read_text().strip()
    if not goal and not epic_id:
        click.echo("Provide a goal as argument, via --from <file>, or via --epic-id.")
        sys.exit(1)

    # Validate --scan-root path exists if provided
    if scan_root is not None:
        scan_root_path = Path(scan_root)
        if not scan_root_path.exists():
            click.echo(f"Error: --scan-root path does not exist: {scan_root}")
            sys.exit(1)
        if not scan_root_path.is_dir():
            click.echo(f"Error: --scan-root path is not a directory: {scan_root}")
            sys.exit(1)

    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)

    if not config.skills.api_key:
        click.echo("Set LOOM_SKILLS_API_KEY environment variable.")
        sys.exit(1)
    if not config.project_id:
        click.echo("No project_id found. Run 'loom init' and 'loom up' first.")
        sys.exit(1)

    asyncio.run(_run_decompose(
        config, project_dir, goal or "", depth, not no_enrich, yes,
        scan_root=Path(scan_root) if scan_root else None,
        min_complexity=min_complexity,
        validate=not no_validate,
        merge_trivial=not no_merge,
        optimize_parallelism=optimize_parallel,
        enrichment_concurrency=enrich_concurrency,
        parent_epic_id=parent_epic_id,
        epic_id=epic_id,
        force_leaf=force_leaf,
    ))


async def _run_decompose(
    config, project_dir, goal, depth, enrich, auto_confirm,
    *,
    scan_root=None,
    min_complexity=None,
    validate=True,
    merge_trivial=True,
    optimize_parallelism=False,
    enrichment_concurrency=None,
    parent_epic_id=None,
    epic_id=None,
    force_leaf=False,
):
    import asyncpg
    import redis.asyncio as aioredis
    from loom.skills.decomposer import decompose, _write_to_db
    from loom.graph.task import Task

    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=5)
    redis_conn = aioredis.from_url(config.redis.url, decode_responses=True)

    try:
        # If epic_id provided, read epic and derive goal/context/parent
        if epic_id:
            from loom.graph.store import get_task, get_tasks_by_status
            epic = await get_task(pool, epic_id)
            if epic.status == "idea":
                from loom.graph.store import promote_idea as _promote
                epic = await _promote(pool, epic_id)
                click.echo(f"Promoted idea → epic: {epic.title}")
            elif epic.status != "epic":
                click.echo(f"Error: Task '{epic_id}' is '{epic.status}', not an epic.")
                sys.exit(1)
            goal = goal or f"Decompose: {epic.title}"
            epic_ctx = epic.context.get("description", "") if isinstance(epic.context, dict) else ""
            if epic_ctx and not scan_root:
                click.echo(f"Epic: {epic.title}")
            parent_epic_id = epic_id
            if not force_leaf:
                force_leaf = True

            # Inject existing tasks as context to prevent duplicate work items
            existing = []
            for st in ("pending", "blocked", "claimed", "epic"):
                existing += await get_tasks_by_status(pool, config.project_id, st, limit=200)
            sibling_titles = [f"- {t.title}" for t in existing if t.id != epic_id]
            if sibling_titles:
                dedup_note = (
                    "\n\nEXISTING TASKS IN THIS PROJECT (do NOT create duplicates of these):\n"
                    + "\n".join(sibling_titles)
                )
                goal = goal + dedup_note

        click.echo(f"Decomposing: {goal}")
        if scan_root:
            click.echo(f"Scanning codebase at: {scan_root}")
        click.echo("Calling LLM...")
        result = await decompose(
            goal=goal, project_id=config.project_id, config=config.skills,
            pool=pool, redis=redis_conn, confirm=True,
            project_dir=str(project_dir), depth=depth,
            enrich=enrich if validate else False,
            scan_root=scan_root,
            min_complexity=min_complexity,
            merge_trivial=merge_trivial,
            optimize_parallelism=optimize_parallelism,
            enrichment_concurrency=enrichment_concurrency,
            parent_epic_id=parent_epic_id,
            force_leaf=force_leaf,
        )

        click.echo(f"\nProposed task graph: {result.total_count} tasks, "
                    f"{len(result.epics)} epics, {result.dependency_edges} deps")
        click.echo("")
        for t in result.tasks:
            prefix = "  " if t.get("parent_id") else ""
            status_icon = {"epic": "E", "pending": "P", "blocked": "B"}.get(t["status"], "?")
            deps = f" (deps: {len(t['depends_on'])})" if t["depends_on"] else ""
            click.echo(f"  {prefix}[{status_icon}] {t['title']}{deps}")

        if auto_confirm:
            confirmed = True
        else:
            click.echo("")
            confirmed = click.confirm("Write this graph to the database?")

        if confirmed:
            tasks = [Task(**t) for t in result.tasks]
            await _write_to_db(tasks, pool, redis_conn, config.project_id)
            click.echo(f"\nWrote {len(tasks)} tasks to database.")
            click.echo("Run 'loom status' to see the project overview.")
        else:
            click.echo("Decomposition cancelled.")
    finally:
        await redis_conn.aclose()
        await pool.close()


@cli.command("graph")
@click.option("--format", "fmt", type=click.Choice(["json", "mermaid", "summary"]),
              default="summary", help="Output format")
def graph_cmd(fmt: str):
    """Show the full task dependency graph."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)

    if not config.project_id:
        click.echo("No project_id found. Run 'loom init' and 'loom up' first.")
        sys.exit(1)

    asyncio.run(_show_graph(config.database.url, config.project_id, fmt))


async def _show_graph(dsn: str, project_id: str, fmt: str):
    import asyncpg
    pool = await asyncpg.create_pool(dsn, min_size=1, max_size=2)

    async with pool.acquire() as conn:
        tasks = await conn.fetch(
            "SELECT * FROM tasks WHERE project_id = $1::uuid", project_id
        )
        all_deps = await conn.fetch(
            """
            SELECT td.task_id, td.depends_on FROM task_deps td
            JOIN tasks t ON t.id = td.task_id
            WHERE t.project_id = $1::uuid
            """,
            project_id,
        )

    dep_map: dict[str, list[str]] = {}
    for d in all_deps:
        dep_map.setdefault(d["task_id"], []).append(d["depends_on"])

    task_list = []
    for t in tasks:
        task_list.append({
            "id": t["id"], "title": t["title"], "status": t["status"],
            "priority": t["priority"], "assignee": t["assignee"],
            "parent_id": t["parent_id"],
            "depends_on": dep_map.get(t["id"], []),
        })

    if fmt == "json":
        click.echo(json.dumps({"tasks": task_list}, indent=2))

    elif fmt == "mermaid":
        lines = ["graph TD"]
        for t in task_list:
            safe_title = t["title"].replace('"', "'")
            label = f'{t["id"]}["{safe_title}"]'
            lines.append(f"    {label}")
            for dep in t["depends_on"]:
                lines.append(f"    {dep} --> {t['id']}")
        click.echo("\n".join(lines))

    else:  # summary
        by_status: dict[str, int] = {}
        for t in task_list:
            by_status[t["status"]] = by_status.get(t["status"], 0) + 1
        total = len(task_list)
        click.echo(f"{total} tasks total")
        for s, c in sorted(by_status.items()):
            click.echo(f"  {s}: {c}")
        if not task_list:
            click.echo("  (no tasks yet — run 'loom decompose' to create some)")

    await pool.close()


# ---------------------------------------------------------------------------
# Phase 5 Stream C: Project management commands
# ---------------------------------------------------------------------------


@cli.group("project")
def project_group():
    """Manage projects — list, create, switch, archive."""


@project_group.command("list")
def project_list_cmd():
    """List all projects."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)
    asyncio.run(_list_projects(config))


async def _list_projects(config):
    import asyncpg
    from loom.graph.project import list_projects
    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=2)
    try:
        projects = await list_projects(pool)
        if not projects:
            click.echo("No projects found.")
            return
        active_id = config.project_id
        for p in projects:
            pid = str(p["id"])
            marker = " *" if pid == active_id else ""
            desc = f" — {p.get('description', '')}" if p.get('description') else ""
            click.echo(f"  {p['name']} ({pid}){desc}{marker}")
    finally:
        await pool.close()


@project_group.command("create")
@click.argument("name")
@click.option("--description", "-d", default="", help="Project description")
def project_create_cmd(name: str, description: str):
    """Create a new project."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)
    asyncio.run(_create_project(config, name, description))


async def _create_project(config, name, description):
    import asyncpg
    from loom.graph.project import create_project
    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=2)
    try:
        project = await create_project(pool, name, description)
        click.echo(f"Created project: {name} ({project['id']})")
    finally:
        await pool.close()


@project_group.command("switch")
@click.argument("project_id")
def project_switch_cmd(project_id: str):
    """Switch active project in .loom/config.yaml."""
    project_dir = Path.cwd()
    config_path = project_dir / ".loom" / "config.yaml"
    if not config_path.exists():
        click.echo("No .loom/config.yaml found. Run 'loom init' first.")
        sys.exit(1)

    with open(config_path) as f:
        config_data = yaml.safe_load(f) or {}

    config_data.setdefault("loom", {})["project_id"] = project_id

    with open(config_path, "w") as f:
        yaml.dump(config_data, f, default_flow_style=False, sort_keys=False)

    click.echo(f"Switched to project: {project_id}")


@project_group.command("archive")
@click.argument("project_id")
def project_archive_cmd(project_id: str):
    """Archive a project."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)
    asyncio.run(_archive_project(config, project_id))


async def _archive_project(config, project_id):
    import asyncpg
    from loom.graph.project import archive_project
    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=2)
    try:
        project = await archive_project(pool, project_id)
        click.echo(f"Archived project: {project['name']} ({project_id})")
    except Exception as e:
        click.echo(f"Error: {e}")
        sys.exit(1)
    finally:
        await pool.close()


# ---------------------------------------------------------------------------
# Stream G: Workflow commands
# ---------------------------------------------------------------------------


@cli.group("workflow")
def workflow_group():
    """Manage workflows — list, run, status."""


@workflow_group.command("list")
def workflow_list_cmd():
    """List available workflows."""
    from loom.workflows.loader import discover_workflows
    project_dir = Path.cwd()
    workflows = discover_workflows(str(project_dir))
    if not workflows:
        click.echo("No workflows found.")
        return
    for name, wf in sorted(workflows.items()):
        inputs_str = ", ".join(wf.inputs) if wf.inputs else "(none)"
        click.echo(f"  {name}: {wf.description or '(no description)'}")
        click.echo(f"    inputs: {inputs_str}")


@workflow_group.command("run")
@click.argument("name")
@click.option("--input", "input_pairs", multiple=True, help="Input as key=value")
def workflow_run_cmd(name: str, input_pairs: tuple[str, ...]):
    """Run a workflow with given inputs."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)

    if not config.skills.api_key:
        click.echo("Set LOOM_SKILLS_API_KEY environment variable.")
        sys.exit(1)
    if not config.project_id:
        click.echo("No project_id found. Run 'loom init' and 'loom up' first.")
        sys.exit(1)

    inputs = {}
    for pair in input_pairs:
        if "=" not in pair:
            click.echo(f"Invalid input format: {pair} (expected key=value)")
            sys.exit(1)
        k, v = pair.split("=", 1)
        inputs[k] = v

    asyncio.run(_run_workflow(config, str(project_dir), name, inputs))


async def _run_workflow(config, project_dir, name, inputs):
    import asyncpg
    import redis.asyncio as aioredis
    from loom.workflows.loader import load_workflow
    from loom.workflows.runner import run_workflow

    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=5)
    redis_conn = aioredis.from_url(config.redis.url, decode_responses=True)

    try:
        wf = load_workflow(name, project_dir)
        click.echo(f"Running workflow: {wf.name}")
        click.echo(f"Steps: {', '.join(s.name for s in wf.steps)}")
        click.echo("")

        result = await run_workflow(
            wf, inputs, config.skills, pool, redis_conn,
            config.project_id, project_dir,
        )

        click.echo(f"Workflow status: {result['status']}")
        if result["status"] == "paused":
            click.echo(f"Paused at step: {result['current_step']}")
            click.echo(f"Run ID: {result['id']}")
            click.echo("Use 'loom workflow resume <run_id>' to continue.")
        elif result["status"] == "completed":
            click.echo(f"Run ID: {result['id']}")
            click.echo("Workflow completed successfully.")
    except LookupError as e:
        click.echo(str(e))
        sys.exit(1)
    finally:
        await redis_conn.aclose()
        await pool.close()


@workflow_group.command("resume")
@click.argument("run_id")
def workflow_resume_cmd(run_id: str):
    """Resume a paused workflow."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)

    if not config.project_id:
        click.echo("No project_id found. Run 'loom init' and 'loom up' first.")
        sys.exit(1)

    asyncio.run(_resume_workflow(config, str(project_dir), run_id))


async def _resume_workflow(config, project_dir, run_id):
    import asyncpg
    import redis.asyncio as aioredis
    from loom.workflows.runner import resume_workflow

    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=5)
    redis_conn = aioredis.from_url(config.redis.url, decode_responses=True)

    try:
        click.echo(f"Resuming workflow run: {run_id}")
        result = await resume_workflow(
            run_id, pool, redis_conn, config.skills,
            config.project_id, project_dir,
        )
        click.echo(f"Workflow status: {result['status']}")
        if result["status"] == "paused":
            click.echo(f"Paused at step: {result['current_step']}")
        elif result["status"] == "completed":
            click.echo("Workflow completed successfully.")
    except (LookupError, ValueError) as e:
        click.echo(str(e))
        sys.exit(1)
    finally:
        await redis_conn.aclose()
        await pool.close()


@workflow_group.command("status")
@click.argument("run_id", required=False)
def workflow_status_cmd(run_id: str | None):
    """Show workflow run status or list all runs."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)

    if not config.project_id:
        click.echo("No project_id found. Run 'loom init' and 'loom up' first.")
        sys.exit(1)

    asyncio.run(_show_workflow_status(config, run_id))


async def _show_workflow_status(config, run_id):
    import asyncpg
    from loom.graph import store

    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=2)

    try:
        if run_id:
            run = await store.get_workflow_run(pool, run_id)
            click.echo(f"Workflow: {run['workflow_name']}")
            click.echo(f"  Status:  {run['status']}")
            click.echo(f"  Step:    {run['current_step']}")
            click.echo(f"  Started: {run['started_at']}")
            if run.get("completed_at"):
                click.echo(f"  Done:    {run['completed_at']}")
            if run.get("error"):
                click.echo(f"  Error:   {run['error']}")
        else:
            runs = await store.list_workflow_runs(pool, config.project_id)
            if not runs:
                click.echo("No workflow runs found.")
                return
            for r in runs:
                click.echo(f"  [{r['status']:>9}] {r['workflow_name']} — {r['id']}")
    except LookupError as e:
        click.echo(str(e))
    finally:
        await pool.close()


# ---------------------------------------------------------------------------
# Stream G: Orchestrate command
# ---------------------------------------------------------------------------


@cli.command("orchestrate")
@click.option("--once", is_flag=True, help="Run a single sweep then exit")
@click.option("--daemon", is_flag=True, help="Run continuously")
def orchestrate_cmd(once: bool, daemon: bool):
    """Run the orchestrator (supervisor loop)."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)

    if not config.project_id:
        click.echo("No project_id found. Run 'loom init' and 'loom up' first.")
        sys.exit(1)

    asyncio.run(_run_orchestrate(config, str(project_dir), once, daemon))


async def _run_orchestrate(config, project_dir, once, daemon):
    import asyncpg
    import redis.asyncio as aioredis
    from loom.orchestration.loop import orchestrator_loop, orchestrator_tick

    pool = await asyncpg.create_pool(config.database.url, min_size=2, max_size=10)
    redis_conn = aioredis.from_url(config.redis.url, decode_responses=True)

    try:
        if once or not daemon:
            click.echo("Running single orchestrator sweep...")
            summary = await orchestrator_tick(
                pool, redis_conn, config.project_id, config, project_dir,
            )
            click.echo(f"  Expired claims released: {summary['expired_claims_released']}")
            click.echo(f"  Tasks retried:           {summary['tasks_retried']}")
            click.echo(f"  Escalations handled:     {summary['escalations_handled']}")
            click.echo(f"  Project complete:        {summary['project_complete']}")
        else:
            click.echo("Starting orchestrator daemon...")
            click.echo(f"  Sweep interval: {config.orchestration.sweep_interval_seconds}s")
            await orchestrator_loop(
                pool, redis_conn, config.project_id, config, project_dir,
            )
    finally:
        await redis_conn.aclose()
        await pool.close()


# ---------------------------------------------------------------------------
# Stream G: Dead letter command
# ---------------------------------------------------------------------------


@cli.command("dead-letter")
@click.option("--retry", "retry_task_id", default=None, help="Manually retry a dead letter task")
@click.option("--limit", default=20, help="Max tasks to list")
def dead_letter_cmd(retry_task_id: str | None, limit: int):
    """List or retry dead letter (permanently failed) tasks."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)

    if not config.project_id:
        click.echo("No project_id found. Run 'loom init' and 'loom up' first.")
        sys.exit(1)

    asyncio.run(_dead_letter(config, retry_task_id, limit))


async def _dead_letter(config, retry_task_id, limit):
    import asyncpg
    import redis.asyncio as aioredis
    from loom.graph import cache, store
    from loom.graph.task import Task, TaskStatus

    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=5)
    redis_conn = aioredis.from_url(config.redis.url, decode_responses=True)

    try:
        if retry_task_id:
            # Manual retry: reset dead letter flags, set to pending
            async with pool.acquire() as conn:
                record = await conn.fetchrow(
                    """
                    UPDATE tasks
                    SET dead_letter = FALSE, dead_letter_reason = NULL,
                        retry_count = 0, retry_after = NULL,
                        status = 'pending', assignee = NULL,
                        updated_at = NOW()
                    WHERE id = $1 AND dead_letter = TRUE
                    RETURNING *
                    """,
                    retry_task_id,
                )
            if record is None:
                click.echo(f"Task {retry_task_id} not found in dead letter queue.")
                return

            deps_rows = await pool.fetch(
                "SELECT depends_on FROM task_deps WHERE task_id = $1", retry_task_id
            )
            task = Task.from_record(record, depends_on=[d["depends_on"] for d in deps_rows])
            await cache.sync_task(redis_conn, task)
            await cache.add_to_ready_queue(redis_conn, task)
            click.echo(f"Retried task {retry_task_id} — now pending.")
        else:
            # List dead letter tasks
            async with pool.acquire() as conn:
                rows = await conn.fetch(
                    """
                    SELECT id, title, dead_letter_reason, retry_count, updated_at
                    FROM tasks
                    WHERE project_id = $1::uuid AND dead_letter = TRUE
                    ORDER BY updated_at DESC
                    LIMIT $2
                    """,
                    config.project_id, limit,
                )
            if not rows:
                click.echo("No tasks in dead letter queue.")
                return
            click.echo(f"Dead letter tasks ({len(rows)}):")
            for r in rows:
                click.echo(
                    f"  {r['id']}: {r['title']} "
                    f"(retries: {r['retry_count']}, reason: {r['dead_letter_reason']})"
                )
    finally:
        await redis_conn.aclose()
        await pool.close()


# ---------------------------------------------------------------------------
# Stream E: Deploy command
# ---------------------------------------------------------------------------


@cli.command("deploy")
@click.option("--project", "gcp_project", default=None, help="GCP project ID")
@click.option("--region", default="us-central1", help="GCP region")
@click.option("--tag", default=None, help="Image tag (default: git short SHA)")
def deploy_cmd(gcp_project: str | None, region: str, tag: str | None):
    """Deploy Loom MCP server to GCP Cloud Run."""
    import os

    # Resolve GCP project
    gcp_project = gcp_project or os.environ.get("LOOM_GCP_PROJECT")
    if not gcp_project:
        click.echo(
            "GCP project required. Use --project or set LOOM_GCP_PROJECT."
        )
        sys.exit(1)

    # Resolve image tag
    if not tag:
        result = subprocess.run(
            ["git", "rev-parse", "--short", "HEAD"],
            capture_output=True, text=True,
        )
        tag = result.stdout.strip() if result.returncode == 0 else "latest"

    image = f"gcr.io/{gcp_project}/loom-mcp:{tag}"
    project_dir = Path.cwd()
    dockerfile = project_dir / "Dockerfile"

    if not dockerfile.exists():
        click.echo("No Dockerfile found in current directory.")
        sys.exit(1)

    # Step 1: Build
    click.echo(f"Building Docker image: {image}")
    result = subprocess.run(
        ["docker", "build", "-t", image, "."],
        cwd=str(project_dir),
    )
    if result.returncode != 0:
        click.echo("Docker build failed.")
        sys.exit(1)

    # Step 2: Push
    click.echo(f"Pushing image to GCR...")
    result = subprocess.run(["docker", "push", image])
    if result.returncode != 0:
        click.echo("Docker push failed. Run 'gcloud auth configure-docker' first.")
        sys.exit(1)

    # Step 3: Deploy to Cloud Run
    click.echo(f"Deploying to Cloud Run ({region})...")
    deploy_args = [
        "gcloud", "run", "deploy", "loom-mcp",
        f"--image={image}",
        f"--region={region}",
        "--platform=managed",
        "--set-env-vars=LOOM_ENV=production",
        "--set-secrets="
        "LOOM_DATABASE_URL=loom-database-url:latest,"
        "LOOM_REDIS_URL=loom-redis-url:latest,"
        "LOOM_SKILLS_API_KEY=loom-skills-api-key:latest",
    ]
    result = subprocess.run(deploy_args)
    if result.returncode != 0:
        click.echo("Cloud Run deployment failed.")
        sys.exit(1)

    # Get the service URL
    url_result = subprocess.run(
        ["gcloud", "run", "services", "describe", "loom-mcp",
         f"--region={region}", "--format=value(status.url)"],
        capture_output=True, text=True,
    )
    if url_result.returncode == 0 and url_result.stdout.strip():
        click.echo(f"Deployed successfully: {url_result.stdout.strip()}")
    else:
        click.echo("Deployed successfully.")


# ---------------------------------------------------------------------------
# Skill commands
# ---------------------------------------------------------------------------


@cli.group("skill")
def skill_group():
    """Manage skills — list, run."""


@skill_group.command("list")
def skill_list_cmd():
    """List available skills."""
    from loom.skills.loader import discover_skills
    project_dir = Path.cwd()
    skills = discover_skills(str(project_dir))
    if not skills:
        click.echo("No skills found.")
        return
    for name, skill in sorted(skills.items()):
        inputs_str = ", ".join(skill.inputs) if skill.inputs else "(none)"
        model_str = skill.model or "(default)"
        click.echo(f"  {name}: {skill.description or '(no description)'}")
        click.echo(f"    inputs: {inputs_str}  model: {model_str}")


@skill_group.command("run")
@click.argument("name")
@click.option("--input", "input_pairs", multiple=True, help="Input as key=value")
def skill_run_cmd(name: str, input_pairs: tuple[str, ...]):
    """Run a skill with given inputs."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)

    if not config.skills.api_key:
        click.echo("Set LOOM_SKILLS_API_KEY environment variable.")
        sys.exit(1)

    inputs = {}
    for pair in input_pairs:
        if "=" not in pair:
            click.echo(f"Invalid input format: {pair} (expected key=value)")
            sys.exit(1)
        k, v = pair.split("=", 1)
        inputs[k] = v

    asyncio.run(_run_skill(config, str(project_dir), name, inputs))


async def _run_skill(config, project_dir, name, inputs):
    from loom.skills.loader import load_skill
    from loom.skills.runner import run_skill

    try:
        skill = load_skill(name, project_dir)
        click.echo(f"Running skill: {name}")
        result = await run_skill(skill, inputs, config.skills)
        click.echo(json.dumps(result, indent=2))
    except Exception as e:
        click.echo(f"Error: {e}")
        sys.exit(1)


# ---------------------------------------------------------------------------
# Config commands
# ---------------------------------------------------------------------------


@cli.group("config")
def config_group():
    """Manage configuration — show, set."""


@config_group.command("show")
def config_show_cmd():
    """Show merged configuration."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)
    data = config.model_dump()
    # Mask API key if present
    if data.get("skills", {}).get("api_key"):
        data["skills"]["api_key"] = "****"
    click.echo(yaml.dump(data, default_flow_style=False, sort_keys=False))


@config_group.command("set")
@click.argument("key")
@click.argument("value")
def config_set_cmd(key: str, value: str):
    """Set a project config value using dot notation (e.g., skills.model)."""
    project_dir = Path.cwd()
    config_path = project_dir / ".loom" / "config.yaml"
    if not config_path.exists():
        click.echo("No .loom/config.yaml found. Run 'loom init' first.")
        sys.exit(1)

    with open(config_path) as f:
        data = yaml.safe_load(f) or {}

    # Navigate dot notation and set value
    parts = key.split(".")
    node = data
    for part in parts[:-1]:
        node = node.setdefault(part, {})
    # Try to parse value as int/float/bool
    parsed_value: str | int | float | bool = value
    if value.lower() in ("true", "false"):
        parsed_value = value.lower() == "true"
    else:
        try:
            parsed_value = int(value)
        except ValueError:
            try:
                parsed_value = float(value)
            except ValueError:
                pass
    node[parts[-1]] = parsed_value

    with open(config_path, "w") as f:
        yaml.dump(data, f, default_flow_style=False, sort_keys=False)

    click.echo(f"Set {key} = {parsed_value}")


# ---------------------------------------------------------------------------
# Phase 5 Stream D: Agent management commands
# ---------------------------------------------------------------------------


@cli.group("agent")
def agent_group():
    """Manage agents — create, list, deactivate."""


@agent_group.command("create")
@click.argument("name")
@click.option("--role", type=click.Choice(["readonly", "worker", "lead", "admin"]),
              default="worker", help="Agent role")
def agent_create_cmd(name: str, role: str):
    """Create a new agent and print its API key."""
    import secrets
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)
    api_key = f"loom-{secrets.token_hex(16)}"
    asyncio.run(_create_agent(config, name, role, api_key))
    click.echo(f"API Key (shown once): {api_key}")
    click.echo("Store this key securely — it cannot be retrieved later.")


async def _create_agent(config, name, role, api_key):
    import asyncpg
    from loom.auth.store import create_agent
    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=2)
    try:
        agent = await create_agent(pool, name, role, api_key)
        click.echo(f"Created agent: {agent.name} (id: {agent.id}, role: {agent.role.value})")
    finally:
        await pool.close()


@agent_group.command("list")
def agent_list_cmd():
    """List all active agents."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)
    asyncio.run(_list_agents(config))


async def _list_agents(config):
    import asyncpg
    from loom.auth.store import list_agents
    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=2)
    try:
        agents = await list_agents(pool)
        if not agents:
            click.echo("No agents found.")
            return
        for a in agents:
            last_seen = a.last_seen_at or "never"
            click.echo(f"  {a.name} ({a.id}) — role: {a.role.value}, last seen: {last_seen}")
    finally:
        await pool.close()


@agent_group.command("deactivate")
@click.argument("agent_id")
def agent_deactivate_cmd(agent_id: str):
    """Deactivate an agent."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)
    asyncio.run(_deactivate_agent(config, agent_id))


async def _deactivate_agent(config, agent_id):
    import asyncpg
    from loom.auth.store import deactivate_agent
    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=2)
    try:
        await deactivate_agent(pool, agent_id)
        click.echo(f"Deactivated agent: {agent_id}")
    finally:
        await pool.close()


# ---------------------------------------------------------------------------
# Logs command
# ---------------------------------------------------------------------------


@cli.command("logs")
@click.option("-n", "--lines", default=50, help="Number of log entries to show")
@click.option("-f", "--follow", is_flag=True, help="Follow log output in real time (Ctrl-C to stop)")
@click.argument("agent", required=False, default=None)
def logs_cmd(lines: int, follow: bool, agent: str | None):
    """Show recent log entries from .loom/logs/.

    Optionally filter by AGENT name (matches 'agent' or 'source' JSON field).
    """
    log_dir = Path.cwd() / ".loom" / "logs"
    if not log_dir.is_dir():
        click.echo("No logs directory found (.loom/logs/).")
        return

    log_files = sorted(log_dir.glob("*.log"), key=lambda p: p.stat().st_mtime)
    if not log_files:
        click.echo("No log files found.")
        return

    def _matches_agent(obj: dict, agent_filter: str) -> bool:
        """Check if a log entry matches the agent filter."""
        agent_val = obj.get("agent", "")
        source_val = obj.get("source", "")
        filt = agent_filter.lower()
        return filt in str(agent_val).lower() or filt in str(source_val).lower()

    entries: list[tuple[str, str, str, str]] = []
    for lf in log_files:
        for line in lf.read_text(errors="replace").splitlines():
            line = line.strip()
            if not line:
                continue
            try:
                obj = json.loads(line)
                if agent and not _matches_agent(obj, agent):
                    continue
                ts = obj.get("timestamp", "")[:19]
                lvl = obj.get("severity", obj.get("level", "???"))
                msg = obj.get("message", line)
                name = obj.get("logger", "")
                entries.append((ts, lvl, name, msg))
            except json.JSONDecodeError:
                if not agent:  # non-JSON lines cannot match an agent filter
                    entries.append(("", "???", "", line))

    if agent and not entries:
        click.echo(f"Warning: no log entries matching agent '{agent}'.")
        return

    for ts, lvl, name, msg in entries[-lines:]:
        prefix = f"{ts} {lvl:<8}" if ts else lvl
        if name:
            prefix += f" {name}:"
        click.echo(f"{prefix} {msg}")

    if follow:
        # Track last read position for each file
        file_positions: dict[Path, int] = {}
        for lf in log_files:
            file_positions[lf] = lf.stat().st_size

        try:
            while True:
                time.sleep(1)
                # Check existing files for new content
                for lf in list(file_positions):
                    try:
                        size = lf.stat().st_size
                    except OSError:
                        continue
                    if size <= file_positions[lf]:
                        continue
                    with open(lf, errors="replace") as fh:
                        fh.seek(file_positions[lf])
                        new_data = fh.read()
                    file_positions[lf] = size
                    for raw_line in new_data.splitlines():
                        raw_line = raw_line.strip()
                        if not raw_line:
                            continue
                        try:
                            obj = json.loads(raw_line)
                            if agent and not _matches_agent(obj, agent):
                                continue
                            ts = obj.get("timestamp", "")[:19]
                            lvl = obj.get("severity", obj.get("level", "???"))
                            msg = obj.get("message", raw_line)
                            name = obj.get("logger", "")
                        except json.JSONDecodeError:
                            if agent:
                                continue
                            ts, lvl, name, msg = "", "???", "", raw_line
                        prefix = f"{ts} {lvl:<8}" if ts else lvl
                        if name:
                            prefix += f" {name}:"
                        click.echo(f"{prefix} {msg}")

                # Detect new log files
                current_files = sorted(log_dir.glob("*.log"), key=lambda p: p.stat().st_mtime)
                for lf in current_files:
                    if lf not in file_positions:
                        file_positions[lf] = 0  # read from start on next iteration
        except KeyboardInterrupt:
            click.echo("\nStopped following logs.")


# ---------------------------------------------------------------------------
# Phase 5 Stream E: Dashboard command
# ---------------------------------------------------------------------------


@cli.command("dashboard")
@click.option("--port", default=None, type=int, help="Health/dashboard port (default: from config)")
def dashboard_cmd(port: int | None):
    """Open the Loom dashboard in your browser."""
    import webbrowser

    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)
    p = port or config.mcp.health_port
    url = f"http://localhost:{p}/dashboard"
    click.echo(f"Opening dashboard: {url}")
    webbrowser.open(url)


# ---------------------------------------------------------------------------
# Stream D: Digest command
# ---------------------------------------------------------------------------


@cli.command("digest")
@click.option("--vault", default=None, help="Obsidian vault path (overrides config)")
def digest_cmd(vault: str | None):
    """Generate and write a daily project digest to Obsidian vault."""
    project_dir = Path.cwd()
    from loom.config import load_config
    config = load_config(project_dir)

    if not config.project_id:
        click.echo("No project_id found. Run 'loom init' and 'loom up' first.")
        sys.exit(1)

    vault_path = vault or config.integrations.obsidian_vault_path
    if not vault_path:
        click.echo("No vault path configured. Use --vault or set LOOM_OBSIDIAN_VAULT_PATH.")
        sys.exit(1)

    asyncio.run(_write_digest(config, vault_path))


async def _write_digest(config, vault_path):
    import asyncpg
    from loom.graph.project import get_project_status
    from loom.integrations.obsidian import write_daily_digest

    pool = await asyncpg.create_pool(config.database.url, min_size=1, max_size=2)

    try:
        ps = await get_project_status(pool, config.project_id)
        status_counts = {
            "pending": ps.pending,
            "claimed": ps.claimed,
            "done": ps.done,
            "failed": ps.failed,
            "blocked": ps.blocked,
        }

        # Get open escalations
        async with pool.acquire() as conn:
            escalation_rows = await conn.fetch(
                """
                SELECT task_id, message FROM escalations
                WHERE project_id = $1::uuid AND resolved_at IS NULL
                ORDER BY created_at DESC LIMIT 20
                """,
                config.project_id,
            )
        escalations = [dict(r) for r in escalation_rows]

        summary = (
            f"{ps.project_name}: {ps.total} tasks total — "
            f"{ps.done} done, {ps.pending} pending, {ps.claimed} claimed, "
            f"{ps.failed} failed, {ps.blocked} blocked"
        )

        file_path = await write_daily_digest(
            vault_path=vault_path,
            project_name=ps.project_name,
            summary=summary,
            status_counts=status_counts,
            escalations=escalations,
        )
        click.echo(f"Digest written to: {file_path}")
    except Exception as e:
        click.echo(f"Failed to write digest: {e}")
        sys.exit(1)
    finally:
        await pool.close()
