# Loom Phase 3 — Orchestration Implementation Plan

## Context

Phase 1 (Foundation) and Phase 2 (Decomposition) are complete. The system has Postgres CRUD, Redis caching, 11 MCP tools, a working decomposition pipeline with 4 built-in skills, and full integration tests. Phase 3 transforms Loom from a task management system into a **fully autonomous orchestration engine** where agents run end-to-end without human intervention after initial decomposition.

### What Already Exists
- Task lifecycle: create → claim → done/fail with atomic Postgres writes
- Dependency resolution: blocked → pending auto-unblocking, epic auto-completion
- Event bus: Redis Stream (persistent) + pub/sub (real-time) + escalation queue (list)
- Skills system: loader, runner, 4 built-in skills, Jinja2 templates, JSON extraction
- Escalation queue primitives: `push_escalation()` / `pop_escalation()` in `bus/queue.py`
- Subscriber: `subscribe_events()` async iterator in `bus/subscriber.py`
- `SELECT FOR UPDATE SKIP LOCKED` for concurrent claiming
- All Redis key patterns centralized in `bus/channels.py`

### What Phase 3 Adds
1. **Claim TTL + heartbeat** — auto-release stale claims from crashed agents
2. **Retry logic + dead letter queue** — exponential backoff, max retries, permanent failure handling
3. **Workflow system** — YAML-defined multi-step processes with state tracking
4. **Orchestrator loop** — autonomous agent that monitors, delegates, and resolves escalations
5. **Additional skills** — debug_failure, review_output, generate_test_plan, write_spec
6. **New MCP tools + CLI** — workflow management, orchestrator control, heartbeat

---

## Work Streams (Parallelizable Chunks)

The plan is organized into 7 work streams. **A is the foundation** — all other streams depend on it. After A, streams B/C/D can run in parallel, then E depends on B+C, then F depends on everything, and G runs last.

```
        ┌──── B (Claim TTL) ────┐
A ──────┤                       ├──── E (Orchestrator) ──── F (MCP + CLI) ──── G (Tests)
(Schema)├──── C (Retry/DLQ) ────┤
        └──── D (Skills)  ──────┘
              (independent)
```

---

## Stream A: Schema + Store Foundation

**Goal:** Add database tables and columns needed by all other streams.

### A1. New Migration File

**Create `loom/db/migrations/002_orchestration.sql`**

```sql
-- Claim TTL: track when claims expire
ALTER TABLE tasks ADD COLUMN claim_expires_at TIMESTAMPTZ;

-- Retry tracking
ALTER TABLE tasks ADD COLUMN retry_count INT NOT NULL DEFAULT 0;
ALTER TABLE tasks ADD COLUMN max_retries INT NOT NULL DEFAULT 3;
ALTER TABLE tasks ADD COLUMN last_failed_at TIMESTAMPTZ;
ALTER TABLE tasks ADD COLUMN retry_after TIMESTAMPTZ;

-- Dead letter: permanently failed tasks
ALTER TABLE tasks ADD COLUMN dead_letter BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE tasks ADD COLUMN dead_letter_reason TEXT;

-- Index for claim expiry sweeps
CREATE INDEX idx_tasks_claim_expires ON tasks(claim_expires_at)
    WHERE status = 'claimed' AND claim_expires_at IS NOT NULL;

-- Index for retry sweeps
CREATE INDEX idx_tasks_retry_after ON tasks(retry_after)
    WHERE status = 'failed' AND retry_after IS NOT NULL AND dead_letter = FALSE;

-- Workflow state table
CREATE TABLE workflow_runs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    project_id UUID NOT NULL REFERENCES projects(id),
    workflow_name TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'running',  -- running, paused, completed, failed
    current_step TEXT NOT NULL DEFAULT '',
    inputs JSONB DEFAULT '{}',
    state JSONB DEFAULT '{}',               -- accumulated state across steps
    outputs JSONB DEFAULT '{}',
    error TEXT,
    started_at TIMESTAMPTZ DEFAULT NOW(),
    completed_at TIMESTAMPTZ,
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_workflow_runs_project ON workflow_runs(project_id, status);

-- Workflow step log (audit trail)
CREATE TABLE workflow_steps (
    id BIGSERIAL PRIMARY KEY,
    run_id UUID NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
    step_name TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'running',  -- running, completed, failed, skipped
    inputs JSONB DEFAULT '{}',
    outputs JSONB DEFAULT '{}',
    error TEXT,
    started_at TIMESTAMPTZ DEFAULT NOW(),
    completed_at TIMESTAMPTZ
);

CREATE INDEX idx_workflow_steps_run ON workflow_steps(run_id);
```

### A2. Store Layer Additions

**Modify `loom/graph/store.py`** — Add functions:

```python
# Claim TTL management
async def set_claim_expiry(pool, task_id, expires_at) -> Task
async def get_expired_claims(pool, project_id) -> list[Task]
async def release_expired_claim(pool, task_id) -> Task  # claimed → pending

# Retry management
async def increment_retry(pool, task_id, retry_after) -> Task
async def get_retryable_tasks(pool, project_id) -> list[Task]
async def mark_dead_letter(pool, task_id, reason) -> Task

# Workflow state CRUD
async def create_workflow_run(pool, project_id, workflow_name, inputs) -> dict
async def get_workflow_run(pool, run_id) -> dict
async def update_workflow_run(pool, run_id, **fields) -> dict
async def list_workflow_runs(pool, project_id, status?) -> list[dict]
async def record_workflow_step(pool, run_id, step_name, status, inputs?, outputs?, error?) -> int
```

### A3. Event Type Additions

**Modify `loom/bus/events.py`** — Add:
```python
TASK_RETRIED = "task.retried"
TASK_DEAD_LETTER = "task.dead_letter"
CLAIM_EXPIRED = "claim.expired"
WORKFLOW_STARTED = "workflow.started"
WORKFLOW_STEP_COMPLETED = "workflow.step_completed"
WORKFLOW_COMPLETED = "workflow.completed"
WORKFLOW_FAILED = "workflow.failed"
```

### A4. Channel Additions

**Modify `loom/bus/channels.py`** — Add:
```python
def claim_ttl_key(project_id: str) -> str:
    return f"loom:{project_id}:claims:ttl"

def dead_letter_set(project_id: str) -> str:
    return f"loom:{project_id}:tasks:dead_letter"

def workflow_channel(project_id: str) -> str:
    return f"loom:{project_id}:workflows"
```

### Files Touched
| File | Action |
|------|--------|
| `loom/db/migrations/002_orchestration.sql` | **Create** |
| `loom/graph/store.py` | **Modify** — add 10 functions |
| `loom/bus/events.py` | **Modify** — add 7 event types |
| `loom/bus/channels.py` | **Modify** — add 3 key patterns |

---

## Stream B: Claim TTL + Heartbeat

**Goal:** Tasks auto-release from crashed/stuck agents after a configurable timeout.

**Depends on:** Stream A

### B1. Config Addition

**Modify `loom/config.py`** — Add to `LoomConfig`:
```python
class OrchestrationConfig(BaseModel):
    claim_ttl_seconds: int = 600           # 10 minutes default
    heartbeat_interval_seconds: int = 120  # 2 minutes
    sweep_interval_seconds: int = 60       # 1 minute
```
Add `orchestration: OrchestrationConfig` field to `LoomConfig`.
Add `LOOM_CLAIM_TTL` env var override.

### B2. Claim TTL Logic

**Modify `loom/graph/store.py`** — Update `claim_task()`:
- After setting `status = 'claimed'`, also set `claim_expires_at = NOW() + interval`.
- Accept `ttl_seconds: int` parameter (from config).

**Add `set_claim_expiry()`** — extends TTL (heartbeat).

**Add `get_expired_claims()`** — `WHERE status = 'claimed' AND claim_expires_at < NOW()`.

**Add `release_expired_claim()`** — `UPDATE tasks SET status = 'pending', assignee = NULL, claim_expires_at = NULL WHERE id = $1 AND status = 'claimed'`. Returns the task for cache sync.

### B3. Heartbeat MCP Tool

**Modify `loom/mcp/tools.py`** — Add:
```python
@mcp.tool()
async def loom_heartbeat(ctx: Context, task_id: str) -> dict:
    """Extend claim TTL for an in-progress task."""
    app = _ctx(ctx)
    ttl = app.config.orchestration.claim_ttl_seconds
    expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl)
    task = await store.set_claim_expiry(app.pool, task_id, expires_at)
    await cache.sync_task(app.redis, task)
    return {"task_id": task_id, "expires_at": expires_at.isoformat()}
```

### B4. Claim Sweep (Background Task)

**Create `loom/orchestration/__init__.py`** — Empty.

**Create `loom/orchestration/sweeper.py`**:
- `sweep_expired_claims(pool, redis, project_id, config)` — async function:
  1. Query `get_expired_claims()`
  2. For each: `release_expired_claim()` → `cache.sync_task()` → `cache.add_to_ready_queue()` → publish `CLAIM_EXPIRED` event
  3. Returns count of released tasks

This runs inside the orchestrator loop (Stream E) but is a standalone function that can be tested independently.

### Files Touched
| File | Action |
|------|--------|
| `loom/config.py` | **Modify** — add OrchestrationConfig |
| `loom/graph/store.py` | **Modify** — update claim_task, add 3 TTL functions |
| `loom/mcp/tools.py` | **Modify** — add loom_heartbeat |
| `loom/orchestration/__init__.py` | **Create** |
| `loom/orchestration/sweeper.py` | **Create** |

---

## Stream C: Retry Logic + Dead Letter Queue

**Goal:** Failed tasks automatically retry with exponential backoff. Permanently failed tasks go to dead letter.

**Depends on:** Stream A

### C1. Retry Constants

**Modify `loom/config.py`** — Add to `OrchestrationConfig`:
```python
max_retries: int = 3
retry_base_delay_seconds: int = 60        # 1 minute base
retry_max_delay_seconds: int = 3600       # 1 hour cap
```

### C2. Retry Logic

**Create `loom/orchestration/retry.py`**:

`compute_retry_delay(retry_count, base_delay, max_delay) -> int`:
- Exponential backoff: `min(base_delay * 2^retry_count, max_delay)`
- Add jitter: `± 20%` random

`process_failed_task(pool, redis, task, config, project_id)`:
- If `task.retry_count < config.max_retries`:
  - Compute delay
  - `store.increment_retry(pool, task_id, retry_after=now+delay)`
  - Publish `TASK_RETRIED` event
- Else:
  - `store.mark_dead_letter(pool, task_id, reason="Max retries exceeded")`
  - `cache.sync_task()` + add to dead letter set
  - Publish `TASK_DEAD_LETTER` event

`sweep_retryable_tasks(pool, redis, project_id)`:
- Query `store.get_retryable_tasks()` — failed tasks where `retry_after <= NOW()` and `dead_letter = FALSE`
- For each: reset status to `pending`, clear assignee, `cache.sync_task()`, `cache.add_to_ready_queue()`
- Returns count

### C3. Integration with fail_task

**Modify `loom/mcp/tools.py`** — Update `loom_fail`:
- After existing logic, call `process_failed_task()` to handle retry/DLQ
- This replaces the simple one-shot failure behavior

### C4. Dead Letter MCP Tool

**Modify `loom/mcp/tools.py`** — Add:
```python
@mcp.tool()
async def loom_dead_letter(ctx: Context, limit: int = 10) -> list[dict]:
    """List tasks in the dead letter queue (permanently failed)."""
```

### Files Touched
| File | Action |
|------|--------|
| `loom/config.py` | **Modify** — extend OrchestrationConfig |
| `loom/orchestration/retry.py` | **Create** |
| `loom/mcp/tools.py` | **Modify** — update loom_fail, add loom_dead_letter |

---

## Stream D: Additional Built-in Skills

**Goal:** Add 4 new skills for orchestration use cases. Fully independent of other streams.

**Depends on:** Nothing (uses existing skills infrastructure)

### D1. Skill Files

**Create `loom/skills/builtin/debug_failure.md`**:
- Inputs: `task_title`, `task_context`, `error_message`, `output`
- Outputs: JSON with `diagnosis`, `fix_steps`, `suggested_tasks` (new tasks to create)
- Purpose: When a task fails, analyze the error and propose fix tasks

**Create `loom/skills/builtin/review_output.md`**:
- Inputs: `task_title`, `done_when`, `output`
- Outputs: JSON with `passed: bool`, `issues: list[str]`, `suggestions: list[str]`
- Purpose: Check task output against its acceptance criteria

**Create `loom/skills/builtin/generate_test_plan.md`**:
- Inputs: `task_title`, `task_context`, `implementation_notes`
- Outputs: JSON with `test_cases: list[{name, description, type, expected_outcome}]`
- Purpose: Generate test cases for completed implementation work

**Create `loom/skills/builtin/write_spec.md`**:
- Inputs: `goal`, `context`, `constraints`
- Outputs: JSON with `spec` object containing `overview`, `requirements`, `technical_approach`, `api_contracts`, `data_models`
- Purpose: Convert high-level requirements into a technical specification

### Files Touched
| File | Action |
|------|--------|
| `loom/skills/builtin/debug_failure.md` | **Create** |
| `loom/skills/builtin/review_output.md` | **Create** |
| `loom/skills/builtin/generate_test_plan.md` | **Create** |
| `loom/skills/builtin/write_spec.md` | **Create** |

---

## Stream E: Workflow System

**Goal:** YAML-defined multi-step processes that execute skills and create tasks in sequence.

**Depends on:** Stream A (workflow tables)

### E1. Workflow Definition Format

Workflows are YAML files with this structure:
```yaml
name: ship_feature
version: "1.0"
description: "End-to-end feature delivery"
inputs:
  - goal
  - context

steps:
  - name: write_spec
    skill: write_spec
    inputs:
      goal: "{{ goal }}"
      context: "{{ context }}"
      constraints: ""
    outputs_key: spec

  - name: decompose
    skill: decompose_project
    inputs:
      goal: "{{ goal }}"
      context: "{{ outputs.spec }}"
      depth: 3
    outputs_key: task_graph

  - name: confirm
    type: confirm          # Pauses for human confirmation
    message: "Review the proposed task graph before proceeding."

  - name: write_tasks
    type: action            # Built-in action, not a skill
    action: write_task_graph
    inputs:
      tasks: "{{ outputs.task_graph }}"
```

Step types:
- `skill` (default) — run a skill via `runner.run_skill()`
- `confirm` — pause workflow, wait for human confirmation
- `action` — built-in action (write_task_graph, wait_for_tasks, etc.)
- `condition` — branch based on previous outputs

### E2. Workflow Loader

**Create `loom/workflows/__init__.py`** — Empty.

**Create `loom/workflows/loader.py`**:
```python
class WorkflowStep(BaseModel):
    name: str
    type: str = "skill"             # skill | confirm | action | condition
    skill: str | None = None
    action: str | None = None
    inputs: dict = {}
    outputs_key: str | None = None
    message: str | None = None      # for confirm steps
    condition: str | None = None    # for condition steps
    on_true: str | None = None      # step name to jump to
    on_false: str | None = None

class WorkflowDefinition(BaseModel):
    name: str
    version: str = "1.0"
    description: str = ""
    inputs: list[str] = []
    steps: list[WorkflowStep] = []
    source_path: str | None = None

def parse_workflow_file(path: Path) -> WorkflowDefinition
def discover_workflows(project_dir?) -> dict[str, WorkflowDefinition]
def load_workflow(name, project_dir?) -> WorkflowDefinition
```

Workflow discovery order mirrors skills: `loom/workflows/builtin/` → `~/.loom/workflows/` → `.loom/workflows/`.

### E3. Workflow Runner

**Create `loom/workflows/runner.py`**:

Core state machine:
```python
async def run_workflow(
    workflow: WorkflowDefinition,
    inputs: dict,
    config: SkillsConfig,
    pool: asyncpg.Pool,
    redis: Redis,
    project_id: str,
    project_dir: str | None = None,
) -> dict:
    """Execute a workflow step by step, persisting state to Postgres."""
```

Implementation:
1. Create `workflow_runs` record via `store.create_workflow_run()`
2. Initialize `state = {"outputs": {}}` + merge inputs
3. For each step in order:
   a. Record step start in `workflow_steps`
   b. Render step inputs via Jinja2 (resolving `{{ outputs.prev_step }}` references)
   c. Execute based on type:
      - `skill`: load skill, call `runner.run_skill()`, store output in `state.outputs[step.outputs_key]`
      - `confirm`: update workflow status to `paused`, return partial result. Resume via `resume_workflow()`.
      - `action`: dispatch to built-in action handler
      - `condition`: evaluate condition, jump to on_true/on_false step
   d. Record step completion
   e. Update `workflow_runs.state` and `current_step`
4. On completion: update status to `completed`, set `completed_at`, publish `WORKFLOW_COMPLETED`
5. On failure: update status to `failed`, set error, publish `WORKFLOW_FAILED`

Resume function:
```python
async def resume_workflow(run_id, pool, redis, config, project_id, project_dir?) -> dict:
    """Resume a paused workflow from its current step."""
```

Built-in actions:
```python
ACTIONS = {
    "write_task_graph": _action_write_task_graph,   # calls decomposer._write_to_db
    "wait_for_tasks": _action_wait_for_tasks,       # polls until all tasks done
}
```

### E4. Built-in Workflow Files

**Create `loom/workflows/builtin/` directory.**

**Create `loom/workflows/builtin/ship_feature.yaml`**:
```yaml
name: ship_feature
version: "1.0"
description: "End-to-end feature delivery: spec → decompose → confirm → execute"
inputs: [goal, context]
steps:
  - name: write_spec
    skill: write_spec
    inputs: { goal: "{{ goal }}", context: "{{ context }}", constraints: "" }
    outputs_key: spec
  - name: decompose
    skill: decompose_project
    inputs: { goal: "{{ goal }}", context: "{{ outputs.spec }}", depth: 3 }
    outputs_key: task_graph
  - name: confirm
    type: confirm
    message: "Review the proposed task graph before writing to database."
  - name: write_tasks
    type: action
    action: write_task_graph
    inputs: { tasks: "{{ outputs.task_graph }}" }
```

**Create `loom/workflows/builtin/debug_and_fix.yaml`**:
```yaml
name: debug_and_fix
version: "1.0"
description: "Diagnose a failed task and create fix tasks"
inputs: [task_id]
steps:
  - name: load_task
    type: action
    action: load_task
    inputs: { task_id: "{{ task_id }}" }
    outputs_key: task
  - name: diagnose
    skill: debug_failure
    inputs:
      task_title: "{{ outputs.task.title }}"
      task_context: "{{ outputs.task.context }}"
      error_message: "{{ outputs.task.output.error | default('Unknown error') }}"
      output: "{{ outputs.task.output }}"
    outputs_key: diagnosis
  - name: create_fix_tasks
    type: action
    action: create_tasks_from_diagnosis
    inputs: { diagnosis: "{{ outputs.diagnosis }}", parent_task_id: "{{ task_id }}" }
```

### Files Touched
| File | Action |
|------|--------|
| `loom/workflows/__init__.py` | **Create** |
| `loom/workflows/loader.py` | **Create** |
| `loom/workflows/runner.py` | **Create** |
| `loom/workflows/builtin/ship_feature.yaml` | **Create** |
| `loom/workflows/builtin/debug_and_fix.yaml` | **Create** |

---

## Stream F: Orchestrator Loop

**Goal:** An autonomous loop that monitors the project, assigns work, handles escalations, and drives completion.

**Depends on:** Streams B, C, D, E (uses TTL sweeps, retry sweeps, skills, workflows)

### F1. Orchestrator Core

**Create `loom/orchestration/loop.py`**:

The orchestrator is an async loop that runs continuously:

```python
async def orchestrator_loop(
    pool: asyncpg.Pool,
    redis: Redis,
    project_id: str,
    config: LoomConfig,
    project_dir: str | None = None,
) -> None:
    """Main orchestrator loop — runs until all tasks are done or interrupted."""
```

Each tick of the loop:
1. **Sweep expired claims** — call `sweeper.sweep_expired_claims()`
2. **Sweep retryable tasks** — call `retry.sweep_retryable_tasks()`
3. **Handle escalations** — pop from escalation queue, run `debug_failure` skill, create fix tasks or reassign
4. **Check project completion** — if all tasks done (no pending/claimed/blocked), publish `PROJECT_COMPLETE`
5. **Sleep** for `config.orchestration.sweep_interval_seconds`

The orchestrator does NOT claim or execute tasks — agents do that via MCP tools. The orchestrator is the "supervisor" that handles failures, timeouts, and coordination.

### F2. Escalation Handler

**Create `loom/orchestration/escalation.py`**:

```python
async def handle_escalation(
    pool, redis, project_id, task_id, message, config, project_dir
) -> dict:
    """Process an escalation: diagnose and create fix tasks."""
```

Flow:
1. Load the failed/blocked task
2. Run `debug_failure` skill with task context + escalation message
3. If skill suggests new tasks: create them as children of the failed task
4. Run `review_output` skill to check if the fix plan is sound
5. Mark escalation as resolved
6. Publish `ESCALATION_RESOLVED` event

### F3. MCP Tools for Orchestrator

**Modify `loom/mcp/tools.py`** — Add:

```python
@mcp.tool()
async def loom_orchestrate(ctx: Context, action: str = "status") -> dict:
    """Orchestrator control: status | start | stop."""

@mcp.tool()
async def loom_workflow(
    ctx: Context, workflow: str, action: str = "start", run_id: str | None = None, **inputs
) -> dict:
    """Start, resume, or check a workflow. action: start | resume | status | list."""
```

### Files Touched
| File | Action |
|------|--------|
| `loom/orchestration/loop.py` | **Create** |
| `loom/orchestration/escalation.py` | **Create** |
| `loom/mcp/tools.py` | **Modify** — add loom_orchestrate, loom_workflow |

---

## Stream G: CLI + MCP + Tests

**Goal:** Wire everything together with CLI commands, update AppContext, and add comprehensive tests.

**Depends on:** All previous streams

### G1. AppContext Update

**Modify `loom/mcp/server.py`**:
- `AppContext` already has `config` and `project_dir` from Phase 2
- No structural changes needed — just ensure lifespan passes orchestration config

### G2. CLI Commands

**Modify `loom/cli.py`** — Add:

`loom workflow list`:
- Discover and list available workflows

`loom workflow run <name> [--input key=value...]`:
- Run a workflow with given inputs
- Display progress step by step
- Pause at confirm steps, prompt user

`loom workflow status [run_id]`:
- Show workflow run status

`loom orchestrate`:
- Options: `--once` (single sweep then exit), `--daemon` (continuous loop)
- Runs the orchestrator loop
- Displays sweep results (expired claims released, tasks retried, escalations handled)

`loom dead-letter`:
- List dead letter tasks
- Option: `--retry <task_id>` to manually retry a dead letter task

### G3. Integration Tests

**Create `tests/integration/test_orchestration.py`**:

| Test | What It Validates |
|------|-------------------|
| `test_claim_ttl_expiry` | Claimed task with expired TTL gets released back to pending |
| `test_heartbeat_extends_ttl` | Heartbeat pushes claim_expires_at forward |
| `test_retry_exponential_backoff` | Failed task retry_after increases exponentially |
| `test_max_retries_dead_letter` | Task exceeding max_retries goes to dead letter |
| `test_dead_letter_manual_retry` | Dead letter task can be manually retried |
| `test_sweep_expired_claims` | Sweeper releases multiple expired claims in one pass |
| `test_sweep_retryable_tasks` | Retryable tasks reset to pending after delay |

**Create `tests/integration/test_workflows.py`**:

| Test | What It Validates |
|------|-------------------|
| `test_discover_builtin_workflows` | Both built-in workflows found |
| `test_parse_workflow_file` | YAML parsing produces correct steps |
| `test_workflow_skill_step` | Skill step executes and stores output |
| `test_workflow_confirm_pauses` | Confirm step pauses workflow, resume continues |
| `test_workflow_state_persisted` | Workflow state written to Postgres |
| `test_workflow_step_log` | Each step logged to workflow_steps table |
| `test_workflow_failure_handling` | Failed step marks workflow as failed |

**Create `tests/integration/test_escalation.py`**:

| Test | What It Validates |
|------|-------------------|
| `test_escalation_round_trip` | Escalation created → handled → resolved |
| `test_escalation_creates_fix_tasks` | Handler creates child tasks from diagnosis |
| `test_orchestrator_single_sweep` | One orchestrator tick processes claims + retries + escalations |

All tests mock LLM calls via `unittest.mock.patch("loom.skills.runner.AsyncAnthropic")`.

### Files Touched
| File | Action |
|------|--------|
| `loom/cli.py` | **Modify** — add workflow, orchestrate, dead-letter commands |
| `tests/integration/test_orchestration.py` | **Create** |
| `tests/integration/test_workflows.py` | **Create** |
| `tests/integration/test_escalation.py` | **Create** |

---

## Complete File Manifest

### New Files (17)
| File | Stream | Purpose |
|------|--------|---------|
| `loom/db/migrations/002_orchestration.sql` | A | Schema additions |
| `loom/orchestration/__init__.py` | B | Package init |
| `loom/orchestration/sweeper.py` | B | Claim TTL sweep |
| `loom/orchestration/retry.py` | C | Retry + DLQ logic |
| `loom/orchestration/loop.py` | F | Main orchestrator loop |
| `loom/orchestration/escalation.py` | F | Escalation handler |
| `loom/skills/builtin/debug_failure.md` | D | Failure diagnosis skill |
| `loom/skills/builtin/review_output.md` | D | Output review skill |
| `loom/skills/builtin/generate_test_plan.md` | D | Test plan generation skill |
| `loom/skills/builtin/write_spec.md` | D | Spec writing skill |
| `loom/workflows/__init__.py` | E | Package init |
| `loom/workflows/loader.py` | E | Workflow discovery + parsing |
| `loom/workflows/runner.py` | E | Workflow execution engine |
| `loom/workflows/builtin/ship_feature.yaml` | E | Feature delivery workflow |
| `loom/workflows/builtin/debug_and_fix.yaml` | E | Debug workflow |
| `tests/integration/test_orchestration.py` | G | TTL + retry tests |
| `tests/integration/test_workflows.py` | G | Workflow tests |
| `tests/integration/test_escalation.py` | G | Escalation tests |

### Modified Files (6)
| File | Stream | Changes |
|------|--------|---------|
| `loom/config.py` | A+B+C | Add OrchestrationConfig |
| `loom/graph/store.py` | A | Add ~10 functions (TTL, retry, workflow CRUD) |
| `loom/bus/events.py` | A | Add 7 event types |
| `loom/bus/channels.py` | A | Add 3 Redis key patterns |
| `loom/mcp/tools.py` | B+C+F | Add heartbeat, dead_letter, orchestrate, workflow tools |
| `loom/cli.py` | G | Add workflow, orchestrate, dead-letter commands |

---

## Execution Order for Multi-Agent Parallelism

### Wave 1 (Sequential — Foundation)
**Agent 1:** Stream A (schema + store + events + channels)

### Wave 2 (Parallel — 3 agents)
**Agent 2:** Stream B (claim TTL + heartbeat + sweeper)
**Agent 3:** Stream C (retry + DLQ)
**Agent 4:** Stream D (4 new skills)

### Wave 3 (Sequential — depends on Wave 2)
**Agent 5:** Stream E (workflow system)

### Wave 4 (Sequential — depends on everything)
**Agent 6:** Stream F (orchestrator loop + escalation handler)

### Wave 5 (Sequential — final integration)
**Agent 7:** Stream G (CLI commands + all tests)

---

## Verification

1. `uv sync` — no new runtime dependencies needed (anthropic + jinja2 already installed)
2. `uv run pytest tests/ -v` — all Phase 1 + 2 + 3 tests pass
3. `uv run python -c "from loom.workflows.loader import discover_workflows; print(discover_workflows())"` — built-in workflows load
4. `uv run python -c "from loom.skills.loader import discover_skills; print(len(discover_skills()))"` — 8 skills (4 Phase 2 + 4 Phase 3)
5. Manual: `loom up && loom workflow list` — shows ship_feature, debug_and_fix
6. Manual: `loom orchestrate --once` — single sweep runs without error
7. Manual: Create a task, claim it, let TTL expire, verify it returns to pending
