# VibePod Orchestrate Implementation Plan

> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Implement `vp orchestrate plan/execute/merge` — a distributed A2A orchestration system that runs AI agent worker containers in parallel across isolated git worktrees.

**Architecture:** A new `orchestrate` Typer subapp delegates to three core modules: `plan_file.py` (PLAN.md data model and I/O), `prompts.py` (prompt template generation), and `worker.py` (worker container spawn + polling + retry). Git worktree operations live in `worktree.py`. The `plan` command writes a planning prompt and runs the controller agent; `execute` spawns workers into worktrees and polls for JSON completion signals; `merge` generates a merge prompt and runs the controller agent again.

**Tech Stack:** Python 3.12, Typer, Docker SDK (`docker` package), PyYAML, dataclasses, subprocess (for git), pytest + monkeypatch for tests. No new dependencies required.

**Spec:** `docs/superpowers/specs/2026-03-22-orchestrate-design.md`

---

## File Map

### New files
| File | Responsibility |
|---|---|
| `src/vibepod/commands/orchestrate.py` | Typer subapp with `plan`, `execute`, `merge` commands |
| `src/vibepod/core/orchestrate/__init__.py` | Package init (empty) |
| `src/vibepod/core/orchestrate/plan_file.py` | `OrchestrateTask`, `OrchestratePlan` dataclasses; PLAN.md parse/write/update |
| `src/vibepod/core/orchestrate/prompts.py` | Planning prompt and merge prompt template generation |
| `src/vibepod/core/orchestrate/worker.py` | Worker spawn, status polling, fallback retry, re-entrancy checks |
| `src/vibepod/core/orchestrate/worktree.py` | Git worktree create/reset/delete via subprocess |
| `tests/test_orchestrate_plan_file.py` | Tests for PLAN.md data model, parsing, writing, updating |
| `tests/test_orchestrate_prompts.py` | Tests for prompt template generation |
| `tests/test_orchestrate_worker.py` | Tests for worker lifecycle (spawn, poll, retry, re-entrancy) |
| `tests/test_orchestrate_worktree.py` | Tests for git worktree operations |

### Modified files
| File | Change |
|---|---|
| `src/vibepod/commands/run.py` | Add `--prompt-file` flag; mount file + inject `VP_PROMPT_FILE` env var |
| `src/vibepod/cli.py` | Register `orchestrate` subapp as `vp orchestrate` |
| `tests/test_run.py` | Tests for `--prompt-file` flag behavior |

---

## Chunk 1: `--prompt-file` flag on `vp run`

**Files:**
- Modify: `src/vibepod/commands/run.py`
- Test: `tests/test_run.py`

- [ ] **Step 1.1: Write failing test — prompt file is mounted read-only at `/vibepod-prompt.md`**

Add to `tests/test_run.py`:

```python
def test_prompt_file_mounts_and_sets_env(monkeypatch, tmp_path: Path) -> None:
    """--prompt-file mounts the file read-only and sets VP_PROMPT_FILE env var."""
    captured: dict = {}
    prompt_file = tmp_path / "my-prompt.md"
    prompt_file.write_text("Do the thing")

    class _CapturingDockerManager:
        def ensure_network(self, name: str) -> None:
            pass
        def networks_with_running_containers(self) -> list[str]:
            return []
        def pull_image(self, image: str) -> None:
            pass
        def ensure_proxy(self, **kwargs) -> None:
            pass
        def run_agent(self, **kwargs) -> object:
            captured.update(kwargs)
            return _make_running_container("vibepod-claude-test")

    monkeypatch.setattr(run_cmd, "get_config", lambda: _make_config())
    monkeypatch.setattr(run_cmd, "DockerManager", _CapturingDockerManager)

    run_cmd.run(agent="claude", workspace=tmp_path, detach=True, prompt_file=prompt_file)

    assert (str(prompt_file), "/vibepod-prompt.md", "ro") in captured["extra_volumes"]
    assert captured["env"]["VP_PROMPT_FILE"] == "/vibepod-prompt.md"


def test_prompt_file_none_does_not_mount(monkeypatch, tmp_path: Path) -> None:
    """Without --prompt-file, no extra volume or VP_PROMPT_FILE is added."""
    captured: dict = {}

    class _CapturingDockerManager:
        def ensure_network(self, name: str) -> None:
            pass
        def networks_with_running_containers(self) -> list[str]:
            return []
        def pull_image(self, image: str) -> None:
            pass
        def ensure_proxy(self, **kwargs) -> None:
            pass
        def run_agent(self, **kwargs) -> object:
            captured.update(kwargs)
            return _make_running_container("vibepod-claude-test")

    monkeypatch.setattr(run_cmd, "get_config", lambda: _make_config())
    monkeypatch.setattr(run_cmd, "DockerManager", _CapturingDockerManager)

    run_cmd.run(agent="claude", workspace=tmp_path, detach=True)

    prompt_vols = [v for v in captured.get("extra_volumes", []) if "/vibepod-prompt.md" in str(v)]
    assert not prompt_vols
    assert "VP_PROMPT_FILE" not in captured.get("env", {})


def test_prompt_file_not_found_raises(monkeypatch, tmp_path: Path) -> None:
    """--prompt-file with a non-existent path raises BadParameter."""
    monkeypatch.setattr(run_cmd, "get_config", lambda: _make_config())

    with pytest.raises(typer.BadParameter, match="not found"):
        run_cmd.run(
            agent="claude",
            workspace=tmp_path,
            detach=True,
            prompt_file=tmp_path / "missing.md",
        )
```

Also add a helper to `tests/test_run.py` (avoid duplicating the container stub):

```python
def _make_running_container(name: str) -> object:
    return type(
        "_Container",
        (),
        {
            "name": name,
            "id": "abc123",
            "status": "running",
            "attrs": {"NetworkSettings": {"Networks": {}}},
            "reload": lambda self: None,
            "labels": {},
            "logs": lambda self, **kw: b"",
        },
    )()
```

- [ ] **Step 1.2: Run tests to confirm they fail**

```bash
cd /workspace && python -m pytest tests/test_run.py::test_prompt_file_mounts_and_sets_env tests/test_run.py::test_prompt_file_none_does_not_mount tests/test_run.py::test_prompt_file_not_found_raises -v
```

Expected: FAIL (TypeError / missing parameter)

- [ ] **Step 1.3: Implement `--prompt-file` in `run.py`**

Add import at top of `src/vibepod/commands/run.py`:
```python
# (Path is already imported)
```

Add the flag to the `run()` function signature (after `ikwid`):

```python
prompt_file: Annotated[
    Path | None,
    typer.Option("--prompt-file", help="Prompt file to mount read-only into the container"),
] = None,
```

Add validation and volume/env injection after the `paste_images` block (before `if proxy_enabled:`):

```python
if prompt_file is not None:
    prompt_file = prompt_file.expanduser().resolve()
    if not prompt_file.exists():
        raise typer.BadParameter(f"Prompt file not found: {prompt_file}")
    extra_volumes.append((str(prompt_file), "/vibepod-prompt.md", "ro"))
    merged_env["VP_PROMPT_FILE"] = "/vibepod-prompt.md"
```

- [ ] **Step 1.4: Run tests to confirm they pass**

```bash
cd /workspace && python -m pytest tests/test_run.py::test_prompt_file_mounts_and_sets_env tests/test_run.py::test_prompt_file_none_does_not_mount tests/test_run.py::test_prompt_file_not_found_raises -v
```

Expected: PASS

- [ ] **Step 1.5: Run full test suite to check for regressions**

```bash
cd /workspace && python -m pytest tests/ -v
```

Expected: all tests pass

- [ ] **Step 1.6: Commit**

```bash
git add src/vibepod/commands/run.py tests/test_run.py
git commit -m "feat: add --prompt-file flag to vp run"
```

---

## Chunk 2: PLAN.md data model and I/O

**Files:**
- Create: `src/vibepod/core/orchestrate/__init__.py`
- Create: `src/vibepod/core/orchestrate/plan_file.py`
- Create: `tests/test_orchestrate_plan_file.py`

- [ ] **Step 2.1: Create package init**

Create `src/vibepod/core/orchestrate/__init__.py` as an empty file.

- [ ] **Step 2.2: Write failing tests for `OrchestrateTask` and `OrchestratePlan`**

Create `tests/test_orchestrate_plan_file.py`:

```python
"""Tests for PLAN.md data model, parsing and writing."""

from __future__ import annotations

from pathlib import Path

import pytest

from vibepod.core.orchestrate.plan_file import (
    OrchestratePlan,
    OrchestrateTask,
    parse_plan,
    write_plan,
    update_task_status,
)


SAMPLE_PLAN_MD = """\
# VibePod Orchestration Plan

## Meta
- **run-id**: abc12345
- **created**: 2026-03-22T10:00:00Z

## Task
Build a login feature with JWT auth and a React UI.

## Sub-tasks

### [TASK-1] Implement JWT authentication
- **persona**: backend
- **agent**: codex (fallback: claude)
- **worktree**: .vibepod/worktrees/task-1
- **prompt-file**: .vibepod/tasks/task-1.md
- **depends-on**: none
- **status**: pending

### [TASK-2] Build login UI
- **persona**: frontend
- **agent**: claude (fallback: codex)
- **worktree**: .vibepod/worktrees/task-2
- **prompt-file**: .vibepod/tasks/task-2.md
- **depends-on**: TASK-1
- **status**: pending
"""


def test_parse_plan_meta():
    plan = parse_plan(SAMPLE_PLAN_MD)
    assert plan.run_id == "abc12345"
    assert plan.created == "2026-03-22T10:00:00Z"
    assert plan.task_description == "Build a login feature with JWT auth and a React UI."


def test_parse_plan_task_count():
    plan = parse_plan(SAMPLE_PLAN_MD)
    assert len(plan.tasks) == 2


def test_parse_plan_task_fields():
    plan = parse_plan(SAMPLE_PLAN_MD)
    t1 = plan.tasks[0]
    assert t1.id == "TASK-1"
    assert t1.title == "Implement JWT authentication"
    assert t1.persona == "backend"
    assert t1.agent == "codex"
    assert t1.fallback == "claude"
    assert t1.worktree == ".vibepod/worktrees/task-1"
    assert t1.prompt_file == ".vibepod/tasks/task-1.md"
    assert t1.depends_on == []
    assert t1.status == "pending"


def test_parse_plan_task_dependency():
    plan = parse_plan(SAMPLE_PLAN_MD)
    t2 = plan.tasks[1]
    assert t2.depends_on == ["TASK-1"]


def test_parse_plan_multiple_dependencies():
    md = SAMPLE_PLAN_MD.replace("- **depends-on**: TASK-1\n", "- **depends-on**: TASK-1, TASK-2\n")
    plan = parse_plan(md)
    assert plan.tasks[1].depends_on == ["TASK-1", "TASK-2"]


def test_write_plan_roundtrip(tmp_path: Path):
    plan = parse_plan(SAMPLE_PLAN_MD)
    output_path = tmp_path / "PLAN.md"
    write_plan(plan, output_path)
    content = output_path.read_text()
    plan2 = parse_plan(content)
    assert plan2.run_id == plan.run_id
    assert len(plan2.tasks) == len(plan.tasks)
    assert plan2.tasks[0].id == "TASK-1"
    assert plan2.tasks[1].depends_on == ["TASK-1"]


def test_update_task_status(tmp_path: Path):
    plan_path = tmp_path / "PLAN.md"
    plan_path.write_text(SAMPLE_PLAN_MD)
    update_task_status(plan_path, "TASK-1", "running")
    content = plan_path.read_text()
    assert "- **status**: running" in content
    # Other tasks must be unchanged
    plan2 = parse_plan(content)
    assert plan2.tasks[1].status == "pending"


def test_update_task_status_unknown_id(tmp_path: Path):
    plan_path = tmp_path / "PLAN.md"
    plan_path.write_text(SAMPLE_PLAN_MD)
    with pytest.raises(ValueError, match="TASK-99"):
        update_task_status(plan_path, "TASK-99", "running")
```

- [ ] **Step 2.3: Run tests to confirm they fail**

```bash
cd /workspace && python -m pytest tests/test_orchestrate_plan_file.py -v
```

Expected: FAIL (ModuleNotFoundError)

- [ ] **Step 2.4: Implement `plan_file.py`**

Create `src/vibepod/core/orchestrate/plan_file.py`:

```python
"""PLAN.md data model and I/O."""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path


@dataclass
class OrchestrateTask:
    id: str
    title: str
    persona: str
    agent: str
    fallback: str | None
    worktree: str
    prompt_file: str
    depends_on: list[str]
    status: str  # pending | waiting | running | done | failed | blocked


@dataclass
class OrchestratePlan:
    run_id: str
    created: str
    task_description: str
    tasks: list[OrchestrateTask] = field(default_factory=list)


def _parse_field(line: str, name: str) -> str | None:
    """Extract value from a '- **name**: value' line."""
    pattern = rf"^\s*-\s+\*\*{re.escape(name)}\*\*:\s*(.+)$"
    m = re.match(pattern, line)
    return m.group(1).strip() if m else None


def parse_plan(content: str) -> OrchestratePlan:
    """Parse PLAN.md content into an OrchestratePlan."""
    lines = content.splitlines()

    run_id = ""
    created = ""
    task_description_lines: list[str] = []
    tasks: list[OrchestrateTask] = []

    section = ""
    i = 0
    while i < len(lines):
        line = lines[i]

        if line.strip() == "## Meta":
            section = "meta"
        elif line.strip() == "## Task":
            section = "task"
        elif line.strip() == "## Sub-tasks":
            section = "subtasks"
        elif line.startswith("### [") and section == "subtasks":
            # Parse task header: ### [TASK-1] Title
            m = re.match(r"^### \[([A-Z0-9_-]+)\]\s+(.+)$", line)
            if m:
                task_id = m.group(1)
                title = m.group(2).strip()
                persona = ""
                agent = ""
                fallback: str | None = None
                worktree = ""
                prompt_file = ""
                depends_on: list[str] = []
                status = "pending"

                i += 1
                while i < len(lines) and not lines[i].startswith("###"):
                    l = lines[i]
                    if v := _parse_field(l, "persona"):
                        persona = v
                    elif v := _parse_field(l, "agent"):
                        # Format: "codex (fallback: claude)" or "codex"
                        fm = re.match(r"^(\S+)\s+\(fallback:\s*(\S+)\)$", v)
                        if fm:
                            agent = fm.group(1)
                            fallback = fm.group(2)
                        else:
                            agent = v
                    elif v := _parse_field(l, "worktree"):
                        worktree = v
                    elif v := _parse_field(l, "prompt-file"):
                        prompt_file = v
                    elif v := _parse_field(l, "depends-on"):
                        if v.lower() != "none":
                            depends_on = [d.strip() for d in v.split(",")]
                    elif v := _parse_field(l, "status"):
                        status = v
                    i += 1

                tasks.append(OrchestrateTask(
                    id=task_id,
                    title=title,
                    persona=persona,
                    agent=agent,
                    fallback=fallback,
                    worktree=worktree,
                    prompt_file=prompt_file,
                    depends_on=depends_on,
                    status=status,
                ))
                continue
        elif section == "meta":
            if v := _parse_field(line, "run-id"):
                run_id = v
            elif v := _parse_field(line, "created"):
                created = v
        elif section == "task" and line.strip() and not line.startswith("#"):
            task_description_lines.append(line.strip())

        i += 1

    return OrchestratePlan(
        run_id=run_id,
        created=created,
        task_description=" ".join(task_description_lines),
        tasks=tasks,
    )


def write_plan(plan: OrchestratePlan, path: Path) -> None:
    """Write an OrchestratePlan to a PLAN.md file."""
    lines = [
        "# VibePod Orchestration Plan",
        "",
        "## Meta",
        f"- **run-id**: {plan.run_id}",
        f"- **created**: {plan.created}",
        "",
        "## Task",
        plan.task_description,
        "",
        "## Sub-tasks",
        "",
    ]

    for task in plan.tasks:
        agent_str = task.agent
        if task.fallback:
            agent_str = f"{task.agent} (fallback: {task.fallback})"
        depends_str = ", ".join(task.depends_on) if task.depends_on else "none"

        lines += [
            f"### [{task.id}] {task.title}",
            f"- **persona**: {task.persona}",
            f"- **agent**: {agent_str}",
            f"- **worktree**: {task.worktree}",
            f"- **prompt-file**: {task.prompt_file}",
            f"- **depends-on**: {depends_str}",
            f"- **status**: {task.status}",
            "",
        ]

    path.write_text("\n".join(lines), encoding="utf-8")


def update_task_status(plan_path: Path, task_id: str, status: str) -> None:
    """Update a single task's status in PLAN.md in-place."""
    plan = parse_plan(plan_path.read_text(encoding="utf-8"))

    found = False
    for task in plan.tasks:
        if task.id == task_id:
            task.status = status
            found = True
            break

    if not found:
        raise ValueError(f"Task {task_id} not found in plan")

    write_plan(plan, plan_path)


def new_plan(task_description: str, run_id: str | None = None) -> OrchestratePlan:
    """Create a new empty OrchestratePlan with generated run-id and timestamp."""
    import uuid
    rid = run_id or uuid.uuid4().hex[:8]
    created = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    return OrchestratePlan(run_id=rid, created=created, task_description=task_description)
```

- [ ] **Step 2.5: Run tests to confirm they pass**

```bash
cd /workspace && python -m pytest tests/test_orchestrate_plan_file.py -v
```

Expected: all pass

- [ ] **Step 2.6: Commit**

```bash
git add src/vibepod/core/orchestrate/ tests/test_orchestrate_plan_file.py
git commit -m "feat: add PLAN.md data model and I/O"
```

---

## Chunk 3: Prompt template generation

**Files:**
- Create: `src/vibepod/core/orchestrate/prompts.py`
- Create: `tests/test_orchestrate_prompts.py`

- [ ] **Step 3.1: Write failing tests**

Create `tests/test_orchestrate_prompts.py`:

```python
"""Tests for orchestrate prompt template generation."""

from __future__ import annotations

from vibepod.core.orchestrate.plan_file import OrchestrateTask, OrchestratePlan
from vibepod.core.orchestrate.prompts import (
    build_planning_prompt,
    build_merge_prompt,
    COMPLETION_INSTRUCTION,
)


PERSONAS = {
    "backend": {
        "preferred": "codex",
        "fallback": "claude",
        "description": "Implements APIs and server logic",
        "capabilities": ["REST APIs", "Database design"],
    },
    "frontend": {
        "preferred": "claude",
        "fallback": "codex",
        "description": "Builds UI components",
        "capabilities": ["React components", "CSS"],
    },
}

PLAN = OrchestratePlan(
    run_id="abc12345",
    created="2026-03-22T10:00:00Z",
    task_description="Build a login feature",
    tasks=[
        OrchestrateTask(
            id="TASK-1",
            title="JWT auth",
            persona="backend",
            agent="codex",
            fallback="claude",
            worktree=".vibepod/worktrees/task-1",
            prompt_file=".vibepod/tasks/task-1.md",
            depends_on=[],
            status="done",
        ),
    ],
)


def test_planning_prompt_contains_task():
    prompt = build_planning_prompt("Build a login feature", PERSONAS, run_id="abc12345")
    assert "Build a login feature" in prompt


def test_planning_prompt_contains_personas():
    prompt = build_planning_prompt("Build a login feature", PERSONAS, run_id="abc12345")
    assert "backend" in prompt
    assert "Implements APIs and server logic" in prompt
    assert "REST APIs" in prompt
    assert "frontend" in prompt


def test_planning_prompt_contains_plan_format():
    prompt = build_planning_prompt("Build a login feature", PERSONAS, run_id="abc12345")
    assert "PLAN.md" in prompt
    assert "## Sub-tasks" in prompt
    assert "run-id" in prompt
    assert "abc12345" in prompt


def test_planning_prompt_contains_completion_instruction():
    prompt = build_planning_prompt("Build a login feature", PERSONAS, run_id="abc12345")
    assert COMPLETION_INSTRUCTION in prompt


def test_merge_prompt_contains_task_description():
    prompt = build_merge_prompt(PLAN, workspace=".vibepod/worktrees")
    assert "Build a login feature" in prompt


def test_merge_prompt_contains_branch_names():
    prompt = build_merge_prompt(PLAN, workspace=".vibepod/worktrees")
    assert "orchestrate/task-1-abc12345" in prompt


def test_merge_prompt_contains_summaries():
    plan_with_summary = OrchestratePlan(
        run_id="abc12345",
        created="2026-03-22T10:00:00Z",
        task_description="Build a login feature",
        tasks=[
            OrchestrateTask(
                id="TASK-1",
                title="JWT auth",
                persona="backend",
                agent="codex",
                fallback="claude",
                worktree=".vibepod/worktrees/task-1",
                prompt_file=".vibepod/tasks/task-1.md",
                depends_on=[],
                status="done",
            ),
        ],
    )
    # Add summary via extra metadata (stored as task.summary attribute added at runtime)
    plan_with_summary.tasks[0].summary = "Implemented JWT with refresh tokens"  # type: ignore[attr-defined]
    prompt = build_merge_prompt(plan_with_summary, workspace=".vibepod/worktrees")
    assert "Implemented JWT with refresh tokens" in prompt
```

- [ ] **Step 3.2: Run tests to confirm they fail**

```bash
cd /workspace && python -m pytest tests/test_orchestrate_prompts.py -v
```

Expected: FAIL (ModuleNotFoundError)

- [ ] **Step 3.3: Implement `prompts.py`**

Create `src/vibepod/core/orchestrate/prompts.py`:

```python
"""Prompt template generation for orchestrate plan and merge phases."""

from __future__ import annotations

from vibepod.core.orchestrate.plan_file import OrchestratePlan

COMPLETION_INSTRUCTION = """\
When you have finished all your work, write the following JSON to the file \
at the path stored in the VP_STATUS_FILE environment variable:

  {"status": "done", "summary": "<one sentence summary>", "files_changed": ["path/to/file", ...]}

If you cannot complete the task, write:

  {"status": "failed", "reason": "<why it failed>"}

Do not exit or stop until you have written this file."""

_PLAN_FORMAT_EXAMPLE = """\
# VibePod Orchestration Plan

## Meta
- **run-id**: {run_id}
- **created**: <ISO timestamp>

## Task
<original task description>

## Sub-tasks

### [TASK-1] <Sub-task title>
- **persona**: <persona name>
- **agent**: <agent> (fallback: <fallback agent>)
- **worktree**: .vibepod/worktrees/task-1
- **prompt-file**: .vibepod/tasks/task-1.md
- **depends-on**: none
- **status**: pending

### [TASK-2] <Sub-task title>
- **persona**: <persona name>
- **agent**: <agent> (fallback: <fallback agent>)
- **worktree**: .vibepod/worktrees/task-2
- **prompt-file**: .vibepod/tasks/task-2.md
- **depends-on**: TASK-1
- **status**: pending"""


def build_planning_prompt(
    task: str,
    personas: dict,
    run_id: str,
) -> str:
    """Build the planning prompt for the controller agent."""
    persona_lines = []
    for name, cfg in personas.items():
        desc = cfg.get("description", "")
        caps = cfg.get("capabilities", [])
        preferred = cfg.get("preferred", "")
        fallback = cfg.get("fallback", "")
        fb_str = f" (fallback: {fallback})" if fallback else ""
        caps_str = "\n".join(f"  - {c}" for c in caps)
        persona_lines.append(
            f"**{name}** — preferred agent: {preferred}{fb_str}\n"
            f"  {desc}\n{caps_str}"
        )

    persona_block = "\n\n".join(persona_lines)
    plan_example = _PLAN_FORMAT_EXAMPLE.format(run_id=run_id)

    return f"""\
You are a software engineering orchestrator. Your job is to break down the \
following task into sub-tasks and assign each sub-task to the most suitable \
persona from the list below. Then write a PLAN.md file.

## Task
{task}

## Available Personas
{persona_block}

## Instructions

1. Analyse the task and identify the independent sub-tasks required.
2. For each sub-task, pick the best-fitting persona from the list above.
3. Identify dependencies between sub-tasks (which must complete before others can start).
4. For each sub-task, write a detailed task prompt file to \
`.vibepod/tasks/task-N.md`. Each task prompt must end with the following \
completion instruction exactly as written:

{COMPLETION_INSTRUCTION}

5. Write `.vibepod/PLAN.md` using exactly the following format \
(replace placeholder text; do not change field names or markdown structure):

{plan_example}

Use `depends-on: none` if a task has no dependencies. For multiple \
dependencies, use comma-separated task IDs (e.g. `TASK-1, TASK-2`).

Write all files now. Do not ask questions. Start immediately.
"""


def build_merge_prompt(plan: OrchestratePlan, workspace: str) -> str:
    """Build the merge prompt for the controller agent."""
    task_lines = []
    for task in plan.tasks:
        if task.status != "done":
            continue
        branch = f"orchestrate/{task.id.lower()}-{plan.run_id}"
        summary = getattr(task, "summary", "")
        summary_line = f" — {summary}" if summary else ""
        task_lines.append(f"- **{task.id}** ({branch}): {task.title}{summary_line}")

    tasks_block = "\n".join(task_lines)

    return f"""\
You are a software engineering orchestrator. Multiple worker agents have \
completed sub-tasks on separate git branches. Your job is to merge all \
branches into the current branch to produce a coherent result.

## Original Task
{plan.task_description}

## Completed Sub-tasks
{tasks_block}

## Instructions

1. For each branch listed above, run `git merge <branch>` to merge it into \
the current branch.
2. Resolve any merge conflicts that arise. Prefer keeping the intent of \
both changes where possible.
3. After all merges are complete, verify the codebase is in a consistent \
state (check imports, obvious syntax errors, etc.).
4. If you cannot resolve a conflict, leave the conflict markers in place, \
write a brief note about the conflict to `.vibepod/merge-conflicts.md`, \
and continue with the remaining branches.
5. Commit after each successful merge with a message like \
`merge: integrate TASK-N results`.

Begin merging now. Do not ask questions.
"""
```

- [ ] **Step 3.4: Run tests to confirm they pass**

```bash
cd /workspace && python -m pytest tests/test_orchestrate_prompts.py -v
```

Expected: all pass

- [ ] **Step 3.5: Commit**

```bash
git add src/vibepod/core/orchestrate/prompts.py tests/test_orchestrate_prompts.py
git commit -m "feat: add orchestrate prompt template generation"
```

---

## Chunk 4: Git worktree management

**Files:**
- Create: `src/vibepod/core/orchestrate/worktree.py`
- Create: `tests/test_orchestrate_worktree.py`

- [ ] **Step 4.1: Write failing tests**

Create `tests/test_orchestrate_worktree.py`:

```python
"""Tests for git worktree operations."""

from __future__ import annotations

import subprocess
from pathlib import Path

import pytest

from vibepod.core.orchestrate.worktree import (
    create_worktree,
    reset_worktree,
    delete_worktree,
    worktree_branch_name,
)


def _init_git_repo(path: Path) -> None:
    """Initialize a git repo with an initial commit."""
    subprocess.run(["git", "init", str(path)], check=True, capture_output=True)
    subprocess.run(["git", "-C", str(path), "config", "user.email", "test@test.com"], check=True, capture_output=True)
    subprocess.run(["git", "-C", str(path), "config", "user.name", "Test"], check=True, capture_output=True)
    (path / "README.md").write_text("hello")
    subprocess.run(["git", "-C", str(path), "add", "."], check=True, capture_output=True)
    subprocess.run(["git", "-C", str(path), "commit", "-m", "init"], check=True, capture_output=True)


def test_worktree_branch_name():
    assert worktree_branch_name("TASK-1", "abc123") == "orchestrate/task-1-abc123"
    assert worktree_branch_name("TASK-42", "xyz") == "orchestrate/task-42-xyz"


def test_create_worktree(tmp_path: Path):
    repo = tmp_path / "repo"
    repo.mkdir()
    _init_git_repo(repo)

    worktree_path = tmp_path / "wt"
    create_worktree(repo, worktree_path, branch="orchestrate/task-1-abc123")

    assert worktree_path.exists()
    assert (worktree_path / "README.md").exists()


def test_create_worktree_creates_branch(tmp_path: Path):
    repo = tmp_path / "repo"
    repo.mkdir()
    _init_git_repo(repo)

    worktree_path = tmp_path / "wt"
    create_worktree(repo, worktree_path, branch="orchestrate/task-1-abc123")

    result = subprocess.run(
        ["git", "-C", str(repo), "branch", "--list", "orchestrate/task-1-abc123"],
        capture_output=True, text=True, check=True,
    )
    assert "orchestrate/task-1-abc123" in result.stdout


def test_reset_worktree_removes_changes(tmp_path: Path):
    repo = tmp_path / "repo"
    repo.mkdir()
    _init_git_repo(repo)

    worktree_path = tmp_path / "wt"
    create_worktree(repo, worktree_path, branch="orchestrate/task-1-abc123")

    # Make changes in worktree
    (worktree_path / "new_file.py").write_text("print('hello')")
    (worktree_path / "README.md").write_text("modified")

    reset_worktree(worktree_path)

    assert not (worktree_path / "new_file.py").exists()
    assert (worktree_path / "README.md").read_text() == "hello"


def test_delete_worktree(tmp_path: Path):
    repo = tmp_path / "repo"
    repo.mkdir()
    _init_git_repo(repo)

    worktree_path = tmp_path / "wt"
    create_worktree(repo, worktree_path, branch="orchestrate/task-1-abc123")
    delete_worktree(repo, worktree_path, branch="orchestrate/task-1-abc123")

    assert not worktree_path.exists()


def test_delete_worktree_removes_branch(tmp_path: Path):
    repo = tmp_path / "repo"
    repo.mkdir()
    _init_git_repo(repo)

    worktree_path = tmp_path / "wt"
    create_worktree(repo, worktree_path, branch="orchestrate/task-1-abc123")
    delete_worktree(repo, worktree_path, branch="orchestrate/task-1-abc123")

    result = subprocess.run(
        ["git", "-C", str(repo), "branch", "--list", "orchestrate/task-1-abc123"],
        capture_output=True, text=True, check=True,
    )
    assert "orchestrate/task-1-abc123" not in result.stdout
```

- [ ] **Step 4.2: Run tests to confirm they fail**

```bash
cd /workspace && python -m pytest tests/test_orchestrate_worktree.py -v
```

Expected: FAIL (ModuleNotFoundError)

- [ ] **Step 4.3: Implement `worktree.py`**

Create `src/vibepod/core/orchestrate/worktree.py`:

```python
"""Git worktree operations for orchestrate workers."""

from __future__ import annotations

import subprocess
from pathlib import Path


class WorktreeError(RuntimeError):
    """Raised when a git worktree operation fails."""


def _git(args: list[str], cwd: Path) -> str:
    result = subprocess.run(
        ["git", *args],
        cwd=str(cwd),
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        raise WorktreeError(
            f"git {' '.join(args)} failed in {cwd}:\n{result.stderr.strip()}"
        )
    return result.stdout.strip()


def worktree_branch_name(task_id: str, run_id: str) -> str:
    """Return the git branch name for a task worktree."""
    return f"orchestrate/{task_id.lower()}-{run_id}"


def create_worktree(repo: Path, worktree_path: Path, branch: str) -> None:
    """Create a new git worktree at worktree_path on a new branch."""
    worktree_path.parent.mkdir(parents=True, exist_ok=True)
    _git(["worktree", "add", "-b", branch, str(worktree_path)], cwd=repo)


def reset_worktree(worktree_path: Path) -> None:
    """Reset worktree to HEAD: discard tracked changes and untracked files."""
    _git(["checkout", "."], cwd=worktree_path)
    _git(["clean", "-fd"], cwd=worktree_path)


def delete_worktree(repo: Path, worktree_path: Path, branch: str) -> None:
    """Remove a worktree and delete its branch."""
    try:
        _git(["worktree", "remove", "--force", str(worktree_path)], cwd=repo)
    except WorktreeError:
        pass  # already gone

    try:
        _git(["branch", "-D", branch], cwd=repo)
    except WorktreeError:
        pass  # branch already deleted
```

- [ ] **Step 4.4: Run tests to confirm they pass**

```bash
cd /workspace && python -m pytest tests/test_orchestrate_worktree.py -v
```

Expected: all pass

- [ ] **Step 4.5: Commit**

```bash
git add src/vibepod/core/orchestrate/worktree.py tests/test_orchestrate_worktree.py
git commit -m "feat: add git worktree management for orchestrate"
```

---

## Chunk 5: Worker lifecycle

**Files:**
- Create: `src/vibepod/core/orchestrate/worker.py`
- Create: `tests/test_orchestrate_worker.py`

- [ ] **Step 5.1: Write failing tests**

Create `tests/test_orchestrate_worker.py`:

```python
"""Tests for worker spawn, status polling, fallback retry, and re-entrancy."""

from __future__ import annotations

import json
from pathlib import Path

import pytest

from vibepod.core.orchestrate.worker import (
    read_status_file,
    StatusResult,
    tasks_ready_to_run,
)
from vibepod.core.orchestrate.plan_file import OrchestrateTask, OrchestratePlan


def _make_task(
    id: str,
    status: str = "pending",
    depends_on: list[str] | None = None,
    fallback: str | None = "claude",
) -> OrchestrateTask:
    return OrchestrateTask(
        id=id,
        title=f"Task {id}",
        persona="backend",
        agent="codex",
        fallback=fallback,
        worktree=f".vibepod/worktrees/{id.lower()}",
        prompt_file=f".vibepod/tasks/{id.lower()}.md",
        depends_on=depends_on or [],
        status=status,
    )


# --- read_status_file ---

def test_read_status_file_done(tmp_path: Path):
    f = tmp_path / "status.json"
    f.write_text(json.dumps({"status": "done", "summary": "All good", "files_changed": []}))
    result = read_status_file(f)
    assert result.status == "done"
    assert result.summary == "All good"


def test_read_status_file_failed(tmp_path: Path):
    f = tmp_path / "status.json"
    f.write_text(json.dumps({"status": "failed", "reason": "Broke"}))
    result = read_status_file(f)
    assert result.status == "failed"
    assert result.reason == "Broke"


def test_read_status_file_missing_returns_none(tmp_path: Path):
    result = read_status_file(tmp_path / "missing.json")
    assert result is None


def test_read_status_file_malformed_returns_none(tmp_path: Path):
    f = tmp_path / "status.json"
    f.write_text("not json {{")
    result = read_status_file(f)
    assert result is None


def test_read_status_file_missing_status_key_returns_none(tmp_path: Path):
    f = tmp_path / "status.json"
    f.write_text(json.dumps({"summary": "something"}))
    result = read_status_file(f)
    assert result is None


# --- tasks_ready_to_run ---

def test_tasks_ready_no_deps():
    plan = OrchestratePlan(run_id="x", created="", task_description="t", tasks=[
        _make_task("TASK-1", status="pending"),
        _make_task("TASK-2", status="pending"),
    ])
    ready = tasks_ready_to_run(plan)
    assert {t.id for t in ready} == {"TASK-1", "TASK-2"}


def test_tasks_ready_respects_deps():
    plan = OrchestratePlan(run_id="x", created="", task_description="t", tasks=[
        _make_task("TASK-1", status="done"),
        _make_task("TASK-2", status="pending", depends_on=["TASK-1"]),
        _make_task("TASK-3", status="pending", depends_on=["TASK-2"]),
    ])
    ready = tasks_ready_to_run(plan)
    assert {t.id for t in ready} == {"TASK-2"}


def test_tasks_ready_skips_running():
    plan = OrchestratePlan(run_id="x", created="", task_description="t", tasks=[
        _make_task("TASK-1", status="running"),
        _make_task("TASK-2", status="pending"),
    ])
    ready = tasks_ready_to_run(plan)
    assert {t.id for t in ready} == {"TASK-2"}


def test_tasks_ready_blocked_if_dep_failed():
    plan = OrchestratePlan(run_id="x", created="", task_description="t", tasks=[
        _make_task("TASK-1", status="failed"),
        _make_task("TASK-2", status="pending", depends_on=["TASK-1"]),
    ])
    ready = tasks_ready_to_run(plan)
    assert ready == []


def test_tasks_ready_waiting_promoted_when_dep_done():
    plan = OrchestratePlan(run_id="x", created="", task_description="t", tasks=[
        _make_task("TASK-1", status="done"),
        _make_task("TASK-2", status="waiting", depends_on=["TASK-1"]),
    ])
    ready = tasks_ready_to_run(plan)
    assert {t.id for t in ready} == {"TASK-2"}
```

- [ ] **Step 5.2: Run tests to confirm they fail**

```bash
cd /workspace && python -m pytest tests/test_orchestrate_worker.py -v
```

Expected: FAIL (ModuleNotFoundError)

- [ ] **Step 5.3: Implement `worker.py`**

Create `src/vibepod/core/orchestrate/worker.py`:

```python
"""Worker lifecycle: spawn, status polling, fallback retry, re-entrancy."""

from __future__ import annotations

import json
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from vibepod.core.orchestrate.plan_file import OrchestratePlan, OrchestrateTask


@dataclass
class StatusResult:
    status: str          # "done" | "failed"
    summary: str = ""
    reason: str = ""
    files_changed: list[str] | None = None


def read_status_file(path: Path) -> StatusResult | None:
    """Read and validate a worker status JSON file.

    Returns None if the file is missing, unreadable, or malformed.
    """
    if not path.exists():
        return None
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return None

    status = data.get("status")
    if status not in ("done", "failed"):
        return None

    return StatusResult(
        status=status,
        summary=str(data.get("summary", "")),
        reason=str(data.get("reason", "")),
        files_changed=data.get("files_changed"),
    )


def _all_deps_done(task: OrchestrateTask, plan: OrchestratePlan) -> bool:
    """Return True when all dependencies of task are in 'done' status."""
    status_by_id = {t.id: t.status for t in plan.tasks}
    return all(status_by_id.get(dep) == "done" for dep in task.depends_on)


def _any_dep_failed(task: OrchestrateTask, plan: OrchestratePlan) -> bool:
    """Return True when any dependency of task is in 'failed' or 'blocked' status."""
    status_by_id = {t.id: t.status for t in plan.tasks}
    return any(
        status_by_id.get(dep) in ("failed", "blocked") for dep in task.depends_on
    )


def tasks_ready_to_run(plan: OrchestratePlan) -> list[OrchestrateTask]:
    """Return tasks that are eligible to start (deps met, not already running/done)."""
    ready = []
    for task in plan.tasks:
        if task.status not in ("pending", "waiting"):
            continue
        if _any_dep_failed(task, plan):
            continue
        if task.depends_on and not _all_deps_done(task, plan):
            continue
        ready.append(task)
    return ready


def poll_worker(
    status_file: Path,
    container: Any,
    poll_interval: float = 5.0,
    timeout: float = 3600.0,
) -> StatusResult | None:
    """Poll until the worker writes a status file or the container exits.

    Returns a StatusResult, or None if timed out without a result.
    poll_interval and timeout are in seconds.
    """
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        result = read_status_file(status_file)
        if result is not None:
            return result

        # Check if container is still running
        try:
            container.reload()
            if container.status not in ("running", "created"):
                # Container stopped without writing status — treat as failed
                result = read_status_file(status_file)
                if result is not None:
                    return result
                return StatusResult(status="failed", reason="container exited without status file")
        except Exception:
            return StatusResult(status="failed", reason="container unreachable")

        time.sleep(poll_interval)

    return None  # timed out
```

- [ ] **Step 5.4: Run tests to confirm they pass**

```bash
cd /workspace && python -m pytest tests/test_orchestrate_worker.py -v
```

Expected: all pass

- [ ] **Step 5.5: Commit**

```bash
git add src/vibepod/core/orchestrate/worker.py tests/test_orchestrate_worker.py
git commit -m "feat: add worker lifecycle module for orchestrate"
```

---

## Chunk 6: `DockerManager.run_agent` label support

**Must be implemented before Chunk 7**, because `orchestrate.py` calls `manager.run_agent(labels=...)` for container tracking and `run_agent` does not currently accept `labels`.

**Files:**
- Modify: `src/vibepod/core/docker.py`
- Test: `tests/test_run.py`

- [ ] **Step 6.1: Write failing test for label forwarding**

Add to `tests/test_run.py`:

```python
def test_run_agent_forwards_labels(tmp_path: Path) -> None:
    class _FakeContainers:
        def __init__(self) -> None:
            self.run_kwargs: dict | None = None
        def run(self, **kwargs):
            self.run_kwargs = kwargs
            return {"id": "agent"}

    class _FakeClient:
        def __init__(self) -> None:
            self.containers = _FakeContainers()

    manager = object.__new__(DockerManager)
    manager.client = _FakeClient()

    workspace = tmp_path / "workspace"
    config_dir = tmp_path / "agents" / "claude"
    workspace.mkdir(parents=True, exist_ok=True)
    config_dir.mkdir(parents=True, exist_ok=True)

    manager.run_agent(
        agent="claude",
        image="vibepod/claude:latest",
        workspace=workspace,
        config_dir=config_dir,
        config_mount_path="/claude",
        env={},
        command=["claude"],
        auto_remove=True,
        name=None,
        version="0.2.1",
        network="vibepod-network",
        labels={"vibepod.orchestrate.task": "TASK-1"},
    )

    run_kwargs = manager.client.containers.run_kwargs
    assert run_kwargs is not None
    assert run_kwargs.get("labels", {}).get("vibepod.orchestrate.task") == "TASK-1"
```

- [ ] **Step 6.2: Run test to confirm it fails**

```bash
cd /workspace && python -m pytest tests/test_run.py::test_run_agent_forwards_labels -v
```

Expected: FAIL (TypeError: unexpected keyword argument 'labels')

- [ ] **Step 6.3: Add `labels` parameter to `DockerManager.run_agent` in `docker.py`**

Find the `run_agent` method signature and add `labels: dict[str, str] | None = None`. Then pass it to `self.client.containers.run(... labels=labels or {})`.

- [ ] **Step 6.4: Run test to confirm it passes**

```bash
cd /workspace && python -m pytest tests/test_run.py::test_run_agent_forwards_labels -v
```

Expected: PASS

- [ ] **Step 6.5: Run full test suite**

```bash
cd /workspace && python -m pytest tests/ -v
```

Expected: all pass

- [ ] **Step 6.6: Commit**

```bash
git add src/vibepod/core/docker.py tests/test_run.py
git commit -m "feat: add labels support to DockerManager.run_agent"
```

---

## Chunk 7: `vp orchestrate` CLI commands + registration

**Files:**
- Create: `src/vibepod/commands/orchestrate.py`
- Modify: `src/vibepod/cli.py`

**Note:** The `plan`, `execute`, and `merge` commands each invoke the controller agent by running a container. This is integration-level behavior; unit tests focus on the helper functions already tested in chunks 1–5. Smoke tests for the commands themselves require Docker and are outside automated CI scope.

- [ ] **Step 7.1: Implement `orchestrate.py`**

Create `src/vibepod/commands/orchestrate.py`:

```python
"""vp orchestrate subcommands: plan, execute, merge."""

from __future__ import annotations

import time
from pathlib import Path
from typing import Annotated

import typer

from vibepod.commands.run import run as run_agent
from vibepod.core.config import get_config
from vibepod.core.docker import DockerClientError, DockerManager
from vibepod.core.orchestrate.plan_file import (
    OrchestratePlan,
    OrchestrateTask,
    new_plan,
    parse_plan,
    update_task_status,
    write_plan,
)
from vibepod.core.orchestrate.prompts import build_merge_prompt, build_planning_prompt
from vibepod.core.orchestrate.worker import (
    StatusResult,
    poll_worker,
    read_status_file,
    tasks_ready_to_run,
)
from vibepod.core.orchestrate.worktree import (
    WorktreeError,
    create_worktree,
    delete_worktree,
    reset_worktree,
    worktree_branch_name,
)
from vibepod.utils.console import error, info, success, warning

app = typer.Typer(help="Agent-to-Agent orchestration: plan, execute, merge")

_DEFAULT_PLAN_FILE = ".vibepod/PLAN.md"
_POLL_INTERVAL = 5.0
_WORKER_TIMEOUT = 3600.0


def _vibepod_dir(workspace: Path) -> Path:
    return workspace / ".vibepod"


def _status_dir(workspace: Path) -> Path:
    d = _vibepod_dir(workspace) / "status"
    d.mkdir(parents=True, exist_ok=True)
    return d


def _tasks_dir(workspace: Path) -> Path:
    d = _vibepod_dir(workspace) / "tasks"
    d.mkdir(parents=True, exist_ok=True)
    return d


def _worktrees_dir(workspace: Path) -> Path:
    return _vibepod_dir(workspace) / "worktrees"


# ---------------------------------------------------------------------------
# vp orchestrate plan
# ---------------------------------------------------------------------------

@app.command("plan")
def orchestrate_plan(
    task: Annotated[str, typer.Argument(help="Task description for the orchestrator")],
    workspace: Annotated[
        Path, typer.Option("-w", "--workspace", help="Workspace directory")
    ] = Path("."),
    plan_file: Annotated[
        Path, typer.Option("--plan-file", help="Override PLAN.md location")
    ] = Path(_DEFAULT_PLAN_FILE),
    force: Annotated[
        bool, typer.Option("--force", help="Overwrite existing PLAN.md without prompting")
    ] = False,
) -> None:
    """Generate a PLAN.md by running the controller agent on the given task."""
    workspace = workspace.expanduser().resolve()
    plan_path = (workspace / plan_file) if not plan_file.is_absolute() else plan_file

    if plan_path.exists() and not force:
        if not typer.confirm(f"{plan_path} already exists. Overwrite?", default=False):
            raise typer.Exit(0)

    config = get_config()
    orch_cfg = config.get("orchestrate", {})
    controller = str(orch_cfg.get("controller", config.get("default_agent", "claude")))
    personas: dict = orch_cfg.get("personas", {})

    if not personas:
        error("No personas defined in config. Add orchestrate.personas to config.yaml.")
        raise typer.Exit(1)

    plan = new_plan(task)
    plan_path.parent.mkdir(parents=True, exist_ok=True)

    # Write planning prompt to .vibepod/planning-prompt.md
    planning_prompt_path = workspace / ".vibepod" / "planning-prompt.md"
    planning_prompt_path.parent.mkdir(parents=True, exist_ok=True)
    planning_prompt_path.write_text(
        build_planning_prompt(task, personas, run_id=plan.run_id),
        encoding="utf-8",
    )

    info(f"Running controller agent ({controller}) to generate plan...")
    try:
        run_agent(
            agent=controller,
            workspace=workspace,
            detach=False,
            ikwid=True,
            prompt_file=planning_prompt_path,
        )
    except SystemExit:
        pass  # Typer raises SystemExit on exit; container may have completed normally

    if not plan_path.exists():
        error(f"Controller agent did not produce {plan_path}. Check agent output above.")
        raise typer.Exit(1)

    success(f"Plan written to {plan_path}")
    info("Review and edit the plan, then run: vp orchestrate execute")


# ---------------------------------------------------------------------------
# vp orchestrate execute
# ---------------------------------------------------------------------------

@app.command("execute")
def orchestrate_execute(
    workspace: Annotated[
        Path, typer.Option("-w", "--workspace", help="Workspace directory")
    ] = Path("."),
    plan_file: Annotated[
        Path, typer.Option("--plan-file", help="Override PLAN.md location")
    ] = Path(_DEFAULT_PLAN_FILE),
) -> None:
    """Read PLAN.md, spawn worker agents, monitor until all tasks complete."""
    workspace = workspace.expanduser().resolve()
    plan_path = (workspace / plan_file) if not plan_file.is_absolute() else plan_file

    if not plan_path.exists():
        error(f"Plan file not found: {plan_path}. Run: vp orchestrate plan <task>")
        raise typer.Exit(1)

    config = get_config()
    orch_cfg = config.get("orchestrate", {})
    max_parallel: int = int(orch_cfg.get("max_parallel", 4))

    try:
        manager = DockerManager()
    except DockerClientError as exc:
        error(str(exc))
        raise typer.Exit(1) from exc

    plan = parse_plan(plan_path.read_text(encoding="utf-8"))

    # Re-entrancy: reset running tasks whose containers are gone
    _recheck_running_tasks(plan, plan_path, manager)

    # Mark blocked tasks and waiting tasks
    _mark_blocked_tasks(plan, plan_path)
    _mark_waiting_tasks(plan, plan_path)

    status_dir = _status_dir(workspace)
    worktrees_base = _worktrees_dir(workspace)
    active: dict[str, object] = {}  # task_id -> container

    info(f"Executing plan (run-id: {plan.run_id}, max_parallel: {max_parallel})")

    while True:
        plan = parse_plan(plan_path.read_text(encoding="utf-8"))

        # Check active workers for completion
        for task_id in list(active.keys()):
            container = active[task_id]
            status_file = status_dir / f"{task_id.lower()}.json"
            result = read_status_file(status_file)
            if result is None:
                try:
                    container.reload()  # type: ignore[union-attr]
                    if container.status not in ("running", "created"):  # type: ignore[union-attr]
                        result = StatusResult(status="failed", reason="container exited without status")
                except Exception:
                    result = StatusResult(status="failed", reason="container unreachable")

            if result is not None:
                _handle_task_result(
                    task_id=task_id,
                    result=result,
                    plan=plan,
                    plan_path=plan_path,
                    workspace=workspace,
                    config=config,
                    manager=manager,
                    active=active,
                    status_dir=status_dir,
                    worktrees_base=worktrees_base,
                )
                del active[task_id]

        # Mark any newly blocked or waiting tasks
        plan = parse_plan(plan_path.read_text(encoding="utf-8"))
        _mark_blocked_tasks(plan, plan_path)
        _mark_waiting_tasks(plan, plan_path)
        plan = parse_plan(plan_path.read_text(encoding="utf-8"))

        # Spawn ready tasks (up to max_parallel)
        available_slots = max_parallel - len(active)
        if available_slots > 0:
            ready = tasks_ready_to_run(plan)
            for task in ready[:available_slots]:
                _spawn_worker(task, plan, workspace, config, manager, status_dir, worktrees_base, active, plan_path)

        # Check if all terminal
        plan = parse_plan(plan_path.read_text(encoding="utf-8"))
        terminal = {"done", "failed", "blocked"}
        if all(t.status in terminal for t in plan.tasks) and not active:
            break

        time.sleep(_POLL_INTERVAL)

    _print_execute_summary(plan)
    info("Run: vp orchestrate merge")


def _recheck_running_tasks(plan: OrchestratePlan, plan_path: Path, manager: DockerManager) -> None:
    """Reset tasks marked 'running' whose containers are no longer alive."""
    for task in plan.tasks:
        if task.status != "running":
            continue
        # Try to find the container by label
        try:
            containers = manager.client.containers.list(
                filters={"label": f"vibepod.orchestrate.task={task.id}"}
            )
            if containers:
                continue  # still running
        except Exception:
            pass
        # Container gone — reset to pending
        update_task_status(plan_path, task.id, "pending")
        warning(f"{task.id}: container not found, reset to pending")


def _mark_blocked_tasks(plan: OrchestratePlan, plan_path: Path) -> None:
    """Mark tasks as blocked if any dependency failed."""
    from vibepod.core.orchestrate.worker import _any_dep_failed
    for task in plan.tasks:
        if task.status in ("pending", "waiting") and _any_dep_failed(task, plan):
            update_task_status(plan_path, task.id, "blocked")
            warning(f"{task.id}: blocked (dependency failed)")


def _mark_waiting_tasks(plan: OrchestratePlan, plan_path: Path) -> None:
    """Mark tasks as waiting when they have unmet dependencies but no failure."""
    from vibepod.core.orchestrate.worker import _any_dep_failed, _all_deps_done
    for task in plan.tasks:
        if task.status != "pending":
            continue
        if task.depends_on and not _all_deps_done(task, plan) and not _any_dep_failed(task, plan):
            update_task_status(plan_path, task.id, "waiting")


def _spawn_worker(
    task: OrchestrateTask,
    plan: OrchestratePlan,
    workspace: Path,
    config: dict,
    manager: DockerManager,
    status_dir: Path,
    worktrees_base: Path,
    active: dict,
    plan_path: Path,
) -> None:
    branch = worktree_branch_name(task.id, plan.run_id)
    worktree_path = worktrees_base / task.id.lower()

    # Create worktree
    try:
        create_worktree(workspace, worktree_path, branch)
    except WorktreeError as exc:
        error(f"{task.id}: failed to create worktree: {exc}")
        update_task_status(plan_path, task.id, "failed")
        return

    # Copy task prompt into worktree
    task_prompt_src = workspace / task.prompt_file
    prompt_dest = worktree_path / ".vibepod-task.md"
    if task_prompt_src.exists():
        prompt_dest.write_text(task_prompt_src.read_text(encoding="utf-8"), encoding="utf-8")

    status_file = status_dir / f"{task.id.lower()}.json"
    status_file.unlink(missing_ok=True)  # clear any old result

    # Resolve agent
    agent = task.agent
    orch_cfg = config.get("orchestrate", {})
    personas = orch_cfg.get("personas", {})
    persona_cfg = personas.get(task.persona, {})
    preferred = persona_cfg.get("preferred", agent)

    try:
        container = manager.run_agent(
            agent=preferred,
            image=config.get("agents", {}).get(preferred, {}).get("image", f"vibepod/{preferred}:latest"),
            workspace=worktree_path,
            config_dir=workspace / ".vibepod" / "agents" / preferred,
            config_mount_path=f"/{preferred}",
            env={
                "VP_STATUS_FILE": f"/vibepod-status/{task.id.lower()}.json",
                "VP_PROMPT_FILE": "/vibepod-prompt.md",
            },
            command=[preferred, "--dangerously-skip-permissions"],
            auto_remove=True,
            name=None,
            version="",
            network=str(config.get("network", "vibepod-network")),
            extra_volumes=[
                (str(prompt_dest), "/vibepod-prompt.md", "ro"),
                (str(status_dir), "/vibepod-status", "rw"),
            ],
            labels={"vibepod.orchestrate.task": task.id},
        )
        update_task_status(plan_path, task.id, "running")
        active[task.id] = container
        info(f"{task.id}: started ({preferred}) in {worktree_path.name}")
    except Exception as exc:
        error(f"{task.id}: failed to start: {exc}")
        update_task_status(plan_path, task.id, "failed")


def _handle_task_result(
    task_id: str,
    result: StatusResult,
    plan: OrchestratePlan,
    plan_path: Path,
    workspace: Path,
    config: dict,
    manager: DockerManager,
    active: dict,
    status_dir: Path,
    worktrees_base: Path,
) -> None:
    task = next((t for t in plan.tasks if t.id == task_id), None)
    if task is None:
        return

    if result.status == "done":
        update_task_status(plan_path, task_id, "done")
        # Store summary on task object for merge prompt
        task.summary = result.summary  # type: ignore[attr-defined]
        success(f"{task_id}: done — {result.summary}")
    else:
        warning(f"{task_id}: failed — {result.reason}")
        # Retry with fallback if available
        if task.fallback:
            info(f"{task_id}: retrying with fallback agent ({task.fallback})...")
            worktree_path = worktrees_base / task_id.lower()
            try:
                reset_worktree(worktree_path)
            except WorktreeError as exc:
                error(f"{task_id}: could not reset worktree: {exc}")
                update_task_status(plan_path, task_id, "failed")
                return
            # Re-spawn with fallback (simplified: reuse _spawn_worker logic inline)
            # Update task agent temporarily for spawn
            original_agent = task.agent
            task.agent = task.fallback
            _spawn_worker(task, plan, workspace, config, manager, status_dir, worktrees_base, active, plan_path)
            task.agent = original_agent
        else:
            update_task_status(plan_path, task_id, "failed")


def _print_execute_summary(plan: OrchestratePlan) -> None:
    done = [t for t in plan.tasks if t.status == "done"]
    failed = [t for t in plan.tasks if t.status == "failed"]
    blocked = [t for t in plan.tasks if t.status == "blocked"]
    info(f"\nExecute complete: {len(done)} done, {len(failed)} failed, {len(blocked)} blocked")
    for t in failed:
        warning(f"  FAILED: {t.id} — {t.title}")
    for t in blocked:
        warning(f"  BLOCKED: {t.id} — {t.title}")


# ---------------------------------------------------------------------------
# vp orchestrate merge
# ---------------------------------------------------------------------------

@app.command("merge")
def orchestrate_merge(
    workspace: Annotated[
        Path, typer.Option("-w", "--workspace", help="Workspace directory")
    ] = Path("."),
    plan_file: Annotated[
        Path, typer.Option("--plan-file", help="Override PLAN.md location")
    ] = Path(_DEFAULT_PLAN_FILE),
    force: Annotated[
        bool, typer.Option("--force", help="Skip warning when tasks are failed/blocked")
    ] = False,
    merge_strategy: Annotated[
        str | None,
        typer.Option("--merge-strategy", help="Override merge strategy (auto|review)")
    ] = None,
) -> None:
    """Synthesize all worker worktree results using the controller agent."""
    workspace = workspace.expanduser().resolve()
    plan_path = (workspace / plan_file) if not plan_file.is_absolute() else plan_file

    if not plan_path.exists():
        error(f"Plan file not found: {plan_path}")
        raise typer.Exit(1)

    plan = parse_plan(plan_path.read_text(encoding="utf-8"))

    failed_or_blocked = [t for t in plan.tasks if t.status in ("failed", "blocked")]
    done_tasks = [t for t in plan.tasks if t.status == "done"]

    if not done_tasks:
        error("No completed tasks to merge.")
        raise typer.Exit(1)

    if failed_or_blocked and not force:
        warning(f"{len(failed_or_blocked)} task(s) are failed/blocked:")
        for t in failed_or_blocked:
            warning(f"  {t.status.upper()}: {t.id} — {t.title}")
        if not typer.confirm("Proceed with merging completed tasks only?", default=False):
            raise typer.Exit(0)

    config = get_config()
    orch_cfg = config.get("orchestrate", {})
    controller = str(orch_cfg.get("controller", config.get("default_agent", "claude")))
    strategy = merge_strategy or str(orch_cfg.get("merge_strategy", "auto"))

    # Load summaries from status files
    status_dir = _status_dir(workspace)
    for task in done_tasks:
        sf = read_status_file(status_dir / f"{task.id.lower()}.json")
        if sf:
            task.summary = sf.summary  # type: ignore[attr-defined]

    # Review gate
    info("\nCompleted tasks:")
    for task in done_tasks:
        branch = f"orchestrate/{task.id.lower()}-{plan.run_id}"
        summary = getattr(task, "summary", "")
        info(f"  {task.id}: {task.title} — {branch}")
        if summary:
            info(f"    Summary: {summary}")

    # Write merge prompt
    merge_prompt_path = workspace / ".vibepod" / "merge-prompt.md"
    merge_prompt_path.write_text(build_merge_prompt(plan, workspace=str(workspace)), encoding="utf-8")

    info(f"\nRunning controller agent ({controller}) to merge branches...")

    if strategy == "review":
        for task in done_tasks:
            branch = f"orchestrate/{task.id.lower()}-{plan.run_id}"
            info(f"\nAbout to merge branch: {branch}")
            if not typer.confirm("Proceed?", default=True):
                info(f"Skipping {task.id}")
                continue
            _run_merge_for_task(task, plan, workspace, controller, config)
    else:
        # auto: run controller agent once with the full merge prompt
        try:
            run_agent(
                agent=controller,
                workspace=workspace,
                detach=False,
                ikwid=True,
                prompt_file=merge_prompt_path,
            )
        except SystemExit:
            pass

    # Cleanup
    if bool(orch_cfg.get("cleanup", True)):
        conflict_file = workspace / ".vibepod" / "merge-conflicts.md"
        if conflict_file.exists():
            warning("Merge conflicts detected. Skipping cleanup. Resolve and re-run merge.")
        else:
            _cleanup(plan, workspace, config)

    success("Merge complete.")


def _run_merge_for_task(
    task: OrchestrateTask,
    plan: OrchestratePlan,
    workspace: Path,
    controller: str,
    config: dict,
) -> None:
    branch = f"orchestrate/{task.id.lower()}-{plan.run_id}"
    prompt_path = workspace / ".vibepod" / f"merge-prompt-{task.id.lower()}.md"
    prompt_path.write_text(
        f"Merge the branch `{branch}` into the current branch.\n"
        f"Resolve any conflicts, then commit with message: merge: integrate {task.id} results\n",
        encoding="utf-8",
    )
    try:
        run_agent(
            agent=controller,
            workspace=workspace,
            detach=False,
            ikwid=True,
            prompt_file=prompt_path,
        )
    except SystemExit:
        pass


def _cleanup(plan: OrchestratePlan, workspace: Path, config: dict) -> None:
    worktrees_base = _worktrees_dir(workspace)
    for task in plan.tasks:
        if task.status != "done":
            continue
        branch = worktree_branch_name(task.id, plan.run_id)
        worktree_path = worktrees_base / task.id.lower()
        try:
            delete_worktree(workspace, worktree_path, branch)
        except Exception as exc:
            warning(f"Could not clean up {task.id}: {exc}")

    status_dir = _status_dir(workspace)
    for f in status_dir.glob("*.json"):
        f.unlink(missing_ok=True)

    info("Cleaned up worktrees and status files.")
```

- [ ] **Step 7.2: Register `orchestrate` subapp in `cli.py`**

Modify `src/vibepod/cli.py` — add import and registration:

```python
from vibepod.commands import config, list_cmd, logs, orchestrate, proxy, run, stop, update
```

And after `app.add_typer(proxy.app, name="proxy")`:

```python
app.add_typer(orchestrate.app, name="orchestrate")
```

- [ ] **Step 7.3: Run existing tests to verify no regressions**

```bash
cd /workspace && python -m pytest tests/ -v
```

Expected: all tests pass

- [ ] **Step 7.4: Smoke test CLI registration**

```bash
cd /workspace && python -m vibepod.cli orchestrate --help
```

Expected: shows `plan`, `execute`, `merge` subcommands

- [ ] **Step 7.5: Commit**

```bash
git add src/vibepod/commands/orchestrate.py src/vibepod/cli.py
git commit -m "feat: add vp orchestrate plan/execute/merge commands"
```

---

## Final: Full test suite + run summary

- [ ] **Step F.1: Run full test suite**

```bash
cd /workspace && python -m pytest tests/ -v --tb=short
```

Expected: all tests pass, no failures

- [ ] **Step F.2: Verify CLI help is complete**

```bash
python -m vibepod.cli --help
python -m vibepod.cli orchestrate --help
python -m vibepod.cli orchestrate plan --help
python -m vibepod.cli orchestrate execute --help
python -m vibepod.cli orchestrate merge --help
python -m vibepod.cli run --help | grep prompt-file
```

Expected: all commands listed, `--prompt-file` visible in `vp run --help`
