# Dobby v1a Implementation Plan

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

**Goal:** Build the core Dobby daemon that can queue tasks, dispatch Claude Code agents across projects, collect results, monitor GPU jobs, and expose a CLI + REST API.

**Architecture:** Asyncio Python daemon with SQLite storage (via `aiosqlite`), REST API via `aiohttp`, CLI via `click` that talks to the daemon over HTTP. Agents dispatched as `claude -p` subprocesses.

**Tech Stack:** Python 3.11+, aiosqlite, aiohttp, click, asyncio

**Spec:** `docs/superpowers/specs/2026-03-21-dobby-meta-agent-design.md`

---

## File Structure

```
dobby/
  __init__.py                  # Package init, version
  models.py                    # Task, Result, Project, Schedule dataclasses
  config.py                    # Config loading from dobby.yaml
  store/
    __init__.py
    interface.py               # DobbyStore Protocol
    sqlite.py                  # SqliteStore implementation (aiosqlite)
  core/
    __init__.py
    queue.py                   # QueueManager: priority ordering, scheduling, per-project locks
    dispatcher.py              # Dispatcher: spawn claude subprocesses, manage concurrency
    collector.py               # Collector: parse DOBBY_RESULT + heuristics, store artifacts
    gpu_monitor.py             # GPUMonitor: SSH poll loop for Phase 2 GPU jobs
    prompt_builder.py          # Build task prompts with project context + infra config
    daemon.py                  # DobbyDaemon: main loop, API server, orchestrates everything
  cli/
    __init__.py
    client.py                  # DobbyClient: HTTP client for daemon API
    main.py                    # CLI entry point (click group)
tests/
  __init__.py
  conftest.py                  # Shared fixtures (tmp db, test store, etc.)
  test_models.py               # Data model tests
  test_sqlite_store.py         # Store CRUD tests
  test_queue.py                # Queue ordering + scheduling tests
  test_collector.py            # Output parsing tests
  test_dispatcher.py           # Dispatcher tests (mocked subprocess)
  test_prompt_builder.py       # Prompt construction tests
  test_gpu_monitor.py          # GPU monitor tests (mocked SSH)
  test_api.py                  # REST API endpoint tests
  test_cli.py                  # CLI command tests
  test_daemon_integration.py   # End-to-end integration test
pyproject.toml                 # Package config, dependencies, entry points
```

---

### Task 1: Project Scaffolding + Data Models

**Files:**
- Create: `dobby/__init__.py`
- Create: `dobby/models.py`
- Create: `pyproject.toml`
- Create: `tests/__init__.py`
- Create: `tests/test_models.py`

- [ ] **Step 1: Create pyproject.toml**

```toml
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"

[project]
name = "dobby"
version = "0.1.0"
description = "Meta-agent orchestrator for multi-project development"
requires-python = ">=3.11"
dependencies = [
    "aiosqlite>=0.20.0",
    "aiohttp>=3.9.0",
    "click>=8.1.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0",
    "pytest-asyncio>=0.23",
    "pytest-aiohttp>=1.0",
    "aioresponses>=0.7",
]

[project.scripts]
dobby = "dobby.cli.main:cli"

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
```

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

```python
# dobby/__init__.py
__version__ = "0.1.0"
```

- [ ] **Step 3: Write failing tests for data models**

```python
# tests/test_models.py
import pytest
from datetime import datetime, timezone
from dobby.models import (
    Task, Result, Project, Schedule, InfraConfig,
    TaskStatus, TaskMode, Priority, ScheduleType,
)


def test_task_creation_quick_mode():
    task = Task(
        project="spare",
        description="Add warmup scheduler",
        exit_criteria="PR ready",
    )
    assert task.id.startswith("t-")
    assert task.status == TaskStatus.PENDING
    assert task.mode == TaskMode.QUICK
    assert task.priority == Priority.NORMAL
    assert task.schedule.type == ScheduleType.ONCE
    assert task.checkpoint is False
    assert task.plan == []
    assert task.created_at is not None


def test_task_creation_planned_mode():
    task = Task(
        project="memagent",
        description="Refactor orchestrator",
        mode=TaskMode.PLANNED,
        exit_criteria="tests pass",
    )
    assert task.status == TaskStatus.DRAFTING
    assert task.mode == TaskMode.PLANNED


def test_task_creation_with_schedule():
    task = Task(
        project="spare",
        description="Nightly eval",
        schedule=Schedule(type=ScheduleType.CRON, expr="0 3 * * *"),
    )
    assert task.schedule.type == ScheduleType.CRON
    assert task.schedule.expr == "0 3 * * *"
    assert task.status == TaskStatus.ACTIVE


def test_task_to_dict_roundtrip():
    task = Task(
        project="spare",
        description="Test task",
        priority=Priority.HIGH,
    )
    d = task.to_dict()
    restored = Task.from_dict(d)
    assert restored.id == task.id
    assert restored.project == task.project
    assert restored.priority == Priority.HIGH
    assert restored.created_at == task.created_at


def test_result_creation():
    result = Result(
        task_id="t-20260321-001",
        status="completed",
        summary="Done",
    )
    assert result.artifacts == []
    assert result.metrics == {}
    assert result.gpu_info is None
    assert result.cost is not None
    assert result.completed_at is not None


def test_result_to_dict_roundtrip():
    result = Result(
        task_id="t-20260321-001",
        status="completed",
        summary="Added scheduler",
        artifacts=[{"type": "pr", "url": "https://github.com/test/pull/1"}],
        metrics={"loss": 0.5},
    )
    d = result.to_dict()
    restored = Result.from_dict(d)
    assert restored.task_id == result.task_id
    assert restored.metrics == {"loss": 0.5}


def test_project_creation():
    project = Project(
        name="spare",
        repo="/Users/simonyu/local/local_orby/spiral_refactor",
        context="Dual-role RL framework",
    )
    assert project.git_remote is None
    assert project.claude_md is True
    assert project.infra == []


def test_project_with_infra():
    project = Project(
        name="spare",
        repo="/path/to/repo",
        infra=[
            InfraConfig(name="gpu-box-1", type="ssh", host="user@gpu-box"),
            InfraConfig(name="modal", type="modal", gpu="H100"),
        ],
    )
    assert len(project.infra) == 2
    assert project.infra[0].host == "user@gpu-box"


def test_schedule_interval_parsing():
    s = Schedule(type=ScheduleType.INTERVAL, every="30m")
    assert s.interval_seconds() == 1800

    s2 = Schedule(type=ScheduleType.INTERVAL, every="2h")
    assert s2.interval_seconds() == 7200


def test_priority_ordering():
    assert Priority.URGENT.weight > Priority.HIGH.weight
    assert Priority.HIGH.weight > Priority.NORMAL.weight
    assert Priority.NORMAL.weight > Priority.LOW.weight
```

- [ ] **Step 4: Run tests to verify they fail**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_models.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'dobby.models'`

- [ ] **Step 5: Implement data models**

Create `dobby/models.py` with:
- Enums: `TaskStatus`, `TaskMode`, `Priority`, `ScheduleType` (use `enum.Enum`)
- `Schedule` dataclass with `type`, `expr`, `every`, `until`, `depends_on` fields. Include `interval_seconds()` method that parses "30m", "2h", etc.
- `InfraConfig` dataclass with `name`, `type`, `host`, `gpus`, `conda_env`, `gpu` fields
- `Project` dataclass with `name`, `repo`, `git_remote`, `context`, `claude_md` (default True), `infra` (default [])
- `Task` dataclass with all spec fields. Auto-generate `id` as `t-YYYYMMDD-XXXX` where XXXX is a random 4-char hex string (`secrets.token_hex(2)`), ensuring no collisions across daemon restarts. `created_at` defaults to `datetime.now(timezone.utc)`. Status defaults based on mode: PLANNED → DRAFTING, QUICK → PENDING. Recurring schedules → ACTIVE.
- `Result` dataclass with all spec fields. `completed_at` defaults to now. `cost` defaults to `{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "cost_usd": 0.0}`.
- Both Task and Result have `to_dict()` / `from_dict()` for JSON serialization. Datetimes serialize as ISO strings. Enums serialize as `.value` strings.
- `Priority` enum has a `weight` property: URGENT=40, HIGH=30, NORMAL=20, LOW=10.

- [ ] **Step 6: Run tests to verify they pass**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_models.py -v`
Expected: All PASS

- [ ] **Step 7: Create empty package files**

Create `dobby/store/__init__.py`, `dobby/core/__init__.py`, `dobby/cli/__init__.py`, `tests/__init__.py` as empty files.

- [ ] **Step 8: Commit**

```bash
git add dobby/ tests/ pyproject.toml
git commit -m "feat(dobby): project scaffolding and data models"
```

---

### Task 2: DobbyStore Protocol + SQLite Implementation

**Files:**
- Create: `dobby/store/interface.py`
- Create: `dobby/store/sqlite.py`
- Create: `tests/conftest.py`
- Create: `tests/test_sqlite_store.py`

- [ ] **Step 1: Write the DobbyStore protocol**

```python
# dobby/store/interface.py
from typing import Protocol, Optional
from dobby.models import Task, Result, Project


class DobbyStore(Protocol):
    async def initialize(self) -> None: ...
    async def close(self) -> None: ...

    # Tasks
    async def create_task(self, task: Task) -> str: ...
    async def get_task(self, task_id: str) -> Task: ...
    async def update_task(self, task_id: str, **fields) -> None: ...
    async def list_tasks(self, project: Optional[str] = None, status: Optional[str] = None) -> list[Task]: ...
    async def get_pending_tasks(self, limit: int = 10) -> list[Task]: ...
    async def get_scheduled_tasks(self) -> list[Task]: ...

    # Results
    async def store_result(self, result: Result) -> None: ...
    async def get_result(self, task_id: str) -> Result | None: ...
    async def list_results(self, project: Optional[str] = None, task_id: Optional[str] = None, last_n: Optional[int] = None) -> list[Result]: ...

    # Projects
    async def register_project(self, project: Project) -> None: ...
    async def get_project(self, name: str) -> Project | None: ...
    async def list_projects(self) -> list[Project]: ...
```

- [ ] **Step 2: Write shared test fixtures**

```python
# tests/conftest.py
import pytest
import tempfile
import os
from dobby.store.sqlite import SqliteStore


@pytest.fixture
async def store(tmp_path):
    db_path = str(tmp_path / "test.db")
    s = SqliteStore(db_path)
    await s.initialize()
    yield s
    await s.close()
```

- [ ] **Step 3: Write failing tests for SQLite store**

```python
# tests/test_sqlite_store.py
import pytest
from dobby.models import (
    Task, Result, Project, InfraConfig, Schedule,
    TaskStatus, Priority, ScheduleType,
)


async def test_create_and_get_task(store):
    task = Task(project="spare", description="Test task")
    task_id = await store.create_task(task)
    retrieved = await store.get_task(task_id)
    assert retrieved.project == "spare"
    assert retrieved.description == "Test task"
    assert retrieved.status == TaskStatus.PENDING


async def test_get_nonexistent_task_raises(store):
    with pytest.raises(KeyError):
        await store.get_task("t-nonexistent")


async def test_update_task(store):
    task = Task(project="spare", description="Test")
    task_id = await store.create_task(task)
    await store.update_task(task_id, status=TaskStatus.RUNNING.value)
    updated = await store.get_task(task_id)
    assert updated.status == TaskStatus.RUNNING


async def test_list_tasks_filter_by_project(store):
    await store.create_task(Task(project="spare", description="A"))
    await store.create_task(Task(project="memagent", description="B"))
    await store.create_task(Task(project="spare", description="C"))
    tasks = await store.list_tasks(project="spare")
    assert len(tasks) == 2
    assert all(t.project == "spare" for t in tasks)


async def test_list_tasks_filter_by_status(store):
    t1 = Task(project="spare", description="A")
    t2 = Task(project="spare", description="B")
    await store.create_task(t1)
    tid2 = await store.create_task(t2)
    await store.update_task(tid2, status=TaskStatus.RUNNING.value)
    tasks = await store.list_tasks(status="pending")
    assert len(tasks) == 1


async def test_get_pending_tasks_priority_order(store):
    await store.create_task(Task(project="a", description="low", priority=Priority.LOW))
    await store.create_task(Task(project="b", description="urgent", priority=Priority.URGENT))
    await store.create_task(Task(project="c", description="normal", priority=Priority.NORMAL))
    pending = await store.get_pending_tasks(limit=10)
    assert pending[0].priority == Priority.URGENT
    assert pending[1].priority == Priority.NORMAL
    assert pending[2].priority == Priority.LOW


async def test_get_scheduled_tasks(store):
    await store.create_task(Task(
        project="spare", description="Nightly",
        schedule=Schedule(type=ScheduleType.CRON, expr="0 3 * * *"),
    ))
    await store.create_task(Task(project="spare", description="One-off"))
    scheduled = await store.get_scheduled_tasks()
    assert len(scheduled) == 1
    assert scheduled[0].schedule.type == ScheduleType.CRON


async def test_store_and_get_result(store):
    result = Result(
        task_id="t-20260321-001",
        status="completed",
        summary="Done",
        artifacts=[{"type": "pr", "url": "https://example.com/pull/1"}],
        metrics={"loss": 0.5},
    )
    await store.store_result(result)
    retrieved = await store.get_result("t-20260321-001")
    assert retrieved.summary == "Done"
    assert retrieved.metrics == {"loss": 0.5}


async def test_get_result_nonexistent(store):
    result = await store.get_result("t-nonexistent")
    assert result is None


async def test_list_results_by_project(store):
    # Need tasks first for project association
    await store.create_task(Task(id="t-1", project="spare", description="A"))
    await store.create_task(Task(id="t-2", project="memagent", description="B"))
    await store.store_result(Result(task_id="t-1", status="completed", summary="A done"))
    await store.store_result(Result(task_id="t-2", status="completed", summary="B done"))
    results = await store.list_results(project="spare")
    assert len(results) == 1
    assert results[0].task_id == "t-1"


async def test_list_results_last_n(store):
    for i in range(5):
        tid = f"t-{i}"
        await store.create_task(Task(id=tid, project="spare", description=f"Task {i}"))
        await store.store_result(Result(task_id=tid, status="completed", summary=f"Done {i}"))
    results = await store.list_results(last_n=3)
    assert len(results) == 3


async def test_register_and_get_project(store):
    project = Project(
        name="spare",
        repo="/path/to/spare",
        git_remote="git@github.com:user/spare.git",
        context="RL framework",
        infra=[InfraConfig(name="gpu-1", type="ssh", host="user@gpu")],
    )
    await store.register_project(project)
    retrieved = await store.get_project("spare")
    assert retrieved.repo == "/path/to/spare"
    assert retrieved.git_remote == "git@github.com:user/spare.git"
    assert len(retrieved.infra) == 1


async def test_get_project_nonexistent(store):
    result = await store.get_project("nope")
    assert result is None


async def test_list_projects(store):
    await store.register_project(Project(name="a", repo="/a"))
    await store.register_project(Project(name="b", repo="/b"))
    projects = await store.list_projects()
    assert len(projects) == 2


async def test_register_project_upsert(store):
    await store.register_project(Project(name="spare", repo="/old"))
    await store.register_project(Project(name="spare", repo="/new"))
    p = await store.get_project("spare")
    assert p.repo == "/new"
```

- [ ] **Step 4: Run tests to verify they fail**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_sqlite_store.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'dobby.store.sqlite'`

- [ ] **Step 5: Implement SqliteStore**

Create `dobby/store/sqlite.py`:
- Class `SqliteStore` that implements `DobbyStore` protocol
- `__init__(self, db_path: str)` stores path
- `initialize()`: connect with `aiosqlite`, enable WAL mode (`PRAGMA journal_mode=WAL`), create tables:
  - `tasks`: id TEXT PK, data TEXT (JSON blob of task.to_dict())
  - `results`: id INTEGER PK AUTOINCREMENT, task_id TEXT, data TEXT (JSON blob)
  - `projects`: name TEXT PK, data TEXT (JSON blob)
- `close()`: close connection
- All CRUD methods serialize/deserialize via `to_dict()`/`from_dict()`
- `get_pending_tasks()`: query tasks WHERE status = 'pending' ORDER BY priority weight DESC, created_at ASC (only PENDING, not PLANNED — planned tasks must be explicitly moved to pending before dispatch)
- `get_scheduled_tasks()`: query tasks WHERE schedule type != 'once' AND status = 'active'
- `list_results(project=...)`: JOIN with tasks table to filter by project

- [ ] **Step 6: Run tests to verify they pass**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_sqlite_store.py -v`
Expected: All PASS

- [ ] **Step 7: Commit**

```bash
git add dobby/store/ tests/conftest.py tests/test_sqlite_store.py
git commit -m "feat(dobby): DobbyStore protocol and SQLite implementation"
```

---

### Task 3: Config Loading

**Files:**
- Create: `dobby/config.py`
- Create: `tests/test_config.py`

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

```python
# tests/test_config.py
import pytest
from dobby.config import DobbyConfig


def test_default_config():
    config = DobbyConfig()
    assert config.port == 8420
    assert config.host == "127.0.0.1"
    assert config.max_concurrent_agents == 5
    assert config.default_timeout == 1800  # 30 min
    assert config.gpu_poll_interval == 300  # 5 min
    assert config.results_dir == "results"


def test_load_from_dict():
    config = DobbyConfig.from_dict({
        "port": 9000,
        "max_concurrent_agents": 10,
    })
    assert config.port == 9000
    assert config.max_concurrent_agents == 10
    assert config.host == "127.0.0.1"  # default preserved


def test_load_from_yaml(tmp_path):
    yaml_path = tmp_path / "dobby.yaml"
    yaml_path.write_text("port: 9000\nmax_concurrent_agents: 3\n")
    config = DobbyConfig.load(str(yaml_path))
    assert config.port == 9000
    assert config.max_concurrent_agents == 3


def test_load_missing_file_returns_defaults():
    config = DobbyConfig.load("/nonexistent/dobby.yaml")
    assert config.port == 8420
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_config.py -v`
Expected: FAIL

- [ ] **Step 3: Implement config**

Create `dobby/config.py`:
- `DobbyConfig` dataclass with fields: `port`, `host`, `max_concurrent_agents`, `default_timeout`, `gpu_poll_interval`, `results_dir`, `db_path`
- `from_dict(cls, d)`: merge dict over defaults
- `load(cls, path)`: read YAML file (use `yaml.safe_load`, add `pyyaml` to deps), fallback to defaults if file missing

- [ ] **Step 4: Run tests, verify pass**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_config.py -v`
Expected: All PASS

- [ ] **Step 5: Add pyyaml to pyproject.toml dependencies**

Add `"pyyaml>=6.0"` to `[project] dependencies`.

- [ ] **Step 6: Commit**

```bash
git add dobby/config.py tests/test_config.py pyproject.toml
git commit -m "feat(dobby): config loading from YAML with defaults"
```

---

### Task 4: Queue Manager

**Files:**
- Create: `dobby/core/queue.py`
- Create: `tests/test_queue.py`

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

```python
# tests/test_queue.py
import pytest
from datetime import datetime, timezone, timedelta
from dobby.models import Task, Schedule, ScheduleType, TaskStatus, Priority
from dobby.core.queue import QueueManager
from dobby.store.sqlite import SqliteStore


@pytest.fixture
async def queue(tmp_path):
    store = SqliteStore(str(tmp_path / "test.db"))
    await store.initialize()
    qm = QueueManager(store)
    yield qm
    await store.close()


async def test_next_task_returns_highest_priority(queue):
    await queue.store.create_task(Task(project="a", description="low", priority=Priority.LOW))
    await queue.store.create_task(Task(project="b", description="urgent", priority=Priority.URGENT))
    task = await queue.next_task()
    assert task.priority == Priority.URGENT


async def test_next_task_respects_project_lock(queue):
    await queue.store.create_task(Task(project="spare", description="first", priority=Priority.HIGH))
    await queue.store.create_task(Task(project="spare", description="second", priority=Priority.NORMAL))
    await queue.store.create_task(Task(project="memagent", description="third", priority=Priority.LOW))
    queue.lock_project("spare")
    task = await queue.next_task()
    assert task.project == "memagent"


async def test_next_task_returns_none_when_empty(queue):
    task = await queue.next_task()
    assert task is None


async def test_next_task_returns_none_when_all_locked(queue):
    await queue.store.create_task(Task(project="spare", description="task"))
    queue.lock_project("spare")
    task = await queue.next_task()
    assert task is None


async def test_lock_unlock_project(queue):
    queue.lock_project("spare")
    assert queue.is_locked("spare")
    queue.unlock_project("spare")
    assert not queue.is_locked("spare")


async def test_cron_task_is_due(queue):
    # A cron "0 * * * *" (every hour) — test that is_due works
    task = Task(
        project="spare",
        description="Hourly",
        schedule=Schedule(type=ScheduleType.CRON, expr="0 * * * *"),
    )
    await queue.store.create_task(task)
    # Just test that get_due_scheduled_tasks runs without error
    due = await queue.get_due_scheduled_tasks()
    assert isinstance(due, list)


async def test_interval_task_is_due(queue):
    task = Task(
        project="spare",
        description="Every 30m",
        schedule=Schedule(type=ScheduleType.INTERVAL, every="30m"),
    )
    tid = await queue.store.create_task(task)
    # First run: should be due immediately
    due = await queue.get_due_scheduled_tasks()
    assert len(due) >= 1


async def test_after_task_becomes_pending_when_dep_completes(queue):
    dep = Task(project="spare", description="training")
    dep_id = await queue.store.create_task(dep)
    follower = Task(
        project="spare",
        description="eval after training",
        schedule=Schedule(type=ScheduleType.AFTER, depends_on=dep_id),
    )
    fid = await queue.store.create_task(follower)
    # Before dep completes: follower not in pending
    await queue.check_dependencies()
    f = await queue.store.get_task(fid)
    assert f.status != TaskStatus.PENDING
    # After dep completes
    await queue.store.update_task(dep_id, status=TaskStatus.COMPLETED.value)
    await queue.check_dependencies()
    f = await queue.store.get_task(fid)
    assert f.status == TaskStatus.PENDING
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_queue.py -v`
Expected: FAIL

- [ ] **Step 3: Implement QueueManager**

Create `dobby/core/queue.py`:
- `QueueManager` class takes a `DobbyStore`
- `_locked_projects: set[str]` — in-memory set of currently locked projects
- `next_task()`: get pending tasks sorted by priority, skip locked projects, return first available (or None)
- `lock_project(name)` / `unlock_project(name)` / `is_locked(name)`
- `get_due_scheduled_tasks()`: check cron tasks (use `croniter` library) and interval tasks against last run time. For interval tasks, track last run time in-memory dict `_last_run: dict[str, datetime]`.
- `check_dependencies()`: find tasks with `schedule.type == AFTER`, check if `depends_on` task is completed, if so update the dependent task to PENDING

Add `"croniter>=2.0"` to pyproject.toml dependencies.

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_queue.py -v`
Expected: All PASS

- [ ] **Step 5: Commit**

```bash
git add dobby/core/queue.py tests/test_queue.py pyproject.toml
git commit -m "feat(dobby): queue manager with priority, project locks, scheduling"
```

---

### Task 5: Collector (Output Parsing)

**Files:**
- Create: `dobby/core/collector.py`
- Create: `tests/test_collector.py`

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

```python
# tests/test_collector.py
import pytest
import json
from dobby.core.collector import Collector


def test_parse_dobby_result_block():
    output = '''Some agent output here...
---DOBBY_RESULT---
{
  "status": "completed",
  "summary": "Added warmup scheduler",
  "pr_url": "https://github.com/user/repo/pull/42",
  "files_changed": ["train.py", "config.py"],
  "metrics": {"final_loss": 0.34},
  "error": null
}
---END_DOBBY_RESULT---
'''
    result = Collector.parse_output(output, exit_code=0, task_id="t-001")
    assert result.status == "completed"
    assert result.summary == "Added warmup scheduler"
    assert result.metrics == {"final_loss": 0.34}
    assert any(a["type"] == "pr" for a in result.artifacts)


def test_parse_output_json_format():
    """When --output-format json is used, Claude Code wraps output in JSON."""
    cc_output = json.dumps({
        "result": "---DOBBY_RESULT---\n{\"status\": \"completed\", \"summary\": \"Done\", \"pr_url\": null, \"files_changed\": [], \"metrics\": {}, \"error\": null}\n---END_DOBBY_RESULT---",
        "cost_usd": 0.42,
        "input_tokens": 45000,
        "output_tokens": 12000,
    })
    result = Collector.parse_output(cc_output, exit_code=0, task_id="t-002")
    assert result.status == "completed"
    assert result.cost["cost_usd"] == 0.42
    assert result.cost["input_tokens"] == 45000


def test_fallback_heuristic_success():
    output = "I've completed the task. Created PR at https://github.com/user/repo/pull/99"
    result = Collector.parse_output(output, exit_code=0, task_id="t-003")
    assert result.status == "completed"
    assert any(a.get("url", "").endswith("pull/99") for a in result.artifacts)


def test_fallback_heuristic_failure():
    output = "Error: could not find file train.py"
    result = Collector.parse_output(output, exit_code=1, task_id="t-004")
    assert result.status == "failed"
    assert "Error" in result.summary


def test_parse_gpu_phase1_result():
    output = '''---DOBBY_RESULT---
{
  "status": "completed",
  "summary": "Training launched",
  "pr_url": null,
  "files_changed": [],
  "metrics": {"gpu_pid": 12345, "log_path": "/home/user/train.log"},
  "error": null
}
---END_DOBBY_RESULT---'''
    result = Collector.parse_output(output, exit_code=0, task_id="t-005")
    assert result.metrics["gpu_pid"] == 12345
    assert result.metrics["log_path"] == "/home/user/train.log"


def test_empty_output_failure():
    result = Collector.parse_output("", exit_code=1, task_id="t-006")
    assert result.status == "failed"
    assert "empty" in result.summary.lower() or result.summary != ""
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_collector.py -v`
Expected: FAIL

- [ ] **Step 3: Implement Collector**

Create `dobby/core/collector.py`:
- `Collector` class with static method `parse_output(output: str, exit_code: int, task_id: str) -> Result`
- Try to extract `---DOBBY_RESULT---` block via regex, parse as JSON
- If output looks like Claude Code JSON format (starts with `{`, has `"result"` key), extract `result` field, then try DOBBY_RESULT within it. Also extract `cost_usd`, `input_tokens`, `output_tokens` into Result.cost.
- Fallback: scan for PR URLs with regex `github\.com/[^/]+/[^/]+/pull/\d+`, determine status from exit_code
- `save_artifacts(task_id: str, output: str, results_dir: str)`: save raw log to `results_dir/task_id/agent.log`, save diff if present

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_collector.py -v`
Expected: All PASS

- [ ] **Step 5: Commit**

```bash
git add dobby/core/collector.py tests/test_collector.py
git commit -m "feat(dobby): collector with DOBBY_RESULT parsing + heuristic fallback"
```

---

### Task 6: Prompt Builder

**Files:**
- Create: `dobby/core/prompt_builder.py`
- Create: `tests/test_prompt_builder.py`

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

```python
# tests/test_prompt_builder.py
import pytest
from dobby.models import Task, Project, InfraConfig, Schedule, ScheduleType
from dobby.core.prompt_builder import PromptBuilder


def test_basic_prompt():
    task = Task(project="spare", description="Add warmup scheduler", exit_criteria="PR ready")
    project = Project(name="spare", repo="/path/to/spare", context="RL framework")
    prompt = PromptBuilder.build(task, project)
    assert "spare" in prompt
    assert "Add warmup scheduler" in prompt
    assert "PR ready" in prompt
    assert "DOBBY_RESULT" in prompt


def test_prompt_with_infra():
    task = Task(project="spare", description="Run training", exit_criteria="Launch and confirm")
    project = Project(
        name="spare", repo="/path",
        infra=[InfraConfig(name="gpu-1", type="ssh", host="user@gpu", conda_env="/env/spare")],
    )
    prompt = PromptBuilder.build(task, project, infra_name="gpu-1")
    assert "user@gpu" in prompt
    assert "/env/spare" in prompt
    assert "nohup" in prompt.lower() or "background" in prompt.lower()


def test_prompt_with_plan():
    task = Task(
        project="spare",
        description="Refactor module",
        plan=["Step 1: rename file", "Step 2: update imports", "Step 3: add tests"],
    )
    project = Project(name="spare", repo="/path")
    prompt = PromptBuilder.build(task, project)
    assert "Step 1" in prompt
    assert "Step 3" in prompt


def test_prompt_with_checkpoint():
    task = Task(project="spare", description="Risky refactor", checkpoint=True)
    project = Project(name="spare", repo="/path")
    prompt = PromptBuilder.build(task, project)
    assert "checkpoint" in prompt.lower() or "approval" in prompt.lower() or "confirm" in prompt.lower()


def test_gpu_phase2_parse_prompt():
    prompt = PromptBuilder.build_gpu_parse_prompt(
        log_content="Epoch 3/3 loss=0.342 acc=0.871",
        task_description="Run SPARE training 3 epochs",
    )
    assert "0.342" in prompt
    assert "DOBBY_RESULT" in prompt
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_prompt_builder.py -v`
Expected: FAIL

- [ ] **Step 3: Implement PromptBuilder**

Create `dobby/core/prompt_builder.py`:
- `PromptBuilder` class with static methods:
- `build(task, project, infra_name=None)`: constructs the full prompt including:
  - Project context section (name, context description)
  - Task description and exit criteria
  - Plan steps if present
  - Infra config if `infra_name` is specified (SSH host, conda env, working dir)
  - For GPU infra tasks: add instructions about launching with nohup, reporting PID/log path
  - Checkpoint instructions if `task.checkpoint` is True
  - DOBBY_RESULT output format instructions at the end
- `build_gpu_parse_prompt(log_content, task_description)`: prompt for Phase 2 log parsing agent

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_prompt_builder.py -v`
Expected: All PASS

- [ ] **Step 5: Commit**

```bash
git add dobby/core/prompt_builder.py tests/test_prompt_builder.py
git commit -m "feat(dobby): prompt builder for task dispatch + GPU jobs"
```

---

### Task 7: Dispatcher

**Files:**
- Create: `dobby/core/dispatcher.py`
- Create: `tests/test_dispatcher.py`

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

```python
# tests/test_dispatcher.py
import pytest
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock, patch
from dobby.models import Task, Project
from dobby.core.dispatcher import Dispatcher


@pytest.fixture
def dispatcher(tmp_path):
    return Dispatcher(
        max_concurrent=2,
        default_timeout=60,
        results_dir=str(tmp_path / "results"),
    )


async def test_dispatch_spawns_subprocess(dispatcher):
    task = Task(project="spare", description="Test", exit_criteria="done")
    project = Project(name="spare", repo="/tmp/fake-repo")

    mock_result = json.dumps({
        "result": "---DOBBY_RESULT---\n{\"status\": \"completed\", \"summary\": \"Done\", \"pr_url\": null, \"files_changed\": [], \"metrics\": {}, \"error\": null}\n---END_DOBBY_RESULT---",
        "cost_usd": 0.10,
        "input_tokens": 1000,
        "output_tokens": 500,
    })

    mock_proc = AsyncMock()
    mock_proc.communicate = AsyncMock(return_value=(mock_result.encode(), b""))
    mock_proc.returncode = 0
    mock_proc.pid = 12345

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc) as mock_exec:
        result = await dispatcher.dispatch(task, project)

    assert result.status == "completed"
    assert result.summary == "Done"
    mock_exec.assert_called_once()
    # Verify claude was called with correct args
    call_args = mock_exec.call_args[0]
    assert "claude" in call_args[0]


async def test_dispatch_respects_concurrency_limit(dispatcher):
    """Should not exceed max_concurrent (2) running agents."""
    assert dispatcher.active_count == 0
    task = Task(project="spare", description="Test")
    project = Project(name="spare", repo="/tmp")

    max_observed = 0

    async def slow_communicate():
        nonlocal max_observed
        max_observed = max(max_observed, dispatcher.active_count)
        await asyncio.sleep(0.3)
        output = '---DOBBY_RESULT---\n{"status": "completed", "summary": "Done", "pr_url": null, "files_changed": [], "metrics": {}, "error": null}\n---END_DOBBY_RESULT---'
        return (output.encode(), b"")

    mock_proc = AsyncMock()
    mock_proc.communicate = slow_communicate
    mock_proc.returncode = 0
    mock_proc.pid = 1

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
        # Launch 3 tasks with max_concurrent=2 — third must wait
        t1 = asyncio.create_task(dispatcher.dispatch(task, project))
        t2 = asyncio.create_task(dispatcher.dispatch(task, project))
        t3 = asyncio.create_task(dispatcher.dispatch(task, project))
        await asyncio.gather(t1, t2, t3)

    # At no point should more than 2 have been active simultaneously
    assert max_observed <= 2


async def test_dispatch_skips_permissions(dispatcher):
    """Dispatcher should always pass --dangerously-skip-permissions for headless operation."""
    task = Task(project="spare", description="Test")
    project = Project(name="spare", repo="/tmp")

    mock_proc = AsyncMock()
    mock_proc.communicate = AsyncMock(return_value=(b'---DOBBY_RESULT---\n{"status": "completed", "summary": "Done", "pr_url": null, "files_changed": [], "metrics": {}, "error": null}\n---END_DOBBY_RESULT---', b""))
    mock_proc.returncode = 0
    mock_proc.pid = 1

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc) as mock_exec:
        await dispatcher.dispatch(task, project)

    call_args = mock_exec.call_args[0]
    args_str = " ".join(str(a) for a in call_args)
    assert "--dangerously-skip-permissions" in args_str


async def test_dispatch_timeout(dispatcher):
    dispatcher.default_timeout = 1  # 1 second timeout
    task = Task(project="spare", description="Slow task")
    project = Project(name="spare", repo="/tmp")

    async def hang():
        await asyncio.sleep(100)
        return (b"", b"")

    mock_proc = AsyncMock()
    mock_proc.communicate = hang
    mock_proc.returncode = -15
    mock_proc.pid = 1
    mock_proc.kill = MagicMock()

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
        result = await dispatcher.dispatch(task, project, timeout=1)

    assert result.status == "failed"


async def test_dispatch_records_pid(dispatcher):
    task = Task(project="spare", description="Test")
    project = Project(name="spare", repo="/tmp")

    mock_proc = AsyncMock()
    mock_proc.communicate = AsyncMock(return_value=(b'---DOBBY_RESULT---\n{"status": "completed", "summary": "Done", "pr_url": null, "files_changed": [], "metrics": {}, "error": null}\n---END_DOBBY_RESULT---', b""))
    mock_proc.returncode = 0
    mock_proc.pid = 42

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
        dispatcher.dispatch_nowait(task, project)
        await asyncio.sleep(0.1)
        assert task.id in dispatcher.running_pids
        # Wait for completion
        await asyncio.sleep(0.5)
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_dispatcher.py -v`
Expected: FAIL

- [ ] **Step 3: Implement Dispatcher**

Create `dobby/core/dispatcher.py`:
- `Dispatcher` class:
  - `__init__(self, max_concurrent, default_timeout, results_dir)`
  - `_semaphore: asyncio.Semaphore` initialized with `max_concurrent`
  - `running_pids: dict[str, int]` — maps task_id → subprocess PID
  - `active_count` property: `max_concurrent - _semaphore._value`
  - `dispatch(task, project, timeout=None, infra_name=None) -> Result`:
    1. Build prompt via `PromptBuilder.build(task, project, infra_name)`
    2. Acquire semaphore
    3. Build command args: `["claude", "-p", prompt, "--project-dir", project.repo, "--output-format", "json", "--max-turns", "200"]`
    4. Add `--dangerously-skip-permissions` (always — agents are headless, nobody to approve prompts)
    5. Create results directory for task
    6. Spawn via `asyncio.create_subprocess_exec(*args, stdout=PIPE, stderr=STDOUT)`
    7. Track PID in `running_pids`
    8. `communicate()` with timeout
    9. On timeout: kill process, return failed Result
    10. Parse output via `Collector.parse_output()`
    11. Save log via `Collector.save_artifacts()`
    12. Release semaphore, remove from running_pids
    13. Return Result
  - `dispatch_nowait(task, project, ...) -> asyncio.Task`: wraps dispatch in `asyncio.create_task`
  - `cancel(task_id)`: find PID, send SIGTERM, wait 10s, SIGKILL

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_dispatcher.py -v`
Expected: All PASS

- [ ] **Step 5: Commit**

```bash
git add dobby/core/dispatcher.py tests/test_dispatcher.py
git commit -m "feat(dobby): dispatcher with concurrency control and timeout"
```

---

### Task 8: GPU Monitor

**Files:**
- Create: `dobby/core/gpu_monitor.py`
- Create: `tests/test_gpu_monitor.py`

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

```python
# tests/test_gpu_monitor.py
import pytest
from unittest.mock import AsyncMock, patch
from dobby.core.gpu_monitor import GPUMonitor


@pytest.fixture
def monitor():
    return GPUMonitor(poll_interval=1)  # 1s for tests


async def test_check_pid_running(monitor):
    """SSH check returns that PID is still running."""
    mock_proc = AsyncMock()
    mock_proc.communicate = AsyncMock(return_value=(b"12345\n", b""))
    mock_proc.returncode = 0

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
        running = await monitor.check_pid("user@gpu", 12345)
    assert running is True


async def test_check_pid_completed(monitor):
    """SSH check returns that PID is no longer running."""
    mock_proc = AsyncMock()
    mock_proc.communicate = AsyncMock(return_value=(b"", b""))
    mock_proc.returncode = 1

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
        running = await monitor.check_pid("user@gpu", 12345)
    assert running is False


async def test_fetch_log(monitor):
    mock_proc = AsyncMock()
    mock_proc.communicate = AsyncMock(return_value=(b"Epoch 3 loss=0.34", b""))
    mock_proc.returncode = 0

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
        log = await monitor.fetch_log("user@gpu", "/home/user/train.log")
    assert "loss=0.34" in log


async def test_monitor_job_until_complete(monitor):
    call_count = 0
    async def mock_check_pid(host, pid):
        nonlocal call_count
        call_count += 1
        return call_count < 3  # Running for 2 checks, then done

    monitor.check_pid = mock_check_pid
    monitor.fetch_log = AsyncMock(return_value="Training complete. Loss=0.5")

    completed = await monitor.wait_for_completion("user@gpu", 12345, "/tmp/log")
    assert completed is True
    assert call_count == 3
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_gpu_monitor.py -v`
Expected: FAIL

- [ ] **Step 3: Implement GPUMonitor**

Create `dobby/core/gpu_monitor.py`:
- `GPUMonitor` class:
  - `__init__(self, poll_interval: int = 300)` (5 min default)
  - `check_pid(host, pid) -> bool`: SSH to host, run `kill -0 <pid>`, return True if running
  - `fetch_log(host, log_path) -> str`: SSH + cat the log file, return contents
  - `wait_for_completion(host, pid, log_path) -> bool`: poll `check_pid` every `poll_interval` seconds until PID is gone, then return True
  - All SSH commands via `asyncio.create_subprocess_exec("ssh", host, command)`

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_gpu_monitor.py -v`
Expected: All PASS

- [ ] **Step 5: Commit**

```bash
git add dobby/core/gpu_monitor.py tests/test_gpu_monitor.py
git commit -m "feat(dobby): GPU monitor with SSH polling"
```

---

### Task 9: REST API

**Files:**
- Create: `dobby/core/api.py`
- Create: `tests/test_api.py`

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

```python
# tests/test_api.py
import pytest
import json
from aiohttp import web
from aiohttp.test_utils import AioHTTPTestCase, TestClient
from dobby.core.api import create_app
from dobby.store.sqlite import SqliteStore
from dobby.models import Task, Project


@pytest.fixture
async def client(tmp_path, aiohttp_client):
    store = SqliteStore(str(tmp_path / "test.db"))
    await store.initialize()
    app = create_app(store)
    client = await aiohttp_client(app)
    yield client
    await store.close()


async def test_health(client):
    resp = await client.get("/health")
    assert resp.status == 200
    data = await resp.json()
    assert data["status"] == "ok"


async def test_create_task(client):
    resp = await client.post("/tasks", json={
        "project": "spare",
        "description": "Test task",
        "exit_criteria": "PR ready",
    })
    assert resp.status == 201
    data = await resp.json()
    assert data["id"].startswith("t-")
    assert data["status"] == "pending"


async def test_list_tasks(client):
    await client.post("/tasks", json={"project": "spare", "description": "A"})
    await client.post("/tasks", json={"project": "memagent", "description": "B"})
    resp = await client.get("/tasks")
    assert resp.status == 200
    data = await resp.json()
    assert len(data) == 2


async def test_list_tasks_filter_project(client):
    await client.post("/tasks", json={"project": "spare", "description": "A"})
    await client.post("/tasks", json={"project": "memagent", "description": "B"})
    resp = await client.get("/tasks?project=spare")
    data = await resp.json()
    assert len(data) == 1
    assert data[0]["project"] == "spare"


async def test_get_task(client):
    resp = await client.post("/tasks", json={"project": "spare", "description": "Test"})
    task_id = (await resp.json())["id"]
    resp = await client.get(f"/tasks/{task_id}")
    assert resp.status == 200
    data = await resp.json()
    assert data["project"] == "spare"


async def test_get_task_not_found(client):
    resp = await client.get("/tasks/t-nonexistent")
    assert resp.status == 404


async def test_patch_task(client):
    resp = await client.post("/tasks", json={"project": "spare", "description": "Test"})
    task_id = (await resp.json())["id"]
    resp = await client.patch(f"/tasks/{task_id}", json={"priority": "high"})
    assert resp.status == 200
    data = await resp.json()
    assert data["priority"] == "high"


async def test_cancel_task(client):
    resp = await client.post("/tasks", json={"project": "spare", "description": "Test"})
    task_id = (await resp.json())["id"]
    resp = await client.patch(f"/tasks/{task_id}", json={"status": "cancelled"})
    assert resp.status == 200


async def test_register_project(client):
    resp = await client.post("/projects", json={
        "name": "spare",
        "repo": "/path/to/spare",
        "context": "RL framework",
    })
    assert resp.status == 201


async def test_list_projects(client):
    await client.post("/projects", json={"name": "a", "repo": "/a"})
    await client.post("/projects", json={"name": "b", "repo": "/b"})
    resp = await client.get("/projects")
    data = await resp.json()
    assert len(data) == 2


async def test_get_project(client):
    await client.post("/projects", json={"name": "spare", "repo": "/path"})
    resp = await client.get("/projects/spare")
    assert resp.status == 200
    data = await resp.json()
    assert data["name"] == "spare"


async def test_get_project_not_found(client):
    resp = await client.get("/projects/nope")
    assert resp.status == 404


async def test_stats(client):
    resp = await client.get("/stats")
    assert resp.status == 200
    data = await resp.json()
    assert "pending" in data
    assert "running" in data
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_api.py -v`
Expected: FAIL

- [ ] **Step 3: Implement API**

Create `dobby/core/api.py`:
- `create_app(store: DobbyStore, dispatcher=None) -> web.Application`
- Store and dispatcher attached to `app` dict for handler access
- Handlers for each endpoint from the spec:
  - `POST /tasks`: parse JSON body, create Task from fields, create via store, return 201
  - `GET /tasks`: query params `project`, `status`, list via store, return JSON array
  - `GET /tasks/{id}`: get via store, 404 if not found
  - `PATCH /tasks/{id}`: update fields, handle cancel (trigger dispatcher.cancel if running)
  - `DELETE /tasks/{id}`: update status to deleted
  - `GET /results`: query params `project`, `last_n`
  - `GET /results/{task_id}`: get result
  - `POST /projects`: parse body, create Project, register via store, 201
  - `GET /projects`: list all
  - `GET /projects/{name}`: get one, 404 if not found
  - `GET /health`: return `{"status": "ok"}`
  - `GET /stats`: query store for counts by status, return summary

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_api.py -v`
Expected: All PASS

- [ ] **Step 5: Commit**

```bash
git add dobby/core/api.py tests/test_api.py
git commit -m "feat(dobby): REST API with full CRUD for tasks, results, projects"
```

---

### Task 10: Daemon Main Loop

**Files:**
- Create: `dobby/core/daemon.py`
- Create: `tests/test_daemon_integration.py`

- [ ] **Step 1: Write failing integration test**

```python
# tests/test_daemon_integration.py
import pytest
import asyncio
import json
from unittest.mock import AsyncMock, patch
from dobby.core.daemon import DobbyDaemon
from dobby.config import DobbyConfig
from dobby.models import Task, Project


@pytest.fixture
async def daemon(tmp_path):
    config = DobbyConfig(
        db_path=str(tmp_path / "test.db"),
        results_dir=str(tmp_path / "results"),
        max_concurrent_agents=2,
        port=0,  # random port
    )
    d = DobbyDaemon(config)
    await d.initialize()
    yield d
    await d.shutdown()


async def test_daemon_initializes(daemon):
    assert daemon.store is not None
    assert daemon.queue is not None
    assert daemon.dispatcher is not None


async def test_daemon_processes_task(daemon):
    # Register a project
    project = Project(name="test-project", repo="/tmp/test-repo")
    await daemon.store.register_project(project)

    # Add a task
    task = Task(project="test-project", description="Say hello", exit_criteria="done")
    await daemon.store.create_task(task)

    # Mock the dispatcher to return a result without actually spawning claude
    mock_result_output = '---DOBBY_RESULT---\n{"status": "completed", "summary": "Hello!", "pr_url": null, "files_changed": [], "metrics": {}, "error": null}\n---END_DOBBY_RESULT---'

    mock_proc = AsyncMock()
    mock_proc.communicate = AsyncMock(return_value=(mock_result_output.encode(), b""))
    mock_proc.returncode = 0
    mock_proc.pid = 1

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
        # Run one iteration of the daemon loop
        await daemon.tick()

    # Task should now be completed
    updated = await daemon.store.get_task(task.id)
    assert updated.status.value == "completed"

    # Result should be stored
    result = await daemon.store.get_result(task.id)
    assert result is not None
    assert result.summary == "Hello!"


async def test_daemon_skips_locked_projects(daemon):
    project = Project(name="locked-proj", repo="/tmp")
    await daemon.store.register_project(project)
    task = Task(project="locked-proj", description="Should skip")
    await daemon.store.create_task(task)

    daemon.queue.lock_project("locked-proj")
    await daemon.tick()

    # Task should still be pending
    t = await daemon.store.get_task(task.id)
    assert t.status.value == "pending"


async def test_daemon_graceful_shutdown(daemon):
    # Should not raise
    await daemon.shutdown()


async def test_daemon_marks_orphaned_tasks_on_restart(daemon):
    """Tasks that were 'running' when daemon crashed should be marked failed."""
    task = Task(project="test", description="Was running")
    tid = await daemon.store.create_task(task)
    await daemon.store.update_task(tid, status="running")

    # Simulate restart: create new daemon with same DB
    config = daemon.config
    await daemon.shutdown()
    d2 = DobbyDaemon(config)
    await d2.initialize()

    t = await d2.store.get_task(tid)
    assert t.status.value == "failed"
    await d2.shutdown()
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_daemon_integration.py -v`
Expected: FAIL

- [ ] **Step 3: Implement DobbyDaemon**

Create `dobby/core/daemon.py`:
- `DobbyDaemon` class:
  - `__init__(self, config: DobbyConfig)`
  - `initialize()`:
    1. Create SqliteStore, initialize
    2. Create QueueManager with store
    3. Create Dispatcher with config values
    4. Create GPUMonitor with config poll interval
    5. Mark any `running` tasks as `failed` (daemon restart recovery)
  - `tick()` — one iteration of the main loop:
    1. `queue.check_dependencies()` — promote `after` tasks
    2. Get next task from queue (respects locks + priority)
    3. If task found:
       - Get project from store
       - Lock project
       - Update task status to `running`
       - Dispatch agent
       - On completion: store result, update task status, unlock project
       - On failure: store failure result, update task, unlock
    4. Check scheduled tasks (cron/interval), spawn due ones
    5. Check GPU monitor for completed jobs
  - `run()` — the main async loop:
    1. Start API server (`create_app` on configured port)
    2. Loop: call `tick()` every 5 seconds
    3. Handle SIGTERM/SIGINT for graceful shutdown
  - `shutdown()`:
    1. Stop accepting new tasks
    2. Wait for running agents (60s timeout)
    3. Close store

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_daemon_integration.py -v`
Expected: All PASS

- [ ] **Step 5: Commit**

```bash
git add dobby/core/daemon.py tests/test_daemon_integration.py
git commit -m "feat(dobby): daemon main loop with task processing and recovery"
```

---

### Task 11: CLI Client + Commands

**Files:**
- Create: `dobby/cli/client.py`
- Create: `dobby/cli/main.py`
- Create: `tests/test_cli.py`

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

```python
# tests/test_cli.py
import pytest
import json
from unittest.mock import AsyncMock, patch, MagicMock
from click.testing import CliRunner
from dobby.cli.main import cli


@pytest.fixture
def runner():
    return CliRunner()


def mock_response(data, status=200):
    resp = MagicMock()
    resp.status = status
    resp.json = AsyncMock(return_value=data)
    resp.__aenter__ = AsyncMock(return_value=resp)
    resp.__aexit__ = AsyncMock(return_value=False)
    return resp


def test_add_task(runner):
    with patch("dobby.cli.client.DobbyClient.post", new_callable=AsyncMock) as mock_post:
        mock_post.return_value = {"id": "t-001", "status": "pending", "project": "spare", "description": "Test"}
        result = runner.invoke(cli, ["add", "Test task", "--project", "spare"])
    assert result.exit_code == 0
    assert "t-001" in result.output


def test_add_task_with_cron(runner):
    with patch("dobby.cli.client.DobbyClient.post", new_callable=AsyncMock) as mock_post:
        mock_post.return_value = {"id": "t-002", "status": "active", "project": "spare", "description": "Nightly eval"}
        result = runner.invoke(cli, ["add", "Nightly eval", "--project", "spare", "--cron", "0 3 * * *"])
    assert result.exit_code == 0
    mock_post.assert_called_once()
    call_data = mock_post.call_args[1].get("json") or mock_post.call_args[0][1]
    assert call_data.get("schedule", {}).get("type") == "cron"


def test_status(runner):
    with patch("dobby.cli.client.DobbyClient.get", new_callable=AsyncMock) as mock_get:
        mock_get.return_value = [
            {"id": "t-001", "project": "spare", "description": "Task A", "status": "running", "priority": "normal"},
            {"id": "t-002", "project": "memagent", "description": "Task B", "status": "pending", "priority": "high"},
        ]
        result = runner.invoke(cli, ["status"])
    assert result.exit_code == 0
    assert "spare" in result.output
    assert "running" in result.output


def test_status_project_filter(runner):
    with patch("dobby.cli.client.DobbyClient.get", new_callable=AsyncMock) as mock_get:
        mock_get.return_value = [
            {"id": "t-001", "project": "spare", "description": "Task A", "status": "running", "priority": "normal"},
        ]
        result = runner.invoke(cli, ["status", "spare"])
    assert result.exit_code == 0
    mock_get.assert_called_once()


def test_results(runner):
    with patch("dobby.cli.client.DobbyClient.get", new_callable=AsyncMock) as mock_get:
        mock_get.return_value = {
            "task_id": "t-001", "status": "completed", "summary": "Done",
            "artifacts": [], "metrics": {}, "cost": {"cost_usd": 0.10},
        }
        result = runner.invoke(cli, ["results", "t-001"])
    assert result.exit_code == 0
    assert "Done" in result.output


def test_projects(runner):
    with patch("dobby.cli.client.DobbyClient.get", new_callable=AsyncMock) as mock_get:
        mock_get.return_value = [
            {"name": "spare", "repo": "/path/spare", "context": "RL"},
            {"name": "memagent", "repo": "/path/memagent", "context": "Memory"},
        ]
        result = runner.invoke(cli, ["projects"])
    assert result.exit_code == 0
    assert "spare" in result.output
    assert "memagent" in result.output


def test_cancel(runner):
    with patch("dobby.cli.client.DobbyClient.patch", new_callable=AsyncMock) as mock_patch:
        mock_patch.return_value = {"id": "t-001", "status": "cancelled"}
        result = runner.invoke(cli, ["cancel", "t-001"])
    assert result.exit_code == 0
    assert "cancelled" in result.output.lower()


def test_retry(runner):
    with patch("dobby.cli.client.DobbyClient.post", new_callable=AsyncMock) as mock_post:
        mock_post.return_value = {"id": "t-001-retry-1", "status": "pending"}
        result = runner.invoke(cli, ["retry", "t-001"])
    assert result.exit_code == 0


def test_stats(runner):
    with patch("dobby.cli.client.DobbyClient.get", new_callable=AsyncMock) as mock_get:
        mock_get.return_value = {
            "pending": 3, "running": 1, "completed": 10, "failed": 2,
            "total_cost_usd": 4.20, "total_tokens": 500000,
        }
        result = runner.invoke(cli, ["stats"])
    assert result.exit_code == 0
    assert "pending" in result.output.lower()


def test_plan(runner):
    with patch("dobby.cli.client.DobbyClient.post", new_callable=AsyncMock) as mock_post:
        mock_post.return_value = {"id": "t-003", "status": "drafting", "mode": "planned"}
        result = runner.invoke(cli, ["plan", "Refactor orchestrator", "--project", "memagent"])
    assert result.exit_code == 0
    assert "t-003" in result.output
    call_data = mock_post.call_args[1].get("json") or mock_post.call_args[0][1]
    assert call_data.get("mode") == "planned"


def test_logs(runner, tmp_path):
    log_file = tmp_path / "results" / "t-001" / "agent.log"
    log_file.parent.mkdir(parents=True)
    log_file.write_text("Agent working...\nDone!")
    with patch("dobby.cli.client.DobbyClient.get", new_callable=AsyncMock) as mock_get:
        mock_get.return_value = {"task_id": "t-001", "artifacts": [{"type": "log", "path": str(log_file)}]}
        result = runner.invoke(cli, ["logs", "t-001"])
    assert result.exit_code == 0


def test_register(runner):
    with patch("dobby.cli.client.DobbyClient.post", new_callable=AsyncMock) as mock_post:
        mock_post.return_value = {"name": "myproject", "repo": "/path/to/repo"}
        result = runner.invoke(cli, ["register", "/path/to/repo", "--name", "myproject"])
    assert result.exit_code == 0
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_cli.py -v`
Expected: FAIL

- [ ] **Step 3: Implement DobbyClient**

Create `dobby/cli/client.py`:
- `DobbyClient` class:
  - `__init__(self, base_url="http://127.0.0.1:8420")`
  - `get(path, params=None) -> dict/list`: async HTTP GET, return JSON
  - `post(path, json=None) -> dict`: async HTTP POST
  - `patch(path, json=None) -> dict`: async HTTP PATCH
  - `delete(path) -> dict`: async HTTP DELETE
  - Uses `aiohttp.ClientSession` for requests
  - Handle connection errors with friendly message: "Dobby daemon not running. Start with: dobby start"

- [ ] **Step 4: Implement CLI commands**

Create `dobby/cli/main.py`:
- Use `click` to define CLI group `cli`
- Helper `run_async(coro)` — runs async function in `asyncio.run()`
- Commands:
  - `add`: `@cli.command()` with args `description`, options `--project` (required), `--exit-criteria`, `--priority`, `--cron`, `--every`, `--after`, `--infra`, `--retries`, `--checkpoint`, `--timeout`
  - `plan`: same as add but sets `mode: "planned"`
  - `status [project]`: GET `/tasks`, optional project filter, format as table
  - `results <task_id_or_project>`: GET result or list results
  - `projects`: GET `/projects`, format as table
  - `register <path> --name <name>`: POST to `/projects`
  - `cancel <task_id>`: PATCH status to cancelled
  - `retry <task_id>`: POST to `/tasks/{id}/retry`
  - `stats`: GET `/stats`
  - `logs <task_id>`: read and print log file from results dir
  - `pause` / `resume`: PATCH daemon state
  - `start`: start the daemon (import and run `DobbyDaemon`)
- Format output with colors using `click.style()` — green for completed, red for failed, yellow for running

- [ ] **Step 5: Run tests to verify they pass**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/test_cli.py -v`
Expected: All PASS

- [ ] **Step 6: Install package in dev mode and test CLI entry point**

Run: `cd /Users/simonyu/local/local_orby && pip install -e ".[dev]"` then `dobby --help`
Expected: Shows help text with all commands

- [ ] **Step 7: Commit**

```bash
git add dobby/cli/ tests/test_cli.py
git commit -m "feat(dobby): CLI with all v1a commands"
```

---

### Task 12: End-to-End Smoke Test

**Files:**
- Create: `tests/test_e2e.py`

- [ ] **Step 1: Write end-to-end test**

```python
# tests/test_e2e.py
"""
End-to-end test: start daemon, add task via API, verify dispatch and result collection.
Uses mocked Claude subprocess to avoid real API calls.
"""
import pytest
import asyncio
import json
from unittest.mock import patch, AsyncMock
from aiohttp.test_utils import TestClient, TestServer
from dobby.core.daemon import DobbyDaemon
from dobby.core.api import create_app
from dobby.config import DobbyConfig
from dobby.models import Project


@pytest.fixture
async def e2e(tmp_path):
    config = DobbyConfig(
        db_path=str(tmp_path / "e2e.db"),
        results_dir=str(tmp_path / "results"),
        max_concurrent_agents=2,
        port=0,
    )
    daemon = DobbyDaemon(config)
    await daemon.initialize()

    # Register a test project
    await daemon.store.register_project(Project(
        name="test-project",
        repo=str(tmp_path / "fake-repo"),
        context="Test project for e2e",
    ))

    app = create_app(daemon.store, daemon.dispatcher)
    server = TestServer(app)
    client = TestClient(server)
    await client.start_server()

    yield {"daemon": daemon, "client": client, "config": config}

    await client.close()
    await daemon.shutdown()


async def test_full_lifecycle(e2e):
    client = e2e["client"]
    daemon = e2e["daemon"]

    # 1. Create a task via API
    resp = await client.post("/tasks", json={
        "project": "test-project",
        "description": "Write a hello world script",
        "exit_criteria": "File created",
    })
    assert resp.status == 201
    task_data = await resp.json()
    task_id = task_data["id"]

    # 2. Verify task is pending
    resp = await client.get(f"/tasks/{task_id}")
    data = await resp.json()
    assert data["status"] == "pending"

    # 3. Mock claude subprocess and run one daemon tick
    dobby_result = json.dumps({
        "status": "completed",
        "summary": "Created hello.py",
        "pr_url": None,
        "files_changed": ["hello.py"],
        "metrics": {},
        "error": None,
    })
    mock_output = f"Working on task...\n---DOBBY_RESULT---\n{dobby_result}\n---END_DOBBY_RESULT---"

    mock_proc = AsyncMock()
    mock_proc.communicate = AsyncMock(return_value=(mock_output.encode(), b""))
    mock_proc.returncode = 0
    mock_proc.pid = 9999

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
        await daemon.tick()

    # 4. Verify task is completed
    resp = await client.get(f"/tasks/{task_id}")
    data = await resp.json()
    assert data["status"] == "completed"

    # 5. Verify result exists
    resp = await client.get(f"/results/{task_id}")
    assert resp.status == 200
    result = await resp.json()
    assert result["summary"] == "Created hello.py"

    # 6. Verify stats
    resp = await client.get("/stats")
    data = await resp.json()
    assert data["completed"] >= 1


async def test_task_failure_lifecycle(e2e):
    client = e2e["client"]
    daemon = e2e["daemon"]

    resp = await client.post("/tasks", json={
        "project": "test-project",
        "description": "This will fail",
    })
    task_id = (await resp.json())["id"]

    mock_proc = AsyncMock()
    mock_proc.communicate = AsyncMock(return_value=(b"Error: something went wrong", b""))
    mock_proc.returncode = 1
    mock_proc.pid = 1234

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
        await daemon.tick()

    resp = await client.get(f"/tasks/{task_id}")
    data = await resp.json()
    assert data["status"] == "failed"

    result = await (await client.get(f"/results/{task_id}")).json()
    assert result["status"] == "failed"


async def test_cancel_running_task(e2e):
    client = e2e["client"]
    daemon = e2e["daemon"]

    resp = await client.post("/tasks", json={
        "project": "test-project",
        "description": "Long task",
    })
    task_id = (await resp.json())["id"]

    # Cancel before it runs
    resp = await client.patch(f"/tasks/{task_id}", json={"status": "cancelled"})
    assert resp.status == 200
    data = await resp.json()
    assert data["status"] == "cancelled"

    # Daemon tick should skip it
    await daemon.tick()
    resp = await client.get(f"/tasks/{task_id}")
    data = await resp.json()
    assert data["status"] == "cancelled"
```

- [ ] **Step 2: Run all tests**

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/ -v`
Expected: All PASS

- [ ] **Step 3: Commit**

```bash
git add tests/test_e2e.py
git commit -m "test(dobby): end-to-end smoke tests for full task lifecycle"
```

---

### Task 13: Polish + README

**Files:**
- Modify: `dobby/cli/main.py` — add `start` command that runs the daemon
- Create: `dobby/__main__.py` — allow `python -m dobby start`

- [ ] **Step 1: Add daemon start command to CLI**

Add to `dobby/cli/main.py`:
```python
@cli.command()
@click.option("--config", "-c", default=None, help="Path to dobby.yaml")
@click.option("--daemon", "-d", is_flag=True, help="Run in background")
def start(config, daemon):
    """Start the Dobby daemon."""
    from dobby.config import DobbyConfig
    from dobby.core.daemon import DobbyDaemon
    cfg = DobbyConfig.load(config) if config else DobbyConfig()
    d = DobbyDaemon(cfg)
    click.echo(f"Dobby listening on {cfg.host}:{cfg.port}")
    asyncio.run(d.run())
```

- [ ] **Step 2: Create __main__.py**

```python
# dobby/__main__.py
from dobby.cli.main import cli
cli()
```

- [ ] **Step 3: Test manual run**

Run: `cd /Users/simonyu/local/local_orby && dobby --help`
Expected: Full help with all commands listed

Run: `dobby start &` then `dobby status` then `dobby projects`
Expected: Daemon starts, status returns empty list, projects returns empty list. Kill with Ctrl+C.

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

Run: `cd /Users/simonyu/local/local_orby && python -m pytest tests/ -v --tb=short`
Expected: All tests PASS

- [ ] **Step 5: Commit**

```bash
git add dobby/__main__.py dobby/cli/main.py
git commit -m "feat(dobby): daemon start command and __main__ entry point"
```

- [ ] **Step 6: Final commit — tag v0.1.0**

```bash
git tag v0.1.0
```
