# BUILD_SPEC — Console v2

**Generated:** 2026-05-01 (Opus 4.7 [1m])
**Input:** consultations/recoil/console-v2-architecture/SYNTHESIS.md
**Detail level:** max
**Visual design:** yes — Phase 1 packages exports from JT's Claude Design exploration (manual, pre-build)
**Phases:** 17
**Estimated build time:** 8–10h wall-clock on Studio
**Dispatch target:** Mac Studio (joeturnerlin@100.105.59.118)

---

## Architecture (locked by /architect)

| Concern | Decision | Rationale |
|---|---|---|
| Backend framework | FastAPI + uvicorn | Typed routes, OpenAPI doc, native async streaming, native Pydantic validation, native SSE via `StreamingResponse`. Replaces vanilla `http.server` substrate of review_server.py per synthesis "factor sub-routers" goal. |
| Frontend framework | Solid.js + TypeScript + Vite | Fine-grained reactivity for bidirectional context binding; ~7kb runtime; no virtual DOM (matches "Blender utilitarian density"); TypeScript enforces synthesis Risk #10 (TS↔Pydantic alignment). |
| Type SSOT | Pydantic models in `v2/types.py` → TS via `pydantic-to-typescript` codegen | Synthesis Risk #10 mitigation: "codegen pipeline TypeScript-from-Pydantic in /spec." Generated `editors/v2/src/types.gen.ts` is read-only, regenerated via `npm run gen:types`. CI assertion fails build if types drift. |
| State persistence | SQLite (WAL mode) at `~/.recoil/v2_workspace.db` | Atomic, locked, concurrent-safe (replaces unsafe `write_text(json.dumps(...))`). Cross-project queries (synthesis #27) become a single `SELECT` instead of an O(N-projects) directory walk. SQLite outside Dropbox avoids sync-mid-write corruption. |
| EventBus | In-process, thread-safe, with sync→async bridge | Production-loop runs in a worker thread of the FastAPI process (not subprocess), so the EventBus singleton is reachable from both. Sync emitters (production_loop hooks) → async SSE consumers (FastAPI clients) via `asyncio.run_coroutine_threadsafe`. |
| Server port | 8431 (uvicorn) | Synthesis-specified. Existing review_server.py stays on 8430. Both can run simultaneously during cutover. |
| Always-on | LaunchAgent → `launcher.sh` → uvicorn | Plist + launcher script; manual install on Studio. Launcher resolves Python (venv vs system). |
| Fast-path | Client-side (`fastpath.ts`) with server-side fallback | Synthesis intent: regex commands SKIP Claude *and* skip the round-trip. Client matches first. Server keeps the same regex set as fallback for non-typed inputs (voice transcription, etc.). |
| Claude API | `anthropic` SDK, async streaming, prompt caching | `cache_control: {type:"ephemeral"}` on system prompt + project-level prefix. Mandated by `~/.claude/CLAUDE.md`. |
| Tests | pytest (backend), interleaved per phase | CP-9 standard: ~80 tests per major build. Test files specified inline with each backend phase. |
| Engine routing (/harness) | Mixed Opus/Gemini per phase | Mechanical phases (FastAPI scaffold, route boilerplate, Vite scaffold, template replication) → Gemini. Architecture-bearing phases (state, EventBus, chat, signal store, template registry contract) → Opus. Per-phase `engine:` directive in headers. |

---

## Pre-build prerequisite (NOT a phase — JT manual)

**Phase 0 (manual, you):** Open Claude Design (https://claude.ai/design or the Anthropic Labs gallery). Iterate on Console v2 visual exploration:

1. Three-panel proportions on a 14" MBP (nav width, chat width, stage width).
2. Density treatment (font sizing, vertical rhythm, hover states).
3. Component look: navigator tree node, eval badge (4 states), proposal card, chat bubble, breadcrumb, command palette, live bay row.
4. Color treatment within the existing palette (--bg-*, --accent-*) — confirm what stays and what adjusts.

Export design tokens (CSS variables) and component mockups. Drop them into `consultations/recoil/console-v2-architecture/claude-design-export.md` (or share screenshots in chat — Phase 1 will package them). The build does not start until Phase 0 is done.

If skipped, Phase 1 falls back to extending the existing `recoil/pipeline/DESIGN_SYSTEM.md` with v2 components only — usable but not informed by your fresh design exploration.

---

## Pre-flight Extraction (run locally BEFORE `/dispatch`)

None. This build does not edit `.claude/settings*.json`, `.mcp.json`, `CLAUDE.md`, or `.env`. All deps are listed in Phase 5 (`pyproject.toml` updates) and Phase 10 (`package.json`) and install at build time on Studio.

---

## Dependency Graph

```
Phase  1 (Design System V2):                  none
Phase  2 (Pydantic types):                    none
Phase  3 (SQLite state + tests):              depends_on 2
Phase  4 (EventBus + tests):                  depends_on 2
Phase  5 (FastAPI scaffold):                  depends_on 2, 3, 4
Phase  6 (Read API routes + tests):           depends_on 5
Phase  7 (Mutation API routes + tests):       depends_on 5, 6
Phase  8 (Claude chat endpoint + tests):      depends_on 5, 7
Phase  9 (Pydantic→TS codegen pipeline):      depends_on 2
Phase 10 (Vite + Solid + TS scaffold):        depends_on 1, 9
Phase 11 (Three-panel shell + signal store):  depends_on 10
Phase 12 (Hierarchy navigator):               depends_on 11
Phase 13 (Artifact stage + template registry + 3 templates): depends_on 11
Phase 14 (Chat panel + fast-path + proposal cards): depends_on 11, 13
Phase 15 (Live bay + app core + palette):     depends_on 11, 12, 13, 14
Phase 16 (Templates Set A — 4 templates):     depends_on 13
Phase 17 (Templates Set B + EvalCoverage backend): depends_on 13, 7, 16
```

Independent branches the harness can run in parallel: {3,4,9} can fan out after Phase 2; {12,13,14} can fan out after Phase 11; {16,17} can fan out after Phase 13.

---

## Validation command (harness uses this between phases)

```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS && \
python3 -c "
import ast, pathlib, sys
errs = []
for p in pathlib.Path('recoil/pipeline/v2').rglob('*.py'):
    try: ast.parse(p.read_text())
    except SyntaxError as e: errs.append(f'{p}: {e}')
for p in pathlib.Path('recoil/pipeline').glob('v2_*.py'):
    try: ast.parse(p.read_text())
    except SyntaxError as e: errs.append(f'{p}: {e}')
if errs:
    print('\n'.join(errs)); sys.exit(1)
print('Python OK')
"
```

Each phase appends its own grep + functional check — the harness runs both the global parse + the phase-specific check after every phase.

---

## Phase 1: Design System V2
**depends_on:** none
**engine:** opus

### Files to create
- `recoil/pipeline/editors/v2/DESIGN_SYSTEM_V2.md`

### Inputs
- `recoil/pipeline/DESIGN_SYSTEM.md` (existing — extend, don't replace)
- `consultations/recoil/console-v2-architecture/claude-design-export.md` (if Phase 0 produced one) OR JT's screenshots in conversation
- Synthesis decisions #1–#32 from `consultations/recoil/console-v2-architecture/SYNTHESIS.md`

### Output structure (markdown sections, in this order)

1. **Inheritance statement** — extends existing `DESIGN_SYSTEM.md`. All `--bg-*`, `--text-*`, `--accent-*`, `--font-*`, `--radius-*`, `--transition-*` carry forward unchanged.

2. **Layout tokens** — `--v2-nav-width`, `--v2-chat-width`, `--v2-chrome-top-h`, `--v2-bay-h`, `--v2-bay-h-expanded`, `--v2-resize-handle-w`. Default values from Phase 0 export OR fall back to `260px / 360px / 44px / 48px / 160px / 4px`.

3. **Three-panel shell** — flex column layout: top chrome (height var), main flex row (nav + handle + stage + handle + chat), bottom bay. Resize handles change cursor + accent on hover/drag.

4. **Hierarchy navigator** — tree node CSS (`.v2-tree-node`, `.selected`, `.highlighted`), 4-level indent, score badges (high/mid/low/none color variants), search input.

5. **Artifact stage** — chrome bar with stage label + back button + history breadcrumb; content area scrollable, padded.

6. **Chat panel** — header, context bar (single-line truncated), messages (user/assistant/system variants), input row (textarea + send button). Streaming token cursor: `.v2-msg-assistant.streaming::after { content: '▍'; animation: blink 1s infinite; }`.

7. **SpecEditProposal card** — bordered card with type label (uppercase mono), summary, optional diff block, approve (green) + reject (transparent) buttons. One section per proposal type if visual treatment differs.

8. **4-state eval badge** — `.v2-eval-badge.evaluated|skip|error|config-miss`. Spec the EXACT label format: `[0.82]` / `[---]` / `[!]` / `[?]`.

9. **Live bay row** — dense compact row, columns: id (mono, 80px), status (color-coded), template label, cost (right-aligned, dim).

10. **Breadcrumb + top chrome** — segments with hover, separators, brand mark, cost pill, ⌘K hint button.

11. **Command palette** — overlay (semi-transparent backdrop), centered modal, large input, scrollable results list with name + kbd hint.

### Required content checklist (for Phase 1 sub-agent)
- [ ] Every CSS variable used in Phases 11–17 is defined here
- [ ] Every CSS class name used in Phases 11–17 has a definition here
- [ ] All four eval badge label strings (`[0.82]`, `[---]`, `[!]`, `[?]`) appear verbatim
- [ ] Streaming-token cursor animation is specified
- [ ] Resize handle interaction (cursor + accent) is specified

### Scope boundary
- Do NOT write code (only markdown + CSS in code blocks)
- Do NOT modify the existing `recoil/pipeline/DESIGN_SYSTEM.md`
- Do NOT invent new color tokens — extend within the existing palette only

### Validation
```bash
test -f recoil/pipeline/editors/v2/DESIGN_SYSTEM_V2.md && \
for token in --v2-nav-width --v2-chat-width --v2-chrome-top-h --v2-bay-h --v2-resize-handle-w; do
  grep -q "$token" recoil/pipeline/editors/v2/DESIGN_SYSTEM_V2.md || { echo "MISSING TOKEN: $token"; exit 1; }
done && \
for cls in v2-shell v2-nav v2-stage v2-chat v2-bay v2-tree-node v2-proposal-card v2-eval-badge v2-bay-row v2-breadcrumb-seg v2-palette; do
  grep -q "$cls" recoil/pipeline/editors/v2/DESIGN_SYSTEM_V2.md || { echo "MISSING CLASS: $cls"; exit 1; }
done && \
for label in '\[0\.82\]' '\[---\]' '\[!\]' '\[?\]'; do
  grep -q "$label" recoil/pipeline/editors/v2/DESIGN_SYSTEM_V2.md || { echo "MISSING LABEL: $label"; exit 1; }
done && \
echo "Phase 1 OK"
```

---

## Phase 2: Pydantic Types (Backend SSOT)
**depends_on:** none
**engine:** opus

### Files to create
- `recoil/pipeline/v2/__init__.py` (empty)
- `recoil/pipeline/v2/types.py`

### Purpose
The single source of truth for every type that crosses the wire. Frontend reads `editors/v2/src/types.gen.ts` (Phase 9 codegen output) generated FROM this file. Any type used by both backend and frontend MUST be defined here as a Pydantic model.

### `recoil/pipeline/v2/types.py` — exact contents

```python
"""
Console v2 type SSOT.

These Pydantic models define every type that crosses the backend/frontend wire.
The TypeScript mirrors are auto-generated from this module (see Phase 9).
DO NOT define a wire type anywhere else.
"""
from __future__ import annotations
from enum import Enum
from typing import Annotated, Any, List, Literal, Optional, Union

from pydantic import BaseModel, Field, ConfigDict


# ── ArtifactContext ───────────────────────────────────────────────────────────

class ArtifactContext(BaseModel):
    """Workspace selection + focus. Enriches every Claude chat message."""
    model_config = ConfigDict(extra="forbid")

    active_project:       Optional[str]   = None
    active_episode:       Optional[str]   = None  # "EP001"
    active_scene:         Optional[str]   = None  # "scene_2"
    active_beat:          Optional[str]   = None  # "B7"
    active_take:          Optional[str]   = None  # "B7-take-3"
    selected_beats:       List[str]       = Field(default_factory=list)
    selected_takes:       List[str]       = Field(default_factory=list)
    artifact_type:        Optional[str]   = None  # "TakesBrowser"
    focused_artifact_id:  Optional[str]   = None

    def as_system_prefix(self) -> str:
        """Compact one-line context injected at start of every Claude system msg."""
        parts = []
        if self.active_project: parts.append(f"project={self.active_project}")
        if self.active_episode: parts.append(f"episode={self.active_episode}")
        if self.active_beat:    parts.append(f"beat={self.active_beat}")
        if self.active_take:    parts.append(f"take={self.active_take}")
        if self.selected_beats: parts.append(f"selected_beats=[{','.join(self.selected_beats)}]")
        if self.selected_takes: parts.append(f"selected_takes=[{','.join(self.selected_takes)}]")
        if self.artifact_type:  parts.append(f"viewing={self.artifact_type}")
        ctx = "; ".join(parts) if parts else "no selection"
        return f"[WORKSPACE CONTEXT: {ctx}]"


# ── SpecEditProposal — 8-type discriminated union ────────────────────────────

class _ProposalBase(BaseModel):
    model_config = ConfigDict(extra="forbid")
    rationale: str = ""

class PromptRewritePayload(_ProposalBase):
    type:       Literal["prompt_rewrite"] = "prompt_rewrite"
    beat_id:    str
    take_id:    Optional[str] = None
    new_prompt: str

class BeatInsertionPayload(_ProposalBase):
    type:           Literal["beat_insertion"] = "beat_insertion"
    after_beat_id:  str
    episode:        str
    scene:          str
    beat_template:  dict[str, Any]

class ParameterChangePayload(_ProposalBase):
    type:        Literal["parameter_change"] = "parameter_change"
    beat_ids:    List[str]
    parameters:  dict[str, Any]

class ScriptEditPayload(_ProposalBase):
    type:       Literal["script_edit"] = "script_edit"
    beat_id:    str
    field:      Literal["action", "dialogue", "character", "parenthetical", "transition"]
    new_value:  str

class MultiBeatDirectivePayload(_ProposalBase):
    type:            Literal["multi_beat_directive"] = "multi_beat_directive"
    beat_ids:        List[str]
    directive:       str
    per_beat_notes:  dict[str, str] = Field(default_factory=dict)

class ExtractCutawayPayload(_ProposalBase):
    type:                Literal["extract_cutaway"] = "extract_cutaway"
    source_beat_id:      str
    cutaway_description: str
    position:            Literal["before", "after"] = "after"

class RefSwapPayload(_ProposalBase):
    type:          Literal["ref_swap"] = "ref_swap"
    beat_ids:      List[str]
    character_id:  Optional[str] = None
    location_id:   Optional[str] = None
    new_ref_path:  str

class RetryStrategyEditPayload(_ProposalBase):
    type:           Literal["retry_strategy_edit"] = "retry_strategy_edit"
    failure_mode:   str
    new_strategy:   str
    registry_diff:  str  # unified diff against strategy_registry.py

SpecEditProposal = Annotated[
    Union[
        PromptRewritePayload,
        BeatInsertionPayload,
        ParameterChangePayload,
        ScriptEditPayload,
        MultiBeatDirectivePayload,
        ExtractCutawayPayload,
        RefSwapPayload,
        RetryStrategyEditPayload,
    ],
    Field(discriminator="type"),
]

# Discriminated-union validator helper. Phase 8 (claude_chat) imports this
# rather than appending it — keeps types.py the single home for proposal logic.
def proposal_from_dict_via_pydantic(d: dict) -> SpecEditProposal:
    """Validate a dict against the discriminated proposal union."""
    from pydantic import TypeAdapter
    adapter = TypeAdapter(SpecEditProposal)
    return adapter.validate_python(d)


# ── Eval ──────────────────────────────────────────────────────────────────────

class EvalBadgeState(str, Enum):
    EVALUATED   = "evaluated"
    SKIP        = "skip"
    ERROR       = "error"
    CONFIG_MISS = "config_miss"

def eval_badge_label(state: EvalBadgeState, score: Optional[float] = None) -> str:
    if state == EvalBadgeState.EVALUATED and score is not None:
        return f"[{score:.2f}]"
    return {"evaluated": "[?.??]", "skip": "[---]",
            "error": "[!]", "config_miss": "[?]"}[state.value]


# ── Domain projections (read API shapes) ──────────────────────────────────────

class ProjectSummary(BaseModel):
    name:      str
    has_bible: bool

class EpisodeSummary(BaseModel):
    episode_id: str
    plan_file:  Optional[str] = None
    beat_count: int = 0

class BeatSummary(BaseModel):
    beat_id:        str
    scene_id:       Optional[str] = None
    status:         str
    primary_take:   Optional[str] = None
    take_count:     int           = 0
    eval_score:     Optional[float] = None
    eval_badge:     EvalBadgeState  = EvalBadgeState.CONFIG_MISS
    cost_usd:       float           = 0.0
    has_audit:      bool            = False

class TakeSummary(BaseModel):
    take_id:    str
    take_index: int
    status:     str
    is_primary: bool
    eval_score: Optional[float] = None
    eval_badge: EvalBadgeState  = EvalBadgeState.CONFIG_MISS
    cost_usd:   float           = 0.0
    image_path: Optional[str]   = None
    video_path: Optional[str]   = None


# ── Workspace state ───────────────────────────────────────────────────────────

class NavigatorFocus(BaseModel):
    project: Optional[str] = None
    episode: Optional[str] = None
    scene:   Optional[str] = None
    beat:    Optional[str] = None
    take:    Optional[str] = None

class ArtifactMount(BaseModel):
    template: Optional[str] = None
    context:  dict[str, Any] = Field(default_factory=dict)

class WorkspaceState(BaseModel):
    project:           str
    last_updated:      float
    navigator_focus:   NavigatorFocus = Field(default_factory=NavigatorFocus)
    artifact_mount:    ArtifactMount  = Field(default_factory=ArtifactMount)
    scroll_positions:  dict[str, Any] = Field(default_factory=dict)


# ── Chat ──────────────────────────────────────────────────────────────────────

class ChatMessage(BaseModel):
    role:    Literal["user", "assistant", "system"]
    content: str
    ts:      float

class ChatRequest(BaseModel):
    message:          str
    artifact_context: Optional[ArtifactContext] = None

# Streamed chat events (SSE payloads) — one model per type
class ChatTokenEvent(BaseModel):
    type: Literal["token"] = "token"
    text: str

class ChatMountEvent(BaseModel):
    type:     Literal["mount"] = "mount"
    template: str
    context:  dict[str, Any] = Field(default_factory=dict)

class ChatFocusEvent(BaseModel):
    type:     Literal["focus"] = "focus"
    focus:    NavigatorFocus

class ChatProposalEvent(BaseModel):
    type:    Literal["proposal"] = "proposal"
    payload: SpecEditProposal

class ChatErrorEvent(BaseModel):
    type: Literal["error"] = "error"
    text: str

class ChatDoneEvent(BaseModel):
    type:         Literal["done"] = "done"
    total_tokens: int = 0

ChatStreamEvent = Annotated[
    Union[ChatTokenEvent, ChatMountEvent, ChatFocusEvent,
          ChatProposalEvent, ChatErrorEvent, ChatDoneEvent],
    Field(discriminator="type"),
]


# ── EventBus payloads (SSE on /v2/api/events) ────────────────────────────────

class StepStatusEvent(BaseModel):
    type:    Literal["step_status"] = "step_status"
    project: str
    episode: str
    shot_id: str
    status:  str
    ts:      float

class TakeCompletedEvent(BaseModel):
    type:     Literal["take_completed"] = "take_completed"
    project:  str
    shot_id:  str
    take_id:  str
    score:    Optional[float] = None
    cost_usd: float
    ts:       float

class EvalCompletedEvent(BaseModel):
    type:     Literal["eval_completed"] = "eval_completed"
    project:  str
    shot_id:  str
    take_id:  str
    panel_id: str
    score:    float
    ts:       float

class BatchSummaryEvent(BaseModel):
    type:        Literal["batch_summary"] = "batch_summary"
    project:     str
    episode:     str
    completed:   int
    failed:      int
    total_cost:  float
    ts:          float

BusEvent = Annotated[
    Union[StepStatusEvent, TakeCompletedEvent, EvalCompletedEvent, BatchSummaryEvent],
    Field(discriminator="type"),
]


# ── Audit ─────────────────────────────────────────────────────────────────────

class AuditEvent(BaseModel):
    """Append-only event in a beat's audit log."""
    id:        Optional[int] = None  # filled by SQLite
    project:   str
    beat_id:   str
    kind:      Literal["reshoot", "score_delta", "applied_edit", "source_note"]
    payload:   dict[str, Any] = Field(default_factory=dict)
    ts:        float

class AuditRecord(BaseModel):
    """Aggregated view of a beat's audit history."""
    beat_id:        str
    last_updated:   float
    reshoot_cycles: List[AuditEvent] = Field(default_factory=list)
    score_deltas:   List[AuditEvent] = Field(default_factory=list)
    applied_edits:  List[AuditEvent] = Field(default_factory=list)
    source_notes:   List[AuditEvent] = Field(default_factory=list)


# ── Eval policy ───────────────────────────────────────────────────────────────

class PolicyRule(BaseModel):
    step_type:        str               # "image_t2i", "video_i2v", etc.
    required_panels:  List[str]         # ["eval_image_v1"]
    intentional_skip: bool = False      # if True → SKIP badge instead of CONFIG_MISS

class EvalPolicy(BaseModel):
    version:   int = 1
    rules:     List[PolicyRule] = Field(default_factory=list)
    on_miss:   Literal["badge_config_miss", "fail_batch"] = "badge_config_miss"
```

### Scope boundary
- Do NOT add helper methods beyond `as_system_prefix` and `eval_badge_label` — keep this module data-only
- Do NOT import from anywhere except `pydantic`, `enum`, `typing`
- Every Pydantic model has `extra="forbid"` (via `_ProposalBase` for proposals; explicit on `ArtifactContext`; default-strict on others — `EvalPolicy` and projection models can stay default for forward-compat)
- Use `discriminator="type"` for every union (so codegen produces TS discriminated unions)

### Validation
```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS && \
python3 -c "
import ast; ast.parse(open('recoil/pipeline/v2/types.py').read())
import sys; sys.path.insert(0, 'recoil/pipeline')
from v2.types import (
    ArtifactContext, SpecEditProposal, EvalBadgeState,
    PromptRewritePayload, BeatInsertionPayload, ParameterChangePayload,
    ScriptEditPayload, MultiBeatDirectivePayload, ExtractCutawayPayload,
    RefSwapPayload, RetryStrategyEditPayload,
    WorkspaceState, ChatMessage, AuditRecord, EvalPolicy,
    StepStatusEvent, TakeCompletedEvent, EvalCompletedEvent, BatchSummaryEvent,
    eval_badge_label,
)
ctx = ArtifactContext(active_project='tartarus', active_beat='B7')
assert 'project=tartarus' in ctx.as_system_prefix()
assert eval_badge_label(EvalBadgeState.EVALUATED, 0.82) == '[0.82]'
assert eval_badge_label(EvalBadgeState.SKIP) == '[---]'
print('Phase 2 OK')
" && echo "Phase 2 OK"
```

---

## Phase 3: SQLite State Layer + tests
**depends_on:** 2
**engine:** opus

### Files to create
- `recoil/pipeline/v2/state.py`
- `recoil/pipeline/v2/tests/__init__.py`
- `recoil/pipeline/v2/tests/conftest.py`
- `recoil/pipeline/v2/tests/test_state.py`

### Database location
- **Production:** `~/.recoil/v2_workspace.db` (outside Dropbox; created on first use, parent dir auto-created)
- **Tests:** `:memory:` (per-test SQLite, no shared state)

### Schema (apply on connect — idempotent)

```sql
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA synchronous  = NORMAL;

CREATE TABLE IF NOT EXISTS workspace (
    project           TEXT PRIMARY KEY,
    last_updated      REAL NOT NULL,
    navigator_focus   TEXT NOT NULL DEFAULT '{}',
    artifact_mount    TEXT NOT NULL DEFAULT '{}',
    scroll_positions  TEXT NOT NULL DEFAULT '{}'
);

CREATE TABLE IF NOT EXISTS chat_messages (
    id        INTEGER PRIMARY KEY AUTOINCREMENT,
    project   TEXT NOT NULL,
    episode   TEXT NOT NULL,
    role      TEXT NOT NULL CHECK (role IN ('user','assistant','system')),
    content   TEXT NOT NULL,
    ts        REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chat_lookup ON chat_messages (project, episode, id);

CREATE TABLE IF NOT EXISTS audit_records (
    project       TEXT NOT NULL,
    beat_id       TEXT NOT NULL,
    last_updated  REAL NOT NULL,
    PRIMARY KEY (project, beat_id)
);

CREATE TABLE IF NOT EXISTS audit_events (
    id        INTEGER PRIMARY KEY AUTOINCREMENT,
    project   TEXT NOT NULL,
    beat_id   TEXT NOT NULL,
    kind      TEXT NOT NULL CHECK (kind IN ('reshoot','score_delta','applied_edit','source_note')),
    payload   TEXT NOT NULL,
    ts        REAL NOT NULL,
    FOREIGN KEY (project, beat_id) REFERENCES audit_records(project, beat_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_audit_lookup ON audit_events (project, beat_id, kind, id);

CREATE TABLE IF NOT EXISTS eval_policy (
    project       TEXT PRIMARY KEY,
    last_updated  REAL NOT NULL,
    policy_json   TEXT NOT NULL
);
```

### `recoil/pipeline/v2/state.py` — interface

```python
"""
SQLite-backed state layer for Console v2.

All writes are atomic (single-statement transactions, WAL mode).
The connection is lazy-initialized per thread and cached.
The schema is applied on first connect (idempotent).
"""
from __future__ import annotations
import json
import sqlite3
import threading
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator, List, Optional

from v2.types import (
    ArtifactMount, AuditEvent, AuditRecord, ChatMessage,
    EvalPolicy, NavigatorFocus, WorkspaceState,
)

# ── Connection ────────────────────────────────────────────────────────────────

_DB_PATH = Path.home() / ".recoil" / "v2_workspace.db"
_local = threading.local()
_db_path_override: Optional[Path] = None
_SCHEMA: list[str]  # populated below


def set_db_path(p: Path | str | None) -> None:
    """Override the DB path. None resets to default. Used by tests."""
    global _db_path_override
    _db_path_override = Path(p) if p else None
    # Drop any cached connection on this thread
    if hasattr(_local, "conn"):
        try: _local.conn.close()
        except Exception: pass
        del _local.conn


def _conn() -> sqlite3.Connection:
    if not hasattr(_local, "conn"):
        path = _db_path_override or _DB_PATH
        if str(path) != ":memory:":
            path.parent.mkdir(parents=True, exist_ok=True)
        c = sqlite3.connect(str(path), check_same_thread=False, timeout=10.0,
                            isolation_level=None)  # autocommit
        c.row_factory = sqlite3.Row
        for stmt in _SCHEMA:
            c.execute(stmt)
        _local.conn = c
    return _local.conn


@contextmanager
def transaction() -> Iterator[sqlite3.Connection]:
    """Explicit transaction context (for multi-statement ops)."""
    c = _conn()
    c.execute("BEGIN")
    try:
        yield c
        c.execute("COMMIT")
    except Exception:
        c.execute("ROLLBACK")
        raise


# ── Workspace ─────────────────────────────────────────────────────────────────

def load_workspace(project: str) -> WorkspaceState:
    row = _conn().execute(
        "SELECT * FROM workspace WHERE project = ?", (project,)
    ).fetchone()
    if not row:
        return WorkspaceState(project=project, last_updated=time.time())
    return WorkspaceState(
        project=row["project"], last_updated=row["last_updated"],
        navigator_focus=NavigatorFocus.model_validate_json(row["navigator_focus"]),
        artifact_mount=ArtifactMount.model_validate_json(row["artifact_mount"]),
        scroll_positions=json.loads(row["scroll_positions"]),
    )

def save_workspace(state: WorkspaceState) -> None:
    state.last_updated = time.time()
    _conn().execute("""
        INSERT INTO workspace (project, last_updated, navigator_focus,
                               artifact_mount, scroll_positions)
        VALUES (?, ?, ?, ?, ?)
        ON CONFLICT(project) DO UPDATE SET
            last_updated     = excluded.last_updated,
            navigator_focus  = excluded.navigator_focus,
            artifact_mount   = excluded.artifact_mount,
            scroll_positions = excluded.scroll_positions
    """, (state.project, state.last_updated,
          state.navigator_focus.model_dump_json(),
          state.artifact_mount.model_dump_json(),
          json.dumps(state.scroll_positions)))

def update_focus(project: str, focus: NavigatorFocus) -> None:
    state = load_workspace(project)
    state.navigator_focus = focus
    save_workspace(state)

def update_mount(project: str, mount: ArtifactMount) -> None:
    state = load_workspace(project)
    state.artifact_mount = mount
    save_workspace(state)


# ── Chat ──────────────────────────────────────────────────────────────────────

def append_chat(project: str, episode: str, msg: ChatMessage) -> int:
    cur = _conn().execute(
        "INSERT INTO chat_messages (project, episode, role, content, ts) VALUES (?,?,?,?,?)",
        (project, episode, msg.role, msg.content, msg.ts),
    )
    return cur.lastrowid

def get_thread(project: str, episode: str, limit: Optional[int] = None) -> List[ChatMessage]:
    sql = ("SELECT role, content, ts FROM chat_messages "
           "WHERE project=? AND episode=? ORDER BY id ASC")
    params: tuple = (project, episode)
    if limit is not None:
        sql += " LIMIT ?"
        params = (project, episode, limit)
    rows = _conn().execute(sql, params).fetchall()
    return [ChatMessage(role=r["role"], content=r["content"], ts=r["ts"]) for r in rows]

def thread_length(project: str, episode: str) -> int:
    return _conn().execute(
        "SELECT COUNT(*) AS n FROM chat_messages WHERE project=? AND episode=?",
        (project, episode),
    ).fetchone()["n"]


# ── Audit ─────────────────────────────────────────────────────────────────────

def append_audit(event: AuditEvent) -> int:
    with transaction() as c:
        c.execute(
            "INSERT OR IGNORE INTO audit_records (project, beat_id, last_updated) VALUES (?,?,?)",
            (event.project, event.beat_id, event.ts),
        )
        c.execute(
            "UPDATE audit_records SET last_updated=? WHERE project=? AND beat_id=?",
            (event.ts, event.project, event.beat_id),
        )
        cur = c.execute(
            "INSERT INTO audit_events (project, beat_id, kind, payload, ts) VALUES (?,?,?,?,?)",
            (event.project, event.beat_id, event.kind,
             json.dumps(event.payload), event.ts),
        )
        return cur.lastrowid

def load_audit(project: str, beat_id: str) -> AuditRecord:
    rec_row = _conn().execute(
        "SELECT * FROM audit_records WHERE project=? AND beat_id=?",
        (project, beat_id),
    ).fetchone()
    if not rec_row:
        return AuditRecord(beat_id=beat_id, last_updated=time.time())

    events = _conn().execute(
        "SELECT * FROM audit_events WHERE project=? AND beat_id=? ORDER BY id ASC",
        (project, beat_id),
    ).fetchall()

    buckets: dict[str, List[AuditEvent]] = {
        "reshoot": [], "score_delta": [], "applied_edit": [], "source_note": [],
    }
    for r in events:
        ev = AuditEvent(id=r["id"], project=r["project"], beat_id=r["beat_id"],
                        kind=r["kind"], payload=json.loads(r["payload"]), ts=r["ts"])
        buckets[r["kind"]].append(ev)
    return AuditRecord(
        beat_id=beat_id, last_updated=rec_row["last_updated"],
        reshoot_cycles=buckets["reshoot"],
        score_deltas=buckets["score_delta"],
        applied_edits=buckets["applied_edit"],
        source_notes=buckets["source_note"],
    )

def has_audit(project: str, beat_id: str) -> bool:
    return _conn().execute(
        "SELECT 1 FROM audit_records WHERE project=? AND beat_id=? LIMIT 1",
        (project, beat_id),
    ).fetchone() is not None


# ── Eval policy ───────────────────────────────────────────────────────────────

_DEFAULT_POLICY_JSON = json.dumps({
    "version": 1,
    "rules": [
        {"step_type": "image_t2i",    "required_panels": ["eval_image_v1"], "intentional_skip": False},
        {"step_type": "video_i2v",    "required_panels": ["eval_video_v1"], "intentional_skip": False},
        {"step_type": "audio_t2a",    "required_panels": ["eval_audio_v1"], "intentional_skip": False},
        {"step_type": "lipsync_post", "required_panels": [],                 "intentional_skip": True},
    ],
    "on_miss": "badge_config_miss",
})

def load_policy(project: str) -> EvalPolicy:
    row = _conn().execute(
        "SELECT policy_json FROM eval_policy WHERE project=?", (project,)
    ).fetchone()
    return EvalPolicy.model_validate_json(row["policy_json"] if row else _DEFAULT_POLICY_JSON)

def save_policy(project: str, policy: EvalPolicy) -> None:
    _conn().execute("""
        INSERT INTO eval_policy (project, last_updated, policy_json)
        VALUES (?, ?, ?)
        ON CONFLICT(project) DO UPDATE SET
            last_updated = excluded.last_updated,
            policy_json  = excluded.policy_json
    """, (project, time.time(), policy.model_dump_json()))


# ── Schema list (for _conn) ──────────────────────────────────────────────────

_SCHEMA = [
    "PRAGMA journal_mode = WAL",
    "PRAGMA foreign_keys = ON",
    "PRAGMA synchronous  = NORMAL",
    """CREATE TABLE IF NOT EXISTS workspace (
        project TEXT PRIMARY KEY, last_updated REAL NOT NULL,
        navigator_focus TEXT NOT NULL DEFAULT '{}',
        artifact_mount TEXT NOT NULL DEFAULT '{}',
        scroll_positions TEXT NOT NULL DEFAULT '{}')""",
    """CREATE TABLE IF NOT EXISTS chat_messages (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        project TEXT NOT NULL, episode TEXT NOT NULL,
        role TEXT NOT NULL CHECK (role IN ('user','assistant','system')),
        content TEXT NOT NULL, ts REAL NOT NULL)""",
    "CREATE INDEX IF NOT EXISTS idx_chat_lookup ON chat_messages (project, episode, id)",
    """CREATE TABLE IF NOT EXISTS audit_records (
        project TEXT NOT NULL, beat_id TEXT NOT NULL,
        last_updated REAL NOT NULL, PRIMARY KEY (project, beat_id))""",
    """CREATE TABLE IF NOT EXISTS audit_events (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        project TEXT NOT NULL, beat_id TEXT NOT NULL,
        kind TEXT NOT NULL CHECK (kind IN ('reshoot','score_delta','applied_edit','source_note')),
        payload TEXT NOT NULL, ts REAL NOT NULL,
        FOREIGN KEY (project, beat_id) REFERENCES audit_records(project, beat_id) ON DELETE CASCADE)""",
    "CREATE INDEX IF NOT EXISTS idx_audit_lookup ON audit_events (project, beat_id, kind, id)",
    """CREATE TABLE IF NOT EXISTS eval_policy (
        project TEXT PRIMARY KEY, last_updated REAL NOT NULL,
        policy_json TEXT NOT NULL)""",
]
```

### `recoil/pipeline/v2/tests/conftest.py`

```python
import sys
from pathlib import Path
PIPELINE = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PIPELINE))
sys.path.insert(0, str(PIPELINE.parent))

import pytest
from v2 import state

@pytest.fixture(autouse=True)
def _isolated_db(tmp_path, monkeypatch):
    db_file = tmp_path / "test_v2.db"
    state.set_db_path(db_file)
    yield
    state.set_db_path(None)


@pytest.fixture(autouse=True)
def _reset_event_bus():
    """Prevent the EventBus singleton from leaking ring-buffer state between
    tests. Phase 4 introduces EventBus; Phases 6/7/8 import it transitively
    through services that emit on it. Without this fixture, an event emitted
    in test A would still be in the ring buffer for test B."""
    yield
    try:
        from v2.events import EventBus
        EventBus.reset_for_tests()
    except ImportError:
        # Phase 3 runs before Phase 4 lands events.py — no-op until it exists.
        pass
```

### `recoil/pipeline/v2/tests/test_state.py`

```python
import time
from v2 import state
from v2.types import (
    ArtifactMount, AuditEvent, ChatMessage, EvalPolicy,
    NavigatorFocus, PolicyRule, WorkspaceState,
)

# Workspace
def test_workspace_roundtrip():
    s = WorkspaceState(project="tartarus", last_updated=time.time())
    s.navigator_focus = NavigatorFocus(project="tartarus", episode="EP001", beat="B7")
    state.save_workspace(s)
    r = state.load_workspace("tartarus")
    assert r.navigator_focus.beat == "B7"

def test_load_returns_default_when_missing():
    r = state.load_workspace("brand-new")
    assert r.project == "brand-new"
    assert r.navigator_focus.beat is None

def test_update_focus_preserves_mount():
    state.update_mount("p", ArtifactMount(template="TakesBrowser", context={"beat_id":"B7"}))
    state.update_focus("p", NavigatorFocus(project="p", beat="B9"))
    r = state.load_workspace("p")
    assert r.artifact_mount.template == "TakesBrowser"
    assert r.navigator_focus.beat == "B9"

# Chat
def test_chat_append_and_thread():
    state.append_chat("p", "EP001", ChatMessage(role="user", content="hi", ts=time.time()))
    state.append_chat("p", "EP001", ChatMessage(role="assistant", content="hello", ts=time.time()))
    msgs = state.get_thread("p", "EP001")
    assert [m.role for m in msgs] == ["user", "assistant"]
    assert state.thread_length("p", "EP001") == 2

def test_chat_episodes_isolated():
    state.append_chat("p", "EP001", ChatMessage(role="user", content="a", ts=time.time()))
    state.append_chat("p", "EP002", ChatMessage(role="user", content="b", ts=time.time()))
    assert state.thread_length("p", "EP001") == 1
    assert state.thread_length("p", "EP002") == 1

# Audit
def test_audit_append_and_load():
    ev = AuditEvent(project="p", beat_id="B7", kind="source_note",
                    payload={"text":"keyframe drift"}, ts=time.time())
    state.append_audit(ev)
    rec = state.load_audit("p", "B7")
    assert rec.beat_id == "B7"
    assert len(rec.source_notes) == 1
    assert rec.source_notes[0].payload["text"] == "keyframe drift"

def test_audit_buckets_by_kind():
    for kind in ("reshoot","score_delta","applied_edit","source_note"):
        state.append_audit(AuditEvent(project="p", beat_id="B7", kind=kind,
                                      payload={}, ts=time.time()))
    rec = state.load_audit("p", "B7")
    assert len(rec.reshoot_cycles) == 1
    assert len(rec.score_deltas) == 1
    assert len(rec.applied_edits) == 1
    assert len(rec.source_notes) == 1

def test_has_audit_false_for_unknown_beat():
    assert state.has_audit("p", "B999") is False

# Policy
def test_default_policy_returned_when_none_saved():
    p = state.load_policy("brand-new-project")
    assert p.version == 1
    assert any(r.step_type == "image_t2i" for r in p.rules)

def test_policy_persisted():
    p = EvalPolicy(version=1, rules=[PolicyRule(step_type="image_t2i", required_panels=["custom"])])
    state.save_policy("p", p)
    r = state.load_policy("p")
    assert r.rules[0].required_panels == ["custom"]

# Concurrency smoke
def test_concurrent_writes_dont_corrupt():
    import threading
    def write(i):
        state.append_chat("p", "EP001", ChatMessage(role="user", content=f"m{i}", ts=time.time()))
    ts = [threading.Thread(target=write, args=(i,)) for i in range(20)]
    for t in ts: t.start()
    for t in ts: t.join()
    assert state.thread_length("p", "EP001") == 20
```

### Scope boundary
- Do NOT use raw JSON files anywhere (synthesis Tenet 1: SSOT)
- Do NOT add ORM (SQLAlchemy etc.) — sqlite3 module is deliberate, keeps the surface tiny
- Do NOT mutate `_DB_PATH` in tests; use `set_db_path`

### Validation
```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS && \
python3 -c "import ast; ast.parse(open('recoil/pipeline/v2/state.py').read())" && \
cd recoil/pipeline && python3 -m pytest v2/tests/test_state.py -v && \
echo "Phase 3 OK"
```

---

## Phase 4: EventBus + tests
**depends_on:** 2
**engine:** opus

### Files to create
- `recoil/pipeline/v2/events.py`
- `recoil/pipeline/v2/tests/test_events.py`

### Design
- Singleton `EventBus` with a thread-safe ring buffer (last 500 events)
- Sync `emit()` from any thread (production_loop hooks call this)
- Async `subscribe()` for SSE consumers; bridges sync→async via `asyncio.run_coroutine_threadsafe`
- Each subscriber has its own bounded `asyncio.Queue` (200 items); slow consumers drop the oldest event rather than block emitters
- Reconnect via `last_event_id`: replays from ring buffer

### `recoil/pipeline/v2/events.py`

```python
"""
Console v2 EventBus — sync emitters, async SSE consumers, in-process.

emit(...) is called from any thread (typically production_loop hooks running
in a worker thread of the FastAPI process).
subscribe(...) is an async generator yielding events. Each subscriber has its
own bounded queue; slow consumers drop oldest rather than blocking emitters.
"""
from __future__ import annotations
import asyncio
import threading
import time
from collections import deque
from typing import AsyncIterator, Deque, Dict, List, Optional

from v2.types import (
    BatchSummaryEvent, BusEvent, EvalCompletedEvent,
    StepStatusEvent, TakeCompletedEvent,
)

_RING_SIZE = 500
_QUEUE_SIZE = 200


class EventBus:
    """Thread-safe + async-bridge SSE event bus."""
    _instance: "EventBus | None" = None
    _singleton_lock = threading.Lock()

    def __init__(self) -> None:
        self._ring: Deque[Dict] = deque(maxlen=_RING_SIZE)
        self._next_id: int = 1
        self._subscribers: List[Dict] = []  # [{queue, loop}]
        self._mu = threading.Lock()

    @classmethod
    def get(cls) -> "EventBus":
        if cls._instance is None:
            with cls._singleton_lock:
                if cls._instance is None:
                    cls._instance = cls()
        return cls._instance

    @classmethod
    def reset_for_tests(cls) -> None:
        """Drop the singleton — only for use in tests."""
        with cls._singleton_lock:
            cls._instance = None

    def emit(self, event: BusEvent) -> int:
        """Emit a typed event. Returns the assigned id."""
        payload = event.model_dump()
        with self._mu:
            eid = self._next_id; self._next_id += 1
            entry = {"id": eid, "type": payload["type"], "data": payload}
            self._ring.append(entry)
            for sub in list(self._subscribers):
                _safe_put(sub, entry)
        return eid

    def replay(self, last_id: Optional[int]) -> List[Dict]:
        """Return events from the ring buffer with id > last_id (or all)."""
        with self._mu:
            return [e for e in self._ring if last_id is None or e["id"] > last_id]

    async def subscribe(self, last_id: Optional[int] = None) -> AsyncIterator[Dict]:
        """Async generator yielding events. Replays missed, then streams live."""
        loop = asyncio.get_running_loop()
        q: asyncio.Queue = asyncio.Queue(maxsize=_QUEUE_SIZE)
        sub = {"queue": q, "loop": loop}

        # Snapshot replay BEFORE adding subscriber to avoid double-yield
        with self._mu:
            missed = [e for e in self._ring if last_id is None or e["id"] > last_id]
            self._subscribers.append(sub)

        try:
            for e in missed:
                yield e
            while True:
                try:
                    e = await asyncio.wait_for(q.get(), timeout=20.0)
                    yield e
                except asyncio.TimeoutError:
                    yield {"id": -1, "type": "heartbeat", "data": {"ts": time.time()}}
        finally:
            with self._mu:
                try: self._subscribers.remove(sub)
                except ValueError: pass


def _safe_put(sub: Dict, entry: Dict) -> None:
    """Put onto subscriber's queue from any thread; drop oldest on overflow."""
    q: asyncio.Queue = sub["queue"]
    loop: asyncio.AbstractEventLoop = sub["loop"]
    if loop.is_closed():
        return
    def _put():
        if q.full():
            try: q.get_nowait()  # drop oldest
            except asyncio.QueueEmpty: pass
        q.put_nowait(entry)
    try:
        loop.call_soon_threadsafe(_put)
    except RuntimeError:
        pass  # loop closed mid-call — subscriber will be cleaned up


# ── Convenience emitters (call sites: production_loop hooks) ──────────────────

def emit_step_status(project: str, episode: str, shot_id: str, status: str) -> None:
    EventBus.get().emit(StepStatusEvent(
        project=project, episode=episode, shot_id=shot_id,
        status=status, ts=time.time()))

def emit_take_completed(project: str, shot_id: str, take_id: str,
                        score: Optional[float], cost_usd: float) -> None:
    EventBus.get().emit(TakeCompletedEvent(
        project=project, shot_id=shot_id, take_id=take_id,
        score=score, cost_usd=cost_usd, ts=time.time()))

def emit_eval_completed(project: str, shot_id: str, take_id: str,
                        panel_id: str, score: float) -> None:
    EventBus.get().emit(EvalCompletedEvent(
        project=project, shot_id=shot_id, take_id=take_id,
        panel_id=panel_id, score=score, ts=time.time()))

def emit_batch_summary(project: str, episode: str,
                       completed: int, failed: int, total_cost: float) -> None:
    EventBus.get().emit(BatchSummaryEvent(
        project=project, episode=episode, completed=completed,
        failed=failed, total_cost=total_cost, ts=time.time()))
```

### `recoil/pipeline/v2/tests/test_events.py`

```python
import asyncio
import threading
import pytest
from v2.events import EventBus
from v2.types import StepStatusEvent, TakeCompletedEvent

@pytest.fixture(autouse=True)
def _reset_bus():
    EventBus.reset_for_tests()
    yield
    EventBus.reset_for_tests()


def test_emit_assigns_increasing_ids():
    bus = EventBus.get()
    a = bus.emit(StepStatusEvent(project="p", episode="EP001", shot_id="B7",
                                 status="running", ts=1.0))
    b = bus.emit(StepStatusEvent(project="p", episode="EP001", shot_id="B7",
                                 status="done", ts=2.0))
    assert b == a + 1


def test_replay_filters_by_last_id():
    bus = EventBus.get()
    bus.emit(StepStatusEvent(project="p", episode="EP001", shot_id="A",
                             status="running", ts=1.0))
    second_id = bus.emit(StepStatusEvent(project="p", episode="EP001", shot_id="B",
                                         status="running", ts=2.0))
    replay = bus.replay(last_id=second_id - 1)
    assert len(replay) == 1
    assert replay[0]["data"]["shot_id"] == "B"


def test_subscribe_replays_then_streams():
    async def run():
        bus = EventBus.get()
        # Pre-emit one event
        bus.emit(StepStatusEvent(project="p", episode="EP001", shot_id="A",
                                 status="running", ts=1.0))
        seen = []

        async def consumer():
            async for e in bus.subscribe(last_id=0):
                if e["type"] == "heartbeat": continue
                seen.append(e)
                if len(seen) >= 2: break

        # Run consumer alongside a delayed emit
        async def emit_later():
            await asyncio.sleep(0.05)
            bus.emit(TakeCompletedEvent(project="p", shot_id="A", take_id="A-take-0",
                                        score=0.9, cost_usd=0.1, ts=2.0))

        await asyncio.wait_for(asyncio.gather(consumer(), emit_later()), timeout=2.0)
        assert seen[0]["data"]["shot_id"] == "A"
        assert seen[1]["type"] == "take_completed"
    asyncio.run(run())


def test_cross_thread_emit_reaches_async_subscriber():
    """Production-loop runs in a worker thread; SSE consumer is on the event loop."""
    async def run():
        bus = EventBus.get()
        seen = []

        async def consumer():
            async for e in bus.subscribe(last_id=0):
                if e["type"] == "heartbeat": continue
                seen.append(e)
                break

        # Emit from a separate thread
        def emit_in_thread():
            import time as _t
            _t.sleep(0.05)
            bus.emit(StepStatusEvent(project="p", episode="EP001", shot_id="X",
                                     status="running", ts=1.0))

        t = threading.Thread(target=emit_in_thread)
        t.start()
        await asyncio.wait_for(consumer(), timeout=2.0)
        t.join()
        assert seen[0]["data"]["shot_id"] == "X"
    asyncio.run(run())


@pytest.mark.asyncio
async def test_slow_consumer_doesnt_block_emitter():
    """Ring buffer caps at RING_SIZE=500 regardless of subscriber speed.

    The original async-iterator version of this test hung on
    `agen.__anext__()` — replay yields nothing for an empty bus, so the
    consumer would block forever. The cap-on-emit invariant is the actual
    load-bearing check; assert it directly.
    """
    bus = EventBus.get()
    EventBus.reset_for_tests()
    for i in range(1000):
        bus.emit(StepStatusEvent(project="p", episode="EP001",
                                 shot_id=f"S{i:04d}",
                                 status="running", ts=float(i)))
    assert len(bus._ring) == 500
    # Ring keeps the most-recent events — last emit must still be present.
    assert bus._ring[-1].shot_id == "S0999"
```

### Scope boundary
- Do NOT add persistent storage of events (ring buffer only — events are ephemeral)
- Do NOT block the emitter thread (slow consumers drop, never block)
- Do NOT import from FastAPI / uvicorn — events.py must be importable from production_loop without pulling the web stack

### Validation
```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS && \
python3 -c "import ast; ast.parse(open('recoil/pipeline/v2/events.py').read())" && \
cd recoil/pipeline && python3 -m pytest v2/tests/test_events.py -v && \
echo "Phase 4 OK"
```

---

## Phase 5: FastAPI Scaffold + LaunchAgent
**depends_on:** 2, 3, 4
**engine:** gemini

### Files to create
- `recoil/pipeline/v2/main.py` — FastAPI app + uvicorn entry
- `recoil/pipeline/v2/settings.py` — Pydantic Settings (env vars)
- `recoil/pipeline/v2/routes/__init__.py` (empty)
- `recoil/pipeline/v2/services/__init__.py` (empty)
- `recoil/pipeline/v2/launcher.sh`
- `recoil/pipeline/com.recoil.console-v2.plist`
- `recoil/pipeline/v2/requirements.txt`

### `recoil/pipeline/v2/requirements.txt`
```
fastapi>=0.110
uvicorn[standard]>=0.27
pydantic>=2.6
pydantic-settings>=2.2
anthropic>=0.30
pytest>=8.0
pytest-asyncio>=0.23
httpx>=0.27
```
(SQLAlchemy intentionally absent — Phase 3 uses stdlib `sqlite3`.)
(`httpx` is required by FastAPI `TestClient`. `pytest` + `pytest-asyncio`
are required to run the test suites added in Phases 3, 4, 6, 7, 8.)

### `recoil/pipeline/v2/settings.py`

```python
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="RECOIL_V2_", env_file=".env", extra="ignore")

    host:           str  = "127.0.0.1"
    port:           int  = 8431
    static_dir:     Path = Path(__file__).resolve().parent.parent / "editors" / "v2" / "dist"
    dev_static_dir: Path = Path(__file__).resolve().parent.parent / "editors" / "v2" / "src"
    db_path:        Path = Path.home() / ".recoil" / "v2_workspace.db"
    anthropic_api_key: str | None = None  # falls back to ANTHROPIC_API_KEY in env
    chat_model:     str  = "claude-sonnet-4-6"
    chat_max_tokens: int = 2048

settings = Settings()
```

### `recoil/pipeline/v2/main.py`

```python
"""Console v2 FastAPI app."""
from __future__ import annotations
import sys
from pathlib import Path

# Path bootstrap (matches review_server.py pattern)
_PIPELINE = Path(__file__).resolve().parent.parent
_RECOIL   = _PIPELINE.parent
for p in (str(_RECOIL), str(_PIPELINE)):
    if p not in sys.path:
        sys.path.insert(0, p)

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse

from v2 import state
from v2.settings import settings


app = FastAPI(title="Recoil Console v2", version="2.0.0")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://127.0.0.1:8430", "http://127.0.0.1:5173"],  # 8430=v1, 5173=Vite dev
    allow_methods=["*"], allow_headers=["*"],
)


@app.get("/v2/api/health")
def health():
    return {"ok": True, "server": "v2", "port": settings.port}


# Routes registered in Phases 6, 7, 8
from v2.routes import projects as _r_projects   # noqa: E402
from v2.routes import beats     as _r_beats     # noqa: E402
from v2.routes import workspace as _r_workspace # noqa: E402
from v2.routes import batch     as _r_batch     # noqa: E402
from v2.routes import policy    as _r_policy    # noqa: E402
from v2.routes import proposal  as _r_proposal  # noqa: E402
from v2.routes import chat      as _r_chat      # noqa: E402
from v2.routes import events    as _r_events    # noqa: E402

app.include_router(_r_projects.router,  prefix="/v2/api")
app.include_router(_r_beats.router,     prefix="/v2/api")
app.include_router(_r_workspace.router, prefix="/v2/api")
app.include_router(_r_batch.router,     prefix="/v2/api")
app.include_router(_r_policy.router,    prefix="/v2/api")
app.include_router(_r_proposal.router,  prefix="/v2/api")
app.include_router(_r_chat.router,      prefix="/v2/api")
app.include_router(_r_events.router,    prefix="/v2/api")


# ── Static SPA serving ────────────────────────────────────────────────────────

def _mount_static():
    """Mount /v2 → built SPA (prod) or src (dev)."""
    if settings.static_dir.exists() and (settings.static_dir / "index.html").exists():
        app.mount("/v2/static", StaticFiles(directory=settings.static_dir), name="v2-static")

        @app.get("/v2", include_in_schema=False)
        @app.get("/v2/", include_in_schema=False)
        def _spa_root():
            return FileResponse(settings.static_dir / "index.html")
    else:
        # Dev mode — Vite dev server on 5173 serves the SPA; this server is API-only
        @app.get("/v2", include_in_schema=False)
        @app.get("/v2/", include_in_schema=False)
        def _spa_dev():
            return {"mode": "dev",
                    "spa_url": "http://127.0.0.1:5173",
                    "hint": "run `npm run dev` inside editors/v2/"}

_mount_static()


# Apply DB path override from settings
state.set_db_path(settings.db_path)


def main():
    """Entry point used by launcher.sh and `python -m v2.main`."""
    import uvicorn
    uvicorn.run("v2.main:app", host=settings.host, port=settings.port, reload=False)


if __name__ == "__main__":
    main()
```

### `recoil/pipeline/v2/launcher.sh`

```bash
#!/bin/bash
set -e
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
PIPELINE_DIR="$(cd "$SELF_DIR/.." && pwd)"
cd "$PIPELINE_DIR"

# Prefer pipeline-local venv if present
if [ -x "$PIPELINE_DIR/.venv/bin/python" ]; then
    PYTHON="$PIPELINE_DIR/.venv/bin/python"
elif command -v python3 >/dev/null 2>&1; then
    PYTHON="$(command -v python3)"
else
    echo "FATAL: no python3 found" >&2
    exit 1
fi

exec "$PYTHON" -m v2.main
```

(Must be `chmod +x` after creation — sub-agent: include `chmod +x recoil/pipeline/v2/launcher.sh` as a final step.)

### `recoil/pipeline/com.recoil.console-v2.plist`

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key><string>com.recoil.console-v2</string>
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/recoil/pipeline/v2/launcher.sh</string>
    </array>
    <key>WorkingDirectory</key>
    <string>/Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/recoil/pipeline</string>
    <key>RunAtLoad</key><true/>
    <key>KeepAlive</key><true/>
    <key>StandardOutPath</key><string>/tmp/recoil-console-v2.log</string>
    <key>StandardErrorPath</key><string>/tmp/recoil-console-v2-err.log</string>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
    </dict>
</dict>
</plist>
```

> **Install on Studio (manual, post-build, JT runs):**
> ```bash
> ssh joeturnerlin@100.105.59.118
> cp ~/Dropbox/CLAUDE_PROJECTS/recoil/pipeline/com.recoil.console-v2.plist ~/Library/LaunchAgents/
> launchctl load ~/Library/LaunchAgents/com.recoil.console-v2.plist
> curl -s http://127.0.0.1:8431/v2/api/health    # → {"ok":true,...}
> ```

### Scope boundary
- Do NOT register any routes in `main.py` itself — all routes go in `v2/routes/*.py`
- Do NOT modify review_server.py
- Phase 5 creates EMPTY route module stubs that Phases 6, 7, 8 fill in. The route modules MUST exist (with `router = APIRouter()` defined and no routes attached) so the imports in `main.py` succeed. Phase 5 sub-agent creates these stubs:

```python
# recoil/pipeline/v2/routes/projects.py  (and beats.py, workspace.py, batch.py,
#                                           policy.py, proposal.py, chat.py, events.py)
from fastapi import APIRouter
router = APIRouter()
```

### Validation
```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS && \
python3 -c "import ast; ast.parse(open('recoil/pipeline/v2/main.py').read())" && \
python3 -c "import ast; ast.parse(open('recoil/pipeline/v2/settings.py').read())" && \
cd recoil/pipeline && \
python3 -c "import sys; sys.path.insert(0,'.'); sys.path.insert(0,'..'); from v2.main import app; print(f'Routes: {len(app.routes)}')" && \
test -x v2/launcher.sh && \
test -f com.recoil.console-v2.plist && \
echo "Phase 5 OK"
```

---

## Phase 6: Read API Routes + tests
**depends_on:** 5
**engine:** gemini

### What already exists (from prior phases)
- `v2/types.py` — `ProjectSummary`, `EpisodeSummary`, `BeatSummary`, `TakeSummary`, `WorkspaceState`, `AuditRecord`, `EvalBadgeState`, `eval_badge_label`
- `v2/state.py` — `load_workspace`, `load_audit`, `has_audit`, `load_policy`
- `v2/main.py` — registers `routes/projects.py`, `routes/beats.py`, `routes/workspace.py` under `/v2/api`

### Files to modify
- `recoil/pipeline/v2/routes/projects.py` — fill in
- `recoil/pipeline/v2/routes/beats.py` — fill in
- `recoil/pipeline/v2/routes/workspace.py` — read-only routes only (PATCH lives in Phase 7)

### Files to create
- `recoil/pipeline/v2/services/store_adapter.py` — wraps `execution.execution_store.ExecutionStore` for v2 read API
- `recoil/pipeline/v2/tests/test_routes_read.py`

### `recoil/pipeline/v2/services/store_adapter.py`

```python
"""
Adapter: translate execution.execution_store shapes → v2.types projections.
v2 routes never touch ExecutionStore directly; they call this adapter.
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import List, Optional

_RECOIL = Path(__file__).resolve().parent.parent.parent.parent
if str(_RECOIL) not in sys.path:
    sys.path.insert(0, str(_RECOIL))

from core.paths import PROJECTS_ROOT
from v2.types import (
    BeatSummary, EpisodeSummary, EvalBadgeState,
    ProjectSummary, TakeSummary,
)
from v2 import state as ws


def list_projects() -> List[ProjectSummary]:
    root = Path(PROJECTS_ROOT)
    out: List[ProjectSummary] = []
    if root.exists():
        for d in sorted(root.iterdir()):
            if d.is_dir() and not d.name.startswith("_"):
                out.append(ProjectSummary(
                    name=d.name,
                    has_bible=(d / "state" / "visual" / "global_bible.json").exists(),
                ))
    return out


def list_episodes(project: str) -> List[EpisodeSummary]:
    plans_dir = Path(PROJECTS_ROOT) / project / "state" / "visual" / "plans"
    out: List[EpisodeSummary] = []
    if plans_dir.exists():
        for f in sorted(plans_dir.glob("ep_*_plan.json")):
            ep = f.stem.replace("_plan", "").upper()
            beats = _count_beats_in_plan(f)
            out.append(EpisodeSummary(
                episode_id=ep, plan_file=str(f), beat_count=beats,
            ))
    return out


def list_beats(project: str, episode: str) -> List[BeatSummary]:
    try:
        from execution.execution_store import ExecutionStore
        store = ExecutionStore(project=project)
        shots = store.get_episode_shots(episode) or []
    except Exception:
        shots = []
    return [_shot_to_beat(s, project) for s in shots]


def list_takes(project: str, episode: str, beat_id: str) -> List[TakeSummary]:
    try:
        from execution.execution_store import ExecutionStore
        store = ExecutionStore(project=project)
        shot  = store.get_shot(episode, beat_id) or {}
    except Exception:
        shot = {}
    primary = shot.get("selected_take_id")
    out: List[TakeSummary] = []
    for i, t in enumerate(shot.get("takes", [])):
        take_id = t.get("take_id", f"{beat_id}-take-{i}")
        out.append(TakeSummary(
            take_id=take_id, take_index=i,
            status=t.get("status", "unknown"),
            is_primary=(take_id == primary),
            eval_score=t.get("eval_score"),
            eval_badge=_compute_badge(t),
            cost_usd=t.get("cost_usd", 0.0),
            image_path=t.get("image_path") or t.get("output_path"),
            video_path=t.get("video_path"),
        ))
    return out


def _shot_to_beat(shot: dict, project: str) -> BeatSummary:
    bid = shot.get("shot_id", shot.get("id", ""))
    return BeatSummary(
        beat_id=bid,
        scene_id=shot.get("scene_id"),
        status=shot.get("status", "unknown"),
        primary_take=shot.get("selected_take_id"),
        take_count=len(shot.get("takes", [])),
        eval_score=shot.get("eval_score"),
        eval_badge=_compute_badge(shot),
        cost_usd=shot.get("cost_usd", 0.0),
        has_audit=ws.has_audit(project, bid),
    )


def _compute_badge(item: dict) -> EvalBadgeState:
    if item.get("eval_skip"):  return EvalBadgeState.SKIP
    if item.get("eval_error"): return EvalBadgeState.ERROR
    if item.get("eval_score") is not None: return EvalBadgeState.EVALUATED
    return EvalBadgeState.CONFIG_MISS


def _count_beats_in_plan(plan_path: Path) -> int:
    import json
    try:
        data = json.loads(plan_path.read_text())
        if isinstance(data, dict):
            for k in ("shots", "beats", "plan"):
                if isinstance(data.get(k), list):
                    return len(data[k])
        if isinstance(data, list): return len(data)
    except Exception:
        pass
    return 0
```

### `recoil/pipeline/v2/routes/projects.py`

```python
from typing import List
from fastapi import APIRouter
from v2.services.store_adapter import list_episodes, list_projects
from v2.types import EpisodeSummary, ProjectSummary

router = APIRouter()

@router.get("/projects", response_model=List[ProjectSummary])
def get_projects():
    return list_projects()

@router.get("/project/{project}/episodes", response_model=List[EpisodeSummary])
def get_episodes(project: str):
    return list_episodes(project)
```

### `recoil/pipeline/v2/routes/beats.py`

```python
from typing import List
from fastapi import APIRouter
from v2.services.store_adapter import list_beats, list_takes
from v2.types import AuditRecord, BeatSummary, TakeSummary
from v2 import state

router = APIRouter()

@router.get("/project/{project}/episode/{episode}/beats", response_model=List[BeatSummary])
def get_beats(project: str, episode: str):
    return list_beats(project, episode)

@router.get("/project/{project}/episode/{episode}/beat/{beat}/takes",
            response_model=List[TakeSummary])
def get_takes(project: str, episode: str, beat: str):
    return list_takes(project, episode, beat)

@router.get("/project/{project}/beat/{beat}/audit", response_model=AuditRecord)
def get_audit(project: str, beat: str):
    return state.load_audit(project, beat)
```

### `recoil/pipeline/v2/routes/workspace.py` (read half — POST/PATCH in Phase 7)

```python
from fastapi import APIRouter, Query
from v2 import state
from v2.types import WorkspaceState

router = APIRouter()

@router.get("/workspace", response_model=WorkspaceState)
def get_workspace(project: str = Query(...)):
    return state.load_workspace(project)
```

### `recoil/pipeline/v2/tests/test_routes_read.py`

```python
import pytest
from fastapi.testclient import TestClient

@pytest.fixture
def client(tmp_path, monkeypatch):
    from v2 import state
    state.set_db_path(tmp_path / "test.db")
    from v2.main import app
    yield TestClient(app)
    state.set_db_path(None)


def test_health(client):
    r = client.get("/v2/api/health")
    assert r.status_code == 200
    assert r.json()["ok"] is True


def test_projects_list_returns_array(client):
    r = client.get("/v2/api/projects")
    assert r.status_code == 200
    assert isinstance(r.json(), list)


def test_workspace_default_for_unknown_project(client):
    r = client.get("/v2/api/workspace?project=nonexistent")
    assert r.status_code == 200
    body = r.json()
    assert body["project"] == "nonexistent"
    assert body["navigator_focus"]["beat"] is None


def test_audit_returns_empty_record_for_unknown_beat(client):
    r = client.get("/v2/api/project/p/beat/B999/audit")
    assert r.status_code == 200
    body = r.json()
    assert body["beat_id"] == "B999"
    assert body["source_notes"] == []


def test_beats_returns_array_even_when_store_empty(client):
    r = client.get("/v2/api/project/nonexistent/episode/EP999/beats")
    assert r.status_code == 200
    assert isinstance(r.json(), list)
```

### Scope boundary
- Do NOT add POST/PATCH/DELETE routes here (Phase 7)
- Do NOT cache responses (every request hits SQLite + ExecutionStore)
- Do NOT call ExecutionStore from `routes/*.py` directly — go through `services/store_adapter.py`

### Validation
```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/recoil/pipeline && \
python3 -m pytest v2/tests/test_routes_read.py -v && \
echo "Phase 6 OK"
```

---

## Phase 7: Mutation API Routes + tests
**depends_on:** 5, 6
**engine:** opus

### What already exists (from prior phases)
- All read routes from Phase 6
- `v2/state.py` — `save_workspace`, `update_focus`, `update_mount`, `append_audit`, `save_policy`
- `v2/types.py` — all proposal payload models, `EvalPolicy`, `NavigatorFocus`, `ArtifactMount`

### Files to modify
- `recoil/pipeline/v2/routes/workspace.py` — add POST `/workspace/focus`, `/workspace/mount`, `/beat/{beat}/note`
- `recoil/pipeline/v2/routes/policy.py` — fill in (GET + PATCH)
- `recoil/pipeline/v2/routes/proposal.py` — fill in (POST approve)
- `recoil/pipeline/v2/routes/batch.py` — fill in (POST launch — runs production_loop in worker thread; emits to EventBus)

### Files to create
- `recoil/pipeline/v2/services/batch_runner.py` — worker-thread launcher with hook wiring
- `recoil/pipeline/v2/tests/test_routes_mutation.py`

### `recoil/pipeline/v2/services/batch_runner.py`

```python
"""
batch_runner — start a production_loop run in a worker thread,
wiring its hooks to the EventBus singleton.
"""
from __future__ import annotations
import sys
import threading
import time
from pathlib import Path
from typing import Optional

_RECOIL = Path(__file__).resolve().parent.parent.parent.parent
if str(_RECOIL) not in sys.path:
    sys.path.insert(0, str(_RECOIL))

from v2.events import (
    emit_batch_summary, emit_eval_completed, emit_step_status, emit_take_completed,
)

# Track active threads — one per (project, episode)
_active: dict[str, threading.Thread] = {}
_lock = threading.Lock()


def is_active(project: str, episode: str) -> bool:
    key = f"{project}::{episode}"
    with _lock:
        t = _active.get(key)
        return t is not None and t.is_alive()


def launch(project: str, episode: str, budget_usd: float = 25.0) -> bool:
    """Launch a batch in a daemon thread. Returns False if already running."""
    key = f"{project}::{episode}"
    with _lock:
        if is_active(project, episode):
            return False
        t = threading.Thread(target=_run, args=(project, episode, budget_usd),
                             daemon=True, name=f"batch-{key}")
        _active[key] = t
        t.start()
    return True


def _run(project: str, episode: str, budget_usd: float) -> None:
    try:
        from orchestrator.production_loop import ProductionLoop
        from orchestrator.production_types import BatchConfig
    except Exception as exc:
        emit_step_status(project, episode, "_init_", f"import_error: {exc}")
        return

    completed = failed = 0
    total_cost = 0.0
    started = time.time()

    # Hook callbacks — production_loop signature matches CP-6 Workflow hooks
    def pre_step(shot_id: str, **_: object) -> None:
        emit_step_status(project, episode, shot_id, "running")

    def post_step(shot_id: str, take_id: str, score: Optional[float],
                  cost_usd: float, **_: object) -> None:
        nonlocal completed, total_cost
        completed += 1
        total_cost += float(cost_usd or 0.0)
        emit_take_completed(project, shot_id, take_id, score, cost_usd or 0.0)

    def on_failure(shot_id: str, error: str, **_: object) -> None:
        nonlocal failed
        failed += 1
        emit_step_status(project, episode, shot_id, f"failed: {error}")

    try:
        loop = ProductionLoop(
            config=BatchConfig(project=project, episode_id=episode,
                               budget_usd=budget_usd),
        )
        # ProductionLoop signature: try kwargs, fall back to attribute set
        try:
            loop.run(pre_step=pre_step, post_step=post_step, on_failure=on_failure)
        except TypeError:
            for hook_name, fn in (("pre_step", pre_step),
                                  ("post_step", post_step),
                                  ("on_failure", on_failure)):
                setattr(loop, hook_name, fn)
            loop.run()
    except Exception as exc:
        emit_step_status(project, episode, "_loop_", f"crashed: {exc}")
    finally:
        emit_batch_summary(project, episode, completed, failed, total_cost)
```

### `recoil/pipeline/v2/routes/workspace.py` — append (don't rewrite Phase 6 contents)

The Phase 7 sub-agent re-opens this file (created in Phase 6) and APPENDS the
new mutation routes after the existing GET. The Phase 6 contents are quoted
verbatim below for context — do NOT modify or delete those lines, only add
after them.

```python
# ============================================================
# (existing GET /workspace from Phase 6 — do NOT modify below)
# ============================================================
from fastapi import APIRouter, Query
from v2 import state
from v2.types import WorkspaceState

router = APIRouter()

@router.get("/workspace", response_model=WorkspaceState)
def get_workspace(project: str = Query(...)):
    return state.load_workspace(project)

# ============================================================
# Phase 7 additions below — append after the Phase 6 GET
# ============================================================
from pydantic import BaseModel
from v2.types import ArtifactMount, NavigatorFocus

class _NoteBody(BaseModel):
    note: str

@router.post("/project/{project}/workspace/focus")
def set_focus(project: str, focus: NavigatorFocus):
    state.update_focus(project, focus)
    return {"ok": True}

@router.post("/project/{project}/workspace/mount")
def set_mount(project: str, mount: ArtifactMount):
    state.update_mount(project, mount)
    return {"ok": True}

@router.post("/project/{project}/beat/{beat}/note")
def add_note(project: str, beat: str, body: _NoteBody):
    import time as _t
    from v2.types import AuditEvent
    state.append_audit(AuditEvent(
        project=project, beat_id=beat, kind="source_note",
        payload={"text": body.note}, ts=_t.time(),
    ))
    return {"ok": True}
```

### `recoil/pipeline/v2/routes/policy.py`

```python
from fastapi import APIRouter
from v2 import state
from v2.types import EvalPolicy

router = APIRouter()

@router.get("/project/{project}/policy", response_model=EvalPolicy)
def get_policy(project: str):
    return state.load_policy(project)

@router.patch("/project/{project}/policy", response_model=EvalPolicy)
def patch_policy(project: str, policy: EvalPolicy):
    state.save_policy(project, policy)
    return state.load_policy(project)
```

### `recoil/pipeline/v2/routes/proposal.py`

```python
import time
from fastapi import APIRouter, HTTPException
from pydantic import ValidationError
from v2 import state
from v2.types import AuditEvent, SpecEditProposal

router = APIRouter()

@router.post("/project/{project}/proposal/approve")
def approve(project: str, proposal: SpecEditProposal):
    # Affected beat(s)
    beat_ids: list[str] = []
    if hasattr(proposal, "beat_id") and getattr(proposal, "beat_id", None):
        beat_ids = [proposal.beat_id]
    elif hasattr(proposal, "beat_ids") and getattr(proposal, "beat_ids", None):
        beat_ids = list(proposal.beat_ids)
    elif hasattr(proposal, "after_beat_id"):
        beat_ids = [proposal.after_beat_id]
    elif hasattr(proposal, "source_beat_id"):
        beat_ids = [proposal.source_beat_id]

    payload = proposal.model_dump()
    for bid in beat_ids:
        state.append_audit(AuditEvent(
            project=project, beat_id=bid, kind="applied_edit",
            payload=payload, ts=time.time(),
        ))
    return {"ok": True, "type": proposal.type, "beats_logged": beat_ids}
```

### `recoil/pipeline/v2/routes/batch.py`

```python
from fastapi import APIRouter
from pydantic import BaseModel
from v2.events import EventBus
from v2.services import batch_runner

router = APIRouter()

class _LaunchBody(BaseModel):
    episode_id: str
    budget_usd: float = 25.0

@router.post("/project/{project}/batch/launch")
def launch(project: str, body: _LaunchBody):
    if batch_runner.is_active(project, body.episode_id):
        return {"ok": False, "error": "batch already running",
                "project": project, "episode": body.episode_id}
    batch_runner.launch(project, body.episode_id, body.budget_usd)
    return {"ok": True, "project": project, "episode": body.episode_id}

@router.get("/project/{project}/batch/status")
def status(project: str):
    bus = EventBus.get()
    recent = [e for e in bus.replay(last_id=None)
              if e.get("data", {}).get("project") == project][-50:]
    return {"project": project, "events": recent}
```

### `recoil/pipeline/v2/tests/test_routes_mutation.py`

```python
import pytest
from fastapi.testclient import TestClient

@pytest.fixture
def client(tmp_path, monkeypatch):
    from v2 import state
    state.set_db_path(tmp_path / "test.db")
    from v2.main import app
    yield TestClient(app)
    state.set_db_path(None)

def test_focus_round_trip(client):
    r = client.post("/v2/api/project/p/workspace/focus",
                    json={"project": "p", "episode": "EP001", "beat": "B7"})
    assert r.status_code == 200
    g = client.get("/v2/api/workspace?project=p")
    assert g.json()["navigator_focus"]["beat"] == "B7"

def test_mount_round_trip(client):
    r = client.post("/v2/api/project/p/workspace/mount",
                    json={"template": "TakesBrowser", "context": {"beat_id": "B7"}})
    assert r.status_code == 200
    g = client.get("/v2/api/workspace?project=p")
    assert g.json()["artifact_mount"]["template"] == "TakesBrowser"

def test_note_appends_to_audit(client):
    r = client.post("/v2/api/project/p/beat/B7/note", json={"note": "drift"})
    assert r.status_code == 200
    g = client.get("/v2/api/project/p/beat/B7/audit")
    notes = g.json()["source_notes"]
    assert len(notes) == 1
    assert notes[0]["payload"]["text"] == "drift"

def test_proposal_approve_logs_audit(client):
    r = client.post("/v2/api/project/p/proposal/approve", json={
        "type": "prompt_rewrite", "beat_id": "B7",
        "new_prompt": "rewritten", "rationale": "pacing",
    })
    assert r.status_code == 200, r.text
    g = client.get("/v2/api/project/p/beat/B7/audit")
    edits = g.json()["applied_edits"]
    assert len(edits) == 1
    assert edits[0]["payload"]["new_prompt"] == "rewritten"

def test_proposal_invalid_type_rejected(client):
    r = client.post("/v2/api/project/p/proposal/approve", json={"type": "bogus"})
    assert r.status_code in (400, 422)

def test_policy_default_then_patch(client):
    g = client.get("/v2/api/project/p/policy")
    assert g.status_code == 200
    assert g.json()["version"] == 1

    p = client.patch("/v2/api/project/p/policy", json={
        "version": 1,
        "rules": [{"step_type": "image_t2i", "required_panels": ["custom_panel"], "intentional_skip": False}],
        "on_miss": "badge_config_miss",
    })
    assert p.status_code == 200
    assert p.json()["rules"][0]["required_panels"] == ["custom_panel"]

def test_batch_launch_idempotent_when_active(client, monkeypatch):
    from v2.services import batch_runner
    monkeypatch.setattr(batch_runner, "is_active", lambda p, e: True)
    r = client.post("/v2/api/project/p/batch/launch", json={"episode_id": "EP001"})
    assert r.status_code == 200
    assert r.json()["ok"] is False
```

### Scope boundary
- Do NOT modify production_loop.py (CP-9 sprint guarantee — byte-stable)
- batch_runner uses TWO call patterns (kwargs + setattr) because production_loop's hook contract is not a finalized interface — the fallback is intentional defensive code. Sub-agent: leave both patterns.
- Do NOT call EventBus.emit() directly from routes — go through service emitters

### Validation
```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/recoil/pipeline && \
python3 -m pytest v2/tests/test_routes_mutation.py -v && \
echo "Phase 7 OK"
```

---

## Phase 8: Claude Chat Endpoint + tests
**depends_on:** 5, 7
**engine:** opus

### What already exists (from prior phases)
- `v2/types.py` — `ChatMessage`, `ChatRequest`, `ArtifactContext`, `ChatStreamEvent` discriminated union
- `v2/state.py` — `append_chat`, `get_thread`, `thread_length`
- `v2/settings.py` — `chat_model`, `chat_max_tokens`, `anthropic_api_key`

### Files to create
- `recoil/pipeline/v2/services/claude_chat.py`
- `recoil/pipeline/v2/tests/test_chat.py`

### Files to modify
- `recoil/pipeline/v2/routes/chat.py` — fill in (POST chat with SSE streaming)

### Design

- Anthropic SDK `client.messages.stream(...)` runs synchronously; we wrap in `asyncio.to_thread`.
- Stream events flow: SDK token chunk → push to `asyncio.Queue` → FastAPI `StreamingResponse` async-iterates the queue.
- **Prompt caching** (mandatory): the `system` prompt has TWO content blocks — `[{type:"text", text:_SYSTEM_BASE, cache_control:{type:"ephemeral"}}, {type:"text", text:f"\n\n{ctx_prefix}"}]`. The base system prompt cache-hits across turns; the volatile context prefix sits in a second uncached block.
- **Server-side fast-path** is a fallback ONLY (client-side fast-path in Phase 14 catches typed-text inputs). Server fast-path catches voice-transcribed `/commands`. Same regex set.
- **Action parsing:** Claude returns inline `\`\`\`json {...} \`\`\`` blocks. We parse them out and emit them as typed `ChatStreamEvent`s after token streaming.

### `recoil/pipeline/v2/services/claude_chat.py`

```python
"""
Claude chat service for Console v2.
Sync streaming SDK call wrapped in asyncio for SSE consumption.
Includes prompt caching (mandated by ~/.claude/CLAUDE.md).
"""
from __future__ import annotations
import asyncio
import json
import re
import time
from typing import AsyncIterator, List, Optional

from v2 import state
from v2.settings import settings
from v2.types import (
    ArtifactContext, ChatMessage,
    ChatDoneEvent, ChatErrorEvent, ChatFocusEvent, ChatMountEvent,
    ChatProposalEvent, ChatTokenEvent, NavigatorFocus,
    proposal_from_dict_via_pydantic,
)

# Anthropic SDK is optional at import time so tests don't require the package
try:
    import anthropic
    _HAS_SDK = True
except ImportError:
    _HAS_SDK = False


_SYSTEM_BASE = """\
You are the Recoil Console v2 AI operator. You assist a filmmaker (JT) with
AI-driven vertical microdrama production.

You receive a [WORKSPACE CONTEXT: ...] line at the start of every turn — it
tells you which project/episode/beat/take JT is currently looking at. Use it.

## Your capabilities
You can do three things by emitting JSON code blocks (```json ... ```):

1. Mount an artifact in the stage:
   {"action":"mount","template":"TakesBrowser","context":{"beat_id":"B7"}}
   Available templates: EpisodeOverview, TakesBrowser, BeatOverview,
   ScriptEditor, StoryboardGrid, KeyframeGrid, BatchDiagnostics,
   RetryStrategyAnalysis, EvalCoverageReport, AudioPlayer.

2. Move the navigator focus:
   {"action":"focus","project":"tartarus","episode":"EP001","beat":"B7"}

3. Propose a spec edit (rendered as a card; JT presses Cmd+Enter to approve):
   {"type":"prompt_rewrite","beat_id":"B7","new_prompt":"...","rationale":"..."}
   Eight proposal types: prompt_rewrite, beat_insertion, parameter_change,
   script_edit, multi_beat_directive, extract_cutaway, ref_swap,
   retry_strategy_edit. Schema details on request.

## Rules
- Use semantic identifiers (beat_id, take_id, episode) — never raw file paths.
- Be concise. This is a production tool, not a chat interface.
- One JSON block per response when proposing/mounting/focusing.
"""


# ── Server-side fast-path (fallback for non-client transcribed input) ────────

_FAST_PATH = [
    (re.compile(r'^/audio\s+(\S+)', re.I),
     lambda m: ChatMountEvent(template="AudioPlayer", context={"take_id": m.group(1)})),
    (re.compile(r'^/takes\s+(\S+)', re.I),
     lambda m: ChatMountEvent(template="TakesBrowser", context={"beat_id": m.group(1)})),
    (re.compile(r'^/storyboard(?:\s+(\S+))?', re.I),
     lambda m: ChatMountEvent(template="StoryboardGrid",
                              context={"episode": m.group(1) or ""})),
    (re.compile(r'^/script(?:\s+(\S+))?', re.I),
     lambda m: ChatMountEvent(template="ScriptEditor",
                              context={"episode": m.group(1) or ""})),
    (re.compile(r'^/board(?:\s+(\S+))?', re.I),
     lambda m: ChatMountEvent(template="EpisodeOverview",
                              context={"episode": m.group(1) or ""})),
    (re.compile(r'^/diagnostics', re.I),
     lambda m: ChatMountEvent(template="BatchDiagnostics", context={})),
    (re.compile(r'^/eval', re.I),
     lambda m: ChatMountEvent(template="EvalCoverageReport", context={})),
]

def fast_path(text: str) -> Optional[ChatMountEvent]:
    text = text.strip()
    if not text.startswith("/"): return None
    for pat, build in _FAST_PATH:
        m = pat.match(text)
        if m: return build(m)
    return None


# ── Action parsing (extract JSON blocks from final response) ─────────────────

_JSON_BLOCK = re.compile(r'```json\s*(\{.*?\})\s*```', re.DOTALL)
_PROPOSAL_TYPES = {
    "prompt_rewrite", "beat_insertion", "parameter_change", "script_edit",
    "multi_beat_directive", "extract_cutaway", "ref_swap", "retry_strategy_edit",
}

def parse_actions(text: str):
    """Yield ChatMountEvent | ChatFocusEvent | ChatProposalEvent from a response."""
    for m in _JSON_BLOCK.finditer(text):
        try:
            obj = json.loads(m.group(1))
        except json.JSONDecodeError:
            continue
        action = obj.get("action")
        if action == "mount":
            yield ChatMountEvent(template=obj.get("template", ""),
                                 context=obj.get("context", {}))
        elif action == "focus":
            yield ChatFocusEvent(focus=NavigatorFocus(
                project=obj.get("project"), episode=obj.get("episode"),
                scene=obj.get("scene"),     beat=obj.get("beat"),
                take=obj.get("take"),
            ))
        elif obj.get("type") in _PROPOSAL_TYPES:
            try:
                proposal = proposal_from_dict_via_pydantic(obj)
                yield ChatProposalEvent(payload=proposal)
            except Exception:
                continue


# ── Streaming endpoint (yields SSE-formatted lines) ──────────────────────────

async def stream_chat(project: str, episode: str, user_text: str,
                      ctx: ArtifactContext) -> AsyncIterator[bytes]:
    """Async generator; yields raw SSE lines (already encoded)."""
    # Persist user message
    state.append_chat(project, episode, ChatMessage(role="user", content=user_text, ts=time.time()))

    # Server-side fast-path
    fp = fast_path(user_text)
    if fp is not None:
        ack = "[fast-path] " + fp.template
        state.append_chat(project, episode,
                          ChatMessage(role="assistant", content=ack, ts=time.time()))
        yield _sse(fp)
        yield _sse(ChatDoneEvent(total_tokens=0))
        return

    if not _HAS_SDK or not _api_key():
        msg = ChatErrorEvent(text="Anthropic SDK or API key unavailable")
        yield _sse(msg)
        yield _sse(ChatDoneEvent(total_tokens=0))
        return

    history = state.get_thread(project, episode, limit=40)  # last 40 turns; cap context
    api_messages = [{"role": m.role, "content": m.content}
                    for m in history if m.role in ("user", "assistant")]

    # Cached system prompt: base block (cached) + volatile context block (uncached)
    system_blocks = [
        {"type": "text", "text": _SYSTEM_BASE,
         "cache_control": {"type": "ephemeral"}},
        {"type": "text", "text": ctx.as_system_prefix()},
    ]

    queue: asyncio.Queue = asyncio.Queue(maxsize=200)
    full_text: list[str] = []
    total_tokens: list[int] = [0]

    def _run_sync():
        client = anthropic.Anthropic(api_key=_api_key())
        try:
            with client.messages.stream(
                model=settings.chat_model,
                max_tokens=settings.chat_max_tokens,
                system=system_blocks,
                messages=api_messages,
            ) as stream:
                for chunk in stream.text_stream:
                    full_text.append(chunk)
                    asyncio.run_coroutine_threadsafe(
                        queue.put({"kind": "token", "text": chunk}), loop)
                final = stream.get_final_message()
                if final and final.usage:
                    total_tokens[0] = final.usage.input_tokens + final.usage.output_tokens
            asyncio.run_coroutine_threadsafe(queue.put({"kind": "done"}), loop)
        except Exception as exc:
            asyncio.run_coroutine_threadsafe(queue.put({"kind": "error", "text": str(exc)}), loop)

    loop = asyncio.get_running_loop()
    loop.run_in_executor(None, _run_sync)

    while True:
        item = await queue.get()
        kind = item["kind"]
        if kind == "token":
            yield _sse(ChatTokenEvent(text=item["text"]))
        elif kind == "error":
            yield _sse(ChatErrorEvent(text=item["text"]))
            yield _sse(ChatDoneEvent(total_tokens=total_tokens[0]))
            return
        elif kind == "done":
            break

    response = "".join(full_text)
    state.append_chat(project, episode,
                      ChatMessage(role="assistant", content=response, ts=time.time()))
    for action in parse_actions(response):
        yield _sse(action)
    yield _sse(ChatDoneEvent(total_tokens=total_tokens[0]))


def _api_key() -> Optional[str]:
    import os
    return settings.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY")


def _sse(event_model) -> bytes:
    return f"data: {event_model.model_dump_json()}\n\n".encode()
```

### `proposal_from_dict_via_pydantic` lives in `v2/types.py` (Phase 2)

The discriminated-union validator helper used by `claude_chat.py` is defined
in Phase 2's `v2/types.py` (right after the `SpecEditProposal` union). Phase 8
imports it via:

```python
from v2.types import proposal_from_dict_via_pydantic
```

Do NOT append a duplicate definition to `types.py` here — the import above is
the only wiring required.

### `recoil/pipeline/v2/routes/chat.py`

```python
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from v2 import state
from v2.services.claude_chat import stream_chat
from v2.types import ArtifactContext, ChatMessage, ChatRequest

router = APIRouter()

@router.post("/project/{project}/episode/{episode}/chat")
async def chat(project: str, episode: str, body: ChatRequest):
    ctx = body.artifact_context or ArtifactContext()
    return StreamingResponse(
        stream_chat(project, episode, body.message, ctx),
        media_type="text/event-stream",
    )

@router.get("/project/{project}/episode/{episode}/chat/history")
async def get_chat_history(
    project: str,
    episode: str,
    limit: int = 40,
) -> list[ChatMessage]:
    """Return recent chat messages for a project/episode.

    Used by ChatPanel.tsx to repopulate the visible message list when the
    navigator focus switches between projects/episodes (replaces the
    setMessages([]) reset that lived in Phase 14 V1).
    """
    thread = state.get_thread(project, episode)
    return thread[-limit:] if thread else []
```

### `recoil/pipeline/v2/tests/test_chat.py`

```python
"""Chat service tests — fast-path, action parsing. Real SDK calls NOT exercised here."""
from v2.services import claude_chat as cc
from v2.types import ChatMountEvent, ChatFocusEvent, ChatProposalEvent

def test_fast_path_audio():
    e = cc.fast_path("/audio B7-take-3")
    assert isinstance(e, ChatMountEvent)
    assert e.template == "AudioPlayer"
    assert e.context["take_id"] == "B7-take-3"

def test_fast_path_misses_non_command():
    assert cc.fast_path("hello there") is None

def test_fast_path_storyboard_no_arg():
    e = cc.fast_path("/storyboard")
    assert e.template == "StoryboardGrid"
    assert e.context["episode"] == ""

def test_parse_action_mount():
    text = 'Sure, mounting now.\n```json\n{"action":"mount","template":"TakesBrowser","context":{"beat_id":"B7"}}\n```'
    actions = list(cc.parse_actions(text))
    assert len(actions) == 1
    assert isinstance(actions[0], ChatMountEvent)
    assert actions[0].template == "TakesBrowser"

def test_parse_action_proposal():
    text = '```json\n{"type":"prompt_rewrite","beat_id":"B7","new_prompt":"x","rationale":"y"}\n```'
    actions = list(cc.parse_actions(text))
    assert len(actions) == 1
    assert isinstance(actions[0], ChatProposalEvent)
    assert actions[0].payload.beat_id == "B7"

def test_parse_action_focus():
    text = '```json\n{"action":"focus","project":"p","beat":"B9"}\n```'
    actions = list(cc.parse_actions(text))
    assert isinstance(actions[0], ChatFocusEvent)
    assert actions[0].focus.beat == "B9"

def test_parse_actions_ignores_garbage():
    text = '```json\n{"action":"unknown"}\n```\n```json\n{"type":"bogus"}\n```'
    assert list(cc.parse_actions(text)) == []

def test_parse_actions_recovers_from_bad_json():
    text = '```json\nNOT VALID JSON\n```\n```json\n{"action":"mount","template":"X"}\n```'
    actions = list(cc.parse_actions(text))
    assert len(actions) == 1
    assert actions[0].template == "X"
```

### Scope boundary
- Do NOT call Claude API in tests (no network)
- Do NOT lower `cache_control` to a single block — the two-block pattern is mandatory
- Do NOT exceed 40-message context (compaction cap; longer threads truncate from the head)
- Do NOT block the event loop in the streaming generator — `_run_sync` runs in executor

### Validation
```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/recoil/pipeline && \
python3 -m pytest v2/tests/test_chat.py -v && \
grep -q "cache_control" v2/services/claude_chat.py && \
grep -q "ephemeral" v2/services/claude_chat.py && \
echo "Phase 8 OK"
```

---

## Phase 9: Pydantic → TypeScript Codegen
**depends_on:** 2
**engine:** opus

### Files to create
- `recoil/pipeline/v2/codegen/__init__.py` (empty)
- `recoil/pipeline/v2/codegen/gen_types.py`
- `recoil/pipeline/v2/codegen/README.md`

### Approach
- Use `pydantic-to-typescript` (`pip install pydantic-to-typescript`) — it shells out to `json2ts` (`npm install -g json-schema-to-typescript`).
- Write a small wrapper: `python -m v2.codegen.gen_types` reads `v2/types.py`, produces `editors/v2/src/types.gen.ts`.
- Phase 10 wires this into `package.json` scripts: `"gen:types": "python3 -m v2.codegen.gen_types"`.
- CI assertion (in Phase 17 smoke test): re-run codegen, `git diff --exit-code editors/v2/src/types.gen.ts` — fail build if drifted.

### `recoil/pipeline/v2/codegen/gen_types.py`

```python
"""
Pydantic → TypeScript codegen for Console v2.

Usage (from recoil/pipeline/):
    python3 -m v2.codegen.gen_types

Reads v2.types, writes editors/v2/src/types.gen.ts.
The output is read-only; edit v2/types.py and re-run instead.
"""
from __future__ import annotations
import sys
from pathlib import Path

PIPELINE = Path(__file__).resolve().parent.parent.parent
RECOIL   = PIPELINE.parent
for p in (str(RECOIL), str(PIPELINE)):
    if p not in sys.path:
        sys.path.insert(0, p)

OUT_PATH = PIPELINE / "editors" / "v2" / "src" / "types.gen.ts"


def main() -> int:
    try:
        from pydantic2ts import generate_typescript_defs
    except ImportError:
        print("FATAL: pip install pydantic-to-typescript", file=sys.stderr)
        return 1

    OUT_PATH.parent.mkdir(parents=True, exist_ok=True)

    # generate_typescript_defs reads a Python module path and writes TS
    generate_typescript_defs(
        module="v2.types",
        output=str(OUT_PATH),
        json2ts_cmd="json2ts",   # requires `npm install -g json-schema-to-typescript`
    )

    # Prepend a banner so editors don't accidentally hand-edit
    banner = (
        "/**\n"
        " * AUTO-GENERATED by recoil/pipeline/v2/codegen/gen_types.py\n"
        " * SOURCE: recoil/pipeline/v2/types.py\n"
        " * DO NOT EDIT — regenerate with: npm run gen:types\n"
        " */\n\n"
    )
    OUT_PATH.write_text(banner + OUT_PATH.read_text())
    print(f"Wrote {OUT_PATH}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
```

### `recoil/pipeline/v2/codegen/README.md`

```markdown
# Codegen

`v2/types.py` (Pydantic) is the SSOT for every type that crosses the wire.
TypeScript mirrors are auto-generated.

## Setup (one-time)

```bash
pip install pydantic-to-typescript
npm install -g json-schema-to-typescript
```

## Regenerate

```bash
cd recoil/pipeline
python3 -m v2.codegen.gen_types
# → recoil/pipeline/editors/v2/src/types.gen.ts
```

Or via npm: `npm run gen:types` (from `editors/v2/`).

## CI

The harness asserts no drift:

```bash
python3 -m v2.codegen.gen_types
git diff --exit-code editors/v2/src/types.gen.ts || \
  { echo "TS types drifted from Pydantic — run npm run gen:types"; exit 1; }
```
```

### Add to `requirements.txt` (Phase 5 — sub-agent re-opens):
```
pydantic-to-typescript>=2.0
```

### Scope boundary
- The TS output file is committed to git (so a fresh checkout works without running codegen)
- The TS output file is read-only — banner says so
- Codegen runs at dev time AND in CI (Phase 17) — never at FastAPI startup

### Validation
```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS && \
python3 -c "import ast; ast.parse(open('recoil/pipeline/v2/codegen/gen_types.py').read())" && \
test -f recoil/pipeline/v2/codegen/README.md && \
grep -q "json2ts" recoil/pipeline/v2/codegen/gen_types.py && \
grep -q "AUTO-GENERATED" recoil/pipeline/v2/codegen/gen_types.py && \
echo "Phase 9 OK (run gen_types in Phase 10)"
```

---

## Phase 10: Vite + Solid + TypeScript Scaffold
**depends_on:** 1, 9
**engine:** gemini

### Files to create
- `recoil/pipeline/editors/v2/package.json`
- `recoil/pipeline/editors/v2/tsconfig.json`
- `recoil/pipeline/editors/v2/vite.config.ts`
- `recoil/pipeline/editors/v2/index.html`
- `recoil/pipeline/editors/v2/.gitignore`
- `recoil/pipeline/editors/v2/src/main.tsx`
- `recoil/pipeline/editors/v2/src/styles/tokens.css`
- `recoil/pipeline/editors/v2/src/styles/shell.css`

### `recoil/pipeline/editors/v2/package.json`

```json
{
  "name": "recoil-console-v2",
  "private": true,
  "version": "2.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview",
    "typecheck": "tsc -b --noEmit",
    "gen:types": "cd ../.. && python3 -m v2.codegen.gen_types",
    "gen:check": "npm run gen:types && git diff --exit-code src/types.gen.ts"
  },
  "dependencies": {
    "solid-js": "^1.8.0"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "typescript": "^5.4.0",
    "vite": "^5.2.0",
    "vite-plugin-solid": "^2.10.0"
  }
}
```

### `recoil/pipeline/editors/v2/tsconfig.json`

```json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "preserve",
    "jsxImportSource": "solid-js",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "allowSyntheticDefaultImports": true,
    "forceConsistentCasingInFileNames": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "types": ["vite/client"],
    "baseUrl": ".",
    "paths": { "~/*": ["./src/*"] }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
```

### `recoil/pipeline/editors/v2/vite.config.ts`

```typescript
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
import path from "node:path";

export default defineConfig({
  plugins: [solid()],
  resolve: { alias: { "~": path.resolve(__dirname, "src") } },
  base: "/v2/static/",
  server: {
    port: 5173,
    proxy: {
      "/v2/api": "http://127.0.0.1:8431",
    },
  },
  build: {
    outDir: "dist",
    emptyOutDir: true,
    sourcemap: true,
  },
});
```

### `recoil/pipeline/editors/v2/index.html`

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>RECOIL LABS — Console v2</title>
  <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Inter:wght@300;400;600&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet" />
  <link rel="stylesheet" href="/v2/static/styles/tokens.css" />
  <link rel="stylesheet" href="/v2/static/styles/shell.css" />
</head>
<body>
  <div id="root"></div>
  <script type="module" src="/v2/static/src/main.tsx"></script>
</body>
</html>
```

### `recoil/pipeline/editors/v2/.gitignore`

```
node_modules/
dist/
.DS_Store
*.log
```

### `recoil/pipeline/editors/v2/src/main.tsx`

```typescript
/* @refresh reload */
import { render } from "solid-js/web";
import App from "./App";

const root = document.getElementById("root");
if (!root) throw new Error("missing #root");
render(() => <App />, root);
```

### `recoil/pipeline/editors/v2/src/styles/tokens.css`

```css
/* CSS custom properties — extracted from DESIGN_SYSTEM_V2.md.
   Phase 11 sub-agent: copy CSS variables from DESIGN_SYSTEM_V2.md verbatim.
   This file is the runtime SSOT for tokens; DESIGN_SYSTEM_V2.md is the design SSOT. */

:root {
  /* Inherited from existing console.css palette */
  --bg-primary: #0a0a0f;
  --bg-secondary: #12121a;
  --bg-tertiary: #1a1a24;
  --bg-card: #12121a;
  --bg-hover: #1e1e2e;
  --bg-active: #252535;
  --border-dim: #1e1e2e;
  --border-default: #2a2a3a;
  --border-bright: #3a3a4a;
  --text-primary: #e0e0e0;
  --text-secondary: #aaa;
  --text-dim: #666;
  --text-label: #888;
  --accent-cyan: #00f0ff;
  --accent-cyan-dim: #003a40;
  --accent-green: #22c55e;
  --accent-green-dim: #1a3a2e;
  --accent-amber: #f59e0b;
  --accent-amber-dim: #5a3d00;
  --accent-red: #ef4444;
  --accent-red-dim: #5a1a1a;
  --accent-magenta: #ff00aa;
  --accent-purple: #9c27b0;
  --font-display: "Orbitron", monospace;
  --font-mono: "JetBrains Mono", "SF Mono", monospace;
  --font-sans: "Inter", -apple-system, sans-serif;
  --radius-sm: 4px;
  --radius-md: 6px;
  --radius-lg: 8px;
  --transition-fast: 150ms ease;
  --transition-normal: 250ms ease;

  /* v2-specific (from DESIGN_SYSTEM_V2.md) */
  --v2-nav-width: 260px;
  --v2-chat-width: 360px;
  --v2-chrome-top-h: 44px;
  --v2-bay-h: 48px;
  --v2-bay-h-expanded: 160px;
  --v2-resize-handle-w: 4px;
  --v2-panel-border: 1px solid var(--border-dim);
}

* { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root {
  height: 100vh; overflow: hidden;
  background: var(--bg-primary); color: var(--text-primary);
  font-family: var(--font-sans); font-size: 14px; line-height: 1.5;
}
```

### `recoil/pipeline/editors/v2/src/styles/shell.css`

Phase 11 sub-agent: copy ALL three-panel/navigator/stage/chat/bay/breadcrumb/palette/badge CSS from DESIGN_SYSTEM_V2.md into this file. Phase 10 sub-agent creates the file with a TODO marker:

```css
/* Three-panel shell + components.
   Source: editors/v2/DESIGN_SYSTEM_V2.md
   Phase 11 fills this in by transcribing the design system. */

.v2-shell {
  display: flex; flex-direction: column;
  height: 100vh; overflow: hidden;
  background: var(--bg-primary);
}
```

### Post-create steps the sub-agent runs

```bash
cd recoil/pipeline/editors/v2
npm install
# Ensure json2ts is available globally (required by the codegen pipeline —
# pydantic-to-typescript shells out to it). Idempotent: only installs if missing.
npm list -g json-schema-to-typescript >/dev/null 2>&1 || \
  npm install -g json-schema-to-typescript
# Generate the TS types from Pydantic
npm run gen:types
# Verify TS compiles (will fail until Phase 11+ adds App.tsx etc; just check the scaffold parses)
node -e "console.log('scaffold ok')"
```

### Scope boundary
- Do NOT write App.tsx, components, templates yet (Phases 11–17)
- Do NOT install React, Vue, Svelte, htmx, lit-html, or any other framework
- Do NOT add Tailwind / SaaS-pastel CSS frameworks (synthesis aesthetic lock #30)

### Validation
```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/recoil/pipeline/editors/v2 && \
test -f package.json && \
test -f tsconfig.json && \
test -f vite.config.ts && \
test -f index.html && \
test -f src/main.tsx && \
test -f src/styles/tokens.css && \
test -d node_modules || (echo "WARN: npm install not yet run" && true) && \
echo "Phase 10 OK"
```

---

## Phase 11: Three-Panel Shell + Signal Store
**depends_on:** 10
**engine:** opus

### Files to create
- `recoil/pipeline/editors/v2/src/App.tsx`
- `recoil/pipeline/editors/v2/src/store.ts`
- `recoil/pipeline/editors/v2/src/api.ts`
- `recoil/pipeline/editors/v2/src/sse.ts`
- `recoil/pipeline/editors/v2/src/styles/components.css`

### Files to modify
- `recoil/pipeline/editors/v2/src/styles/shell.css` — fill in (transcribe from DESIGN_SYSTEM_V2.md)

### `recoil/pipeline/editors/v2/src/store.ts`

```typescript
/**
 * Reactive workspace store — single source of truth for client state.
 *
 * This is the bidirectional context binding hub:
 *   - Click in navigator → setFocus(...) → ArtifactContext signal updates →
 *     chat panel context bar refreshes; chat sends include the new context
 *   - Claude emits {action:"focus", ...} → setFocus(...) → navigator highlights
 *     the matching node "as if you'd clicked it"
 *   - Mounts work the same way: setMount(...) is the universal entry point
 */
import { createSignal, createMemo } from "solid-js";
import type {
  ArtifactContext, ArtifactMount, NavigatorFocus, WorkspaceState,
} from "./types.gen";
import { saveFocus, saveMount, fetchWorkspace } from "./api";

const blankFocus: NavigatorFocus = {
  project: null, episode: null, scene: null, beat: null, take: null,
};
const blankMount: ArtifactMount = { template: null, context: {} };

const [activeProject, setActiveProject] = createSignal<string | null>(null);
const [focus, _setFocus] = createSignal<NavigatorFocus>(blankFocus);
const [mount, _setMount] = createSignal<ArtifactMount>(blankMount);
const [selectedBeats,  setSelectedBeats]  = createSignal<string[]>([]);
const [selectedTakes,  setSelectedTakes]  = createSignal<string[]>([]);
const [seasonCost,     setSeasonCost]     = createSignal<number>(0);

/** ArtifactContext derived from primitive signals — auto-recomputes. */
const artifactContext = createMemo<ArtifactContext>(() => ({
  active_project: focus().project,
  active_episode: focus().episode,
  active_scene:   focus().scene,
  active_beat:    focus().beat,
  active_take:    focus().take,
  selected_beats: selectedBeats(),
  selected_takes: selectedTakes(),
  artifact_type:  mount().template,
  focused_artifact_id: null,
}));

export const store = {
  // Read
  activeProject, focus, mount, selectedBeats, selectedTakes,
  seasonCost, artifactContext,

  // Project switch (loads workspace from server)
  async switchProject(project: string) {
    setActiveProject(project);
    const ws: WorkspaceState = await fetchWorkspace(project);
    _setFocus(ws.navigator_focus);
    _setMount(ws.artifact_mount);
    setSelectedBeats([]); setSelectedTakes([]);
  },

  // Bidirectional binding: BOTH navigator clicks AND chat-emitted focus events
  // call this. There is no second path.
  setFocus(next: Partial<NavigatorFocus>) {
    const merged = { ...focus(), ...next };
    _setFocus(merged);
    const proj = merged.project;
    if (proj) saveFocus(proj, merged).catch(console.error);
  },

  setMount(template: string | null, context: Record<string, unknown> = {}) {
    const next: ArtifactMount = { template, context };
    _setMount(next);
    const proj = activeProject();
    if (proj && template) saveMount(proj, next).catch(console.error);
  },

  toggleSelectBeat(beat: string) {
    setSelectedBeats((cur) =>
      cur.includes(beat) ? cur.filter((b) => b !== beat) : [...cur, beat]);
  },

  clearSelection() {
    setSelectedBeats([]); setSelectedTakes([]);
  },

  bumpCost(usd: number) { setSeasonCost((c) => c + usd); },
};
```

### `recoil/pipeline/editors/v2/src/api.ts`

```typescript
/**
 * Typed fetch wrappers. All v2 backend calls go through here.
 * Types are imported from the codegen output (types.gen.ts).
 */
import type {
  ArtifactMount, BeatSummary, ChatMessage, ChatRequest, EpisodeSummary,
  EvalPolicy, NavigatorFocus, ProjectSummary, SpecEditProposal, TakeSummary,
  WorkspaceState,
} from "./types.gen";

const API = "/v2/api";

async function get<T>(path: string): Promise<T> {
  const r = await fetch(`${API}${path}`);
  if (!r.ok) throw new Error(`GET ${path} → ${r.status}`);
  return r.json() as Promise<T>;
}
async function send<T>(path: string, method: "POST" | "PATCH", body: unknown): Promise<T> {
  const r = await fetch(`${API}${path}`, {
    method, headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!r.ok) throw new Error(`${method} ${path} → ${r.status}`);
  return r.json() as Promise<T>;
}

// Read
export const fetchProjects   = ()                                              => get<ProjectSummary[]>("/projects");
export const fetchEpisodes   = (p: string)                                     => get<EpisodeSummary[]>(`/project/${p}/episodes`);
export const fetchBeats      = (p: string, e: string)                          => get<BeatSummary[]>(`/project/${p}/episode/${e}/beats`);
export const fetchTakes      = (p: string, e: string, b: string)               => get<TakeSummary[]>(`/project/${p}/episode/${e}/beat/${b}/takes`);
export const fetchAudit      = (p: string, b: string)                          => get(`/project/${p}/beat/${b}/audit`);
export const fetchWorkspace  = (p: string)                                     => get<WorkspaceState>(`/workspace?project=${p}`);
export const fetchPolicy     = (p: string)                                     => get<EvalPolicy>(`/project/${p}/policy`);

// Mutate
export const saveFocus       = (p: string, focus: NavigatorFocus)              => send(`/project/${p}/workspace/focus`, "POST", focus);
export const saveMount       = (p: string, mount: ArtifactMount)               => send(`/project/${p}/workspace/mount`, "POST", mount);
export const addNote         = (p: string, b: string, note: string)            => send(`/project/${p}/beat/${b}/note`, "POST", { note });
export const approveProposal = (p: string, proposal: SpecEditProposal)         => send(`/project/${p}/proposal/approve`, "POST", proposal);
export const launchBatch     = (p: string, episode: string, budget = 25.0)     => send(`/project/${p}/batch/launch`, "POST", { episode_id: episode, budget_usd: budget });
export const savePolicy      = (p: string, policy: EvalPolicy)                 => send<EvalPolicy>(`/project/${p}/policy`, "PATCH", policy);

// Chat (returns Response so caller can read SSE)
export async function postChat(p: string, e: string, body: ChatRequest): Promise<Response> {
  return fetch(`${API}/project/${p}/episode/${e}/chat`, {
    method: "POST", headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
}

// Chat history — used by ChatPanel.tsx on project/episode focus change
// (Phase 8 GET /project/{p}/episode/{e}/chat/history?limit=40).
export async function fetchChatHistory(p: string, e: string): Promise<ChatMessage[]> {
  const r = await fetch(
    `${API}/project/${encodeURIComponent(p)}/episode/${encodeURIComponent(e)}/chat/history?limit=40`,
  );
  if (!r.ok) return [];
  return r.json() as Promise<ChatMessage[]>;
}
```

### `recoil/pipeline/editors/v2/src/sse.ts`

```typescript
/**
 * SSE client for the EventBus stream (/v2/api/events).
 * Reconnects automatically with Last-Event-ID.
 */
import { createSignal, onCleanup } from "solid-js";

export type BusEvent = {
  id: number;
  type: string;
  data: Record<string, unknown>;
};

export type Listener = (e: BusEvent) => void;

let lastId: number | null = null;
const listeners = new Set<Listener>();

let es: EventSource | null = null;

function connect() {
  const url = lastId == null
    ? "/v2/api/events"
    : `/v2/api/events?lastEventId=${lastId}`;
  es = new EventSource(url);

  es.onmessage = (msg) => {
    if (!msg.data) return;
    try {
      const evt = JSON.parse(msg.data) as BusEvent;
      if (evt.id > 0) lastId = evt.id;
      listeners.forEach((l) => l(evt));
    } catch (err) {
      console.warn("SSE parse fail", err, msg.data);
    }
  };
  es.onerror = () => {
    es?.close();
    es = null;
    setTimeout(connect, 1500);  // simple backoff
  };
}

export function startSSE() { if (!es) connect(); }
export function stopSSE() { es?.close(); es = null; }

export function onBusEvent(listener: Listener) {
  listeners.add(listener);
  onCleanup(() => listeners.delete(listener));
}

/** Filter helper used by Live Bay + EvalBadge updates */
export function createBusEventSignal<T extends BusEvent>(predicate: (e: BusEvent) => e is T) {
  const [latest, setLatest] = createSignal<T | null>(null);
  onBusEvent((e) => { if (predicate(e)) setLatest(e); });
  return latest;
}
```

### `recoil/pipeline/editors/v2/src/App.tsx`

```typescript
import { onMount } from "solid-js";
import Navigator       from "./components/Navigator";
import ArtifactStage   from "./components/ArtifactStage";
import ChatPanel       from "./components/ChatPanel";
import LiveBay         from "./components/LiveBay";
import Breadcrumb      from "./components/Breadcrumb";
import CommandPalette  from "./components/CommandPalette";
import { startSSE } from "./sse";
import { store } from "./store";
import { fetchProjects } from "./api";

export default function App() {
  onMount(async () => {
    startSSE();
    // Auto-pick first project if any
    try {
      const projects = await fetchProjects();
      if (projects.length > 0 && !store.activeProject()) {
        await store.switchProject(projects[0].name);
      }
    } catch (e) { console.warn("Failed to bootstrap projects", e); }
  });

  return (
    <div class="v2-shell">
      <header class="v2-chrome-top">
        <span class="v2-brand">RECOIL</span>
        <span class="v2-breadcrumb-sep">/</span>
        <Breadcrumb />
        <span style={{ flex: 1 }} />
        <span class="v2-cost-pill">${store.seasonCost().toFixed(3)}</span>
        <CommandPaletteTrigger />
      </header>

      <div class="v2-main">
        <Navigator />
        <div class="v2-resize-handle" data-panel="nav" />
        <ArtifactStage />
        <div class="v2-resize-handle" data-panel="chat" />
        <ChatPanel />
      </div>

      <LiveBay />
      <CommandPalette />
    </div>
  );
}

function CommandPaletteTrigger() {
  return (
    <button
      class="v2-cmd-k-hint"
      onClick={() => window.dispatchEvent(new CustomEvent("v2:open-palette"))}
      title="Command palette (⌘K)"
    >⌘K</button>
  );
}
```

### Stub component files (Phase 11 creates empty placeholders so App.tsx compiles)

Phase 11 sub-agent creates these files — minimal stubs that Phases 12–15 fill in:

- `recoil/pipeline/editors/v2/src/components/Navigator.tsx`
- `recoil/pipeline/editors/v2/src/components/ArtifactStage.tsx`
- `recoil/pipeline/editors/v2/src/components/ChatPanel.tsx`
- `recoil/pipeline/editors/v2/src/components/LiveBay.tsx`
- `recoil/pipeline/editors/v2/src/components/Breadcrumb.tsx`
- `recoil/pipeline/editors/v2/src/components/CommandPalette.tsx`

Each stub:
```typescript
export default function ComponentName() {
  return <div class="v2-placeholder" data-component="ComponentName" />;
}
```

### `recoil/pipeline/editors/v2/src/styles/components.css`

Empty file with header comment; populated by Phases 12-15 as components land:

```css
/* Component-level CSS for Console v2.
   Each Phase 12-15 sub-agent appends styles for its component as it lands.
   All class names follow the v2-* namespace defined in DESIGN_SYSTEM_V2.md. */
```

### Modify `index.html` — add `components.css` link

Phase 11 sub-agent edits the existing `<head>` to include:
```html
<link rel="stylesheet" href="/v2/static/styles/components.css" />
```

### Modify `shell.css` — transcribe from DESIGN_SYSTEM_V2.md

Phase 11 sub-agent reads `editors/v2/DESIGN_SYSTEM_V2.md` and transcribes EVERY CSS code block under sections "Three-panel shell," "Hierarchy navigator," "Artifact stage," "Chat panel," "SpecEditProposal card," "4-state eval badge," "Live bay row," "Breadcrumb + top chrome," "Command palette," and "Resize handles" verbatim into `shell.css`. The design doc IS the spec for the CSS.

### Scope boundary
- `store.ts` is the ONLY place mount/focus signals are mutated; components read via `store.focus()` / `store.mount()` and write via `store.setFocus(...)` / `store.setMount(...)`
- Do NOT add a separate "navigator state" or "stage state" — there is one store
- Do NOT introduce Solid stores (`createStore`) — fine-grained signals + memos are deliberate; matches the bidirectional binding pattern simply

### Validation
```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/recoil/pipeline/editors/v2 && \
test -f src/App.tsx && \
test -f src/store.ts && \
test -f src/api.ts && \
test -f src/sse.ts && \
test -f src/styles/shell.css && \
test -f src/components/Navigator.tsx && \
test -f src/components/ArtifactStage.tsx && \
test -f src/components/ChatPanel.tsx && \
test -f src/components/LiveBay.tsx && \
test -f src/components/Breadcrumb.tsx && \
test -f src/components/CommandPalette.tsx && \
grep -q "artifactContext" src/store.ts && \
grep -q "createSignal" src/store.ts && \
grep -q "EventSource" src/sse.ts && \
# Verify all load-bearing CSS classes were transcribed from DESIGN_SYSTEM_V2.md
# into shell.css. If any are missing the layout will silently break at runtime.
for cls in v2-shell v2-nav v2-stage v2-chat v2-bay v2-tree-node \
          v2-proposal-card v2-eval-badge v2-bay-row v2-breadcrumb-seg \
          v2-palette; do
  grep -q "\\.${cls}\\b" src/styles/shell.css || \
    { echo "MISSING CSS class: $cls"; exit 1; }
done && \
npm run typecheck && \
echo "Phase 11 OK"
```

---

## Phase 12: Hierarchy Navigator
**depends_on:** 11
**engine:** opus

### What already exists (from prior phases)
- `store` with `focus`, `setFocus`, `selectedBeats`, `toggleSelectBeat`, `switchProject`
- `api.ts` with `fetchProjects`, `fetchEpisodes`, `fetchBeats`, `fetchTakes`
- CSS classes `.v2-nav`, `.v2-tree-node`, `.v2-score-badge` from shell.css

### Files to modify
- `recoil/pipeline/editors/v2/src/components/Navigator.tsx`

### Behavior
- Tree levels: Project → Episode → Scene (grouping) → Beat → Take
- Lazy-load: episodes load on project switch; beats load on episode expand; takes load on beat expand
- Score badges (high/mid/low/none) per beat from `BeatSummary.eval_badge` + `eval_score`
- Click → `store.setFocus({...})` (single click)
- Shift+Click on beat → `store.toggleSelectBeat(beat_id)` (multi-select highlight)
- Arrow keys (Up/Down/Left/Right) navigate the visible tree without LLM round-trip — bind on the `.v2-nav-tree` container with `tabindex={0}`
- Search input filters by beat_id substring (Phase 12 V1: client-side filter on currently-loaded nodes)
- Reactive: when `store.focus()` changes from anywhere (chat-emitted focus event), the matching node gets `.highlighted` class and scrolls into view

### Implementation contract

```typescript
// recoil/pipeline/editors/v2/src/components/Navigator.tsx
import { createSignal, createResource, createEffect, For, Show } from "solid-js";
import { fetchProjects, fetchEpisodes, fetchBeats, fetchTakes } from "../api";
import { store } from "../store";
import type { BeatSummary, EpisodeSummary, ProjectSummary, TakeSummary } from "../types.gen";

export default function Navigator() {
  const [projects] = createResource(fetchProjects);
  const [search, setSearch] = createSignal("");
  const [expanded, setExpanded] = createSignal<Set<string>>(new Set());

  // Derived: which scene grouping a beat belongs to (from BeatSummary.scene_id)
  // Tree rendering: project → episodes (expandable) → grouped-by-scene → beats → takes

  // Keyboard nav: capture Up/Down/Left/Right when tree has focus
  let treeRef: HTMLDivElement | undefined;
  function onKey(e: KeyboardEvent) {
    if (!["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Enter"].includes(e.key)) return;
    e.preventDefault();
    // Phase 12 sub-agent: implement nav logic against rendered nodes
    // (See "Tree keyboard navigation" notes below)
  }

  // React to chat-emitted focus changes: scroll the matching node into view
  createEffect(() => {
    const f = store.focus();
    if (!f.beat || !treeRef) return;
    const node = treeRef.querySelector<HTMLElement>(`[data-beat="${f.beat}"]`);
    node?.scrollIntoView({ block: "nearest" });
  });

  return (
    <aside class="v2-nav" aria-label="Hierarchy Navigator">
      <div class="v2-nav-header"><span>NAVIGATOR</span></div>
      <div class="v2-nav-search">
        <input
          type="text" placeholder="Search…" autocomplete="off" spellcheck={false}
          value={search()} onInput={(e) => setSearch(e.currentTarget.value)}
        />
      </div>
      <div class="v2-nav-tree" ref={treeRef} role="tree" tabindex={0} onKeyDown={onKey}>
        <Show when={projects()} fallback={<div class="v2-tree-loading">Loading…</div>}>
          <For each={projects()!}>{(p) => (
            <ProjectNode project={p} expanded={expanded()} setExpanded={setExpanded} search={search()} />
          )}</For>
        </Show>
      </div>
    </aside>
  );
}

function ProjectNode(props: {
  project: ProjectSummary; expanded: Set<string>;
  setExpanded: (s: Set<string>) => void; search: string;
}) {
  const key = `p:${props.project.name}`;
  const isOpen   = () => props.expanded.has(key);
  const isActive = () => store.activeProject() === props.project.name;

  // Chevron-only toggle. Capture wasOpen ONCE so we don't read isOpen()
  // twice across the mutation (which previously caused the
  // switchProject-after-toggle branch to misread state).
  const toggle = () => {
    const next = new Set(props.expanded);
    const wasOpen = isOpen();
    wasOpen ? next.delete(key) : next.add(key);
    props.setExpanded(next);
  };

  return (
    <>
      <div class="v2-tree-node v2-tree-project"
           classList={{ "v2-active": isActive(), selected: isActive() }}
           data-project={props.project.name}>
        <span class="v2-tree-chevron"
              onClick={(e) => { e.stopPropagation(); toggle(); }}>
          {isOpen() ? "▾" : "▸"}
        </span>
        <span class="v2-tree-label"
              onClick={() => store.switchProject(props.project.name)}>
          {props.project.name}
        </span>
      </div>
      <Show when={isOpen()}>
        <EpisodeList project={props.project.name} expanded={props.expanded}
                     setExpanded={props.setExpanded} search={props.search} />
      </Show>
    </>
  );
}

function EpisodeList(props: {
  project: string; expanded: Set<string>;
  setExpanded: (s: Set<string>) => void; search: string;
}) {
  const [eps] = createResource(() => props.project, fetchEpisodes);
  return (
    <Show when={eps()}>
      <For each={eps()!}>{(ep) => (
        <EpisodeNode project={props.project} ep={ep}
                     expanded={props.expanded} setExpanded={props.setExpanded}
                     search={props.search} />
      )}</For>
    </Show>
  );
}

function EpisodeNode(props: {
  project: string; ep: EpisodeSummary; expanded: Set<string>;
  setExpanded: (s: Set<string>) => void; search: string;
}) {
  const key = `e:${props.project}:${props.ep.episode_id}`;
  const isOpen = () => props.expanded.has(key);
  const toggle = () => {
    const next = new Set(props.expanded);
    isOpen() ? next.delete(key) : next.add(key);
    props.setExpanded(next);
    store.setFocus({ project: props.project, episode: props.ep.episode_id });
  };
  return (
    <>
      <div class="v2-tree-node" style={{ "padding-left": "20px" }}
           classList={{ selected: store.focus().episode === props.ep.episode_id }}
           onClick={toggle} data-episode={props.ep.episode_id}>
        <span class="v2-tree-chevron">{isOpen() ? "▾" : "▸"}</span>
        <span class="v2-tree-label">{props.ep.episode_id}</span>
        <span class="v2-tree-meta" style={{ "font-size": "9px", color: "var(--text-dim)" }}>
          {props.ep.beat_count}
        </span>
      </div>
      <Show when={isOpen()}>
        <BeatList project={props.project} episode={props.ep.episode_id}
                  expanded={props.expanded} setExpanded={props.setExpanded}
                  search={props.search} />
      </Show>
    </>
  );
}

function BeatList(props: {
  project: string; episode: string; expanded: Set<string>;
  setExpanded: (s: Set<string>) => void; search: string;
}) {
  const [beats] = createResource(
    () => `${props.project}::${props.episode}`,
    () => fetchBeats(props.project, props.episode),
  );
  // Group beats by scene_id
  const grouped = () => {
    const data = beats() ?? [];
    const filt = props.search ? data.filter(b => b.beat_id.includes(props.search)) : data;
    const m: Record<string, BeatSummary[]> = {};
    for (const b of filt) (m[b.scene_id ?? "_"] ??= []).push(b);
    return m;
  };
  return (
    <Show when={beats()}>
      <For each={Object.entries(grouped())}>{([sceneId, bs]) => (
        <SceneGroup project={props.project} episode={props.episode}
                    sceneId={sceneId} beats={bs}
                    expanded={props.expanded} setExpanded={props.setExpanded} />
      )}</For>
    </Show>
  );
}

function SceneGroup(props: {
  project: string; episode: string; sceneId: string;
  beats: BeatSummary[]; expanded: Set<string>; setExpanded: (s: Set<string>) => void;
}) {
  const key = `s:${props.project}:${props.episode}:${props.sceneId}`;
  const isOpen = () => props.expanded.has(key);
  const toggle = () => {
    const next = new Set(props.expanded);
    isOpen() ? next.delete(key) : next.add(key);
    props.setExpanded(next);
  };
  return (
    <>
      <div class="v2-tree-node" style={{ "padding-left": "36px" }}
           onClick={toggle} data-scene={props.sceneId}>
        <span class="v2-tree-chevron">{isOpen() ? "▾" : "▸"}</span>
        <span class="v2-tree-label" style={{ "font-style": "italic", opacity: 0.8 }}>
          {props.sceneId === "_" ? "(unscened)" : props.sceneId}
        </span>
      </div>
      <Show when={isOpen()}>
        <For each={props.beats}>{(b) => (
          <BeatNode project={props.project} episode={props.episode} beat={b} />
        )}</For>
      </Show>
    </>
  );
}

function BeatNode(props: { project: string; episode: string; beat: BeatSummary }) {
  const isFocused   = () => store.focus().beat === props.beat.beat_id;
  const isSelected  = () => store.selectedBeats().includes(props.beat.beat_id);
  const onClick = (e: MouseEvent) => {
    if (e.shiftKey) {
      store.toggleSelectBeat(props.beat.beat_id);
    } else {
      store.setFocus({ project: props.project, episode: props.episode,
                       scene: props.beat.scene_id ?? null, beat: props.beat.beat_id });
      store.setMount("TakesBrowser", { beat_id: props.beat.beat_id });
    }
  };
  const badge = () => {
    if (props.beat.eval_score == null) return "v2-score-badge none";
    const s = props.beat.eval_score;
    return s >= 0.75 ? "v2-score-badge high"
         : s >= 0.5  ? "v2-score-badge mid" : "v2-score-badge low";
  };
  return (
    <div class="v2-tree-node" style={{ "padding-left": "52px" }}
         classList={{ selected: isFocused(), highlighted: isSelected() }}
         onClick={onClick} data-beat={props.beat.beat_id}>
      <span class="v2-tree-label">{props.beat.beat_id}</span>
      <span class={badge()}>
        {props.beat.eval_score != null ? props.beat.eval_score.toFixed(2) : "—"}
      </span>
    </div>
  );
}
```

### Tree keyboard navigation (Phase 12 sub-agent: implement)
- Up/Down: move focus to prev/next visible tree node (a node is visible if all ancestors are expanded). Use a flat list rebuilt from the rendered DOM (`treeRef.querySelectorAll(".v2-tree-node")`).
- Right: if collapsed, expand. If already expanded, move down to first child.
- Left: if expanded, collapse. If already collapsed, move up to parent.
- Enter: dispatch the node's `onClick` (move focus / toggle expand).

### Scope boundary
- Do NOT fetch takes here (mount on the TakesBrowser does that)
- Do NOT mutate any state outside the store
- Do NOT add drag-and-drop, multi-select beyond Shift+Click, or reordering — V1 scope

### Validation
```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/recoil/pipeline/editors/v2 && \
grep -q "ProjectNode"     src/components/Navigator.tsx && \
grep -q "EpisodeNode"     src/components/Navigator.tsx && \
grep -q "BeatNode"        src/components/Navigator.tsx && \
grep -q "store.setFocus"  src/components/Navigator.tsx && \
grep -q "store.setMount"  src/components/Navigator.tsx && \
grep -q "store.toggleSelectBeat" src/components/Navigator.tsx && \
npm run typecheck && \
echo "Phase 12 OK"
```

---

## Phase 13: Artifact Stage + Template Registry + 3 Initial Templates
**depends_on:** 11
**engine:** opus (registry contract); Gemini for the 3 template implementations

### What already exists (from prior phases)
- `store.mount()` returns the current `ArtifactMount` (template name + context)
- `api.ts` with `fetchBeats`, `fetchTakes`
- CSS for `.v2-stage`, `.v2-stage-chrome`, `.v2-stage-content`, `.v2-eval-badge`

### Files to create
- `recoil/pipeline/editors/v2/src/templates/BaseTemplate.tsx` — contract
- `recoil/pipeline/editors/v2/src/templates/registry.ts` — registry + dynamic loader
- `recoil/pipeline/editors/v2/src/templates/EpisodeOverview.tsx`
- `recoil/pipeline/editors/v2/src/templates/TakesBrowser.tsx`
- `recoil/pipeline/editors/v2/src/templates/BeatOverview.tsx`
- `recoil/pipeline/editors/v2/src/components/EvalBadge.tsx`

### Files to modify
- `recoil/pipeline/editors/v2/src/components/ArtifactStage.tsx`

### Template contract — `BaseTemplate.tsx`

```typescript
/**
 * Template contract.
 *
 * Templates are Solid components that:
 *   - take `context: Record<string, unknown>` as their only prop
 *   - render reactive content into the artifact stage
 *   - optionally call `store.setFocus(...)` / `store.setMount(...)` to mount
 *     a different template in response to clicks (e.g. a beat row in
 *     EpisodeOverview opens TakesBrowser for that beat)
 *
 * Every named template that ships in V1 is registered in `registry.ts`.
 * Ad-hoc artifacts compose template primitives — they don't bypass the
 * contract.
 */
import type { Component } from "solid-js";

export type TemplateContext = Record<string, unknown>;

export interface TemplateMeta {
  name: string;            // unique key; matches ArtifactMount.template
  label: string;           // display label in the stage chrome
  contextSchema?: string[];// names of context keys this template consumes
}

export type TemplateComponent = Component<{ context: TemplateContext }>;

export interface TemplateModule {
  meta: TemplateMeta;
  default: TemplateComponent;
}
```

### `registry.ts`

```typescript
/**
 * Template registry — maps name → lazy importer.
 * Adding a new template = one entry here + one file in templates/.
 */
import type { TemplateModule } from "./BaseTemplate";

type Loader = () => Promise<TemplateModule>;

const registry: Record<string, Loader> = {
  EpisodeOverview:        () => import("./EpisodeOverview"),
  TakesBrowser:           () => import("./TakesBrowser"),
  BeatOverview:           () => import("./BeatOverview"),
  // Phase 16:
  ScriptEditor:           () => import("./ScriptEditor"),
  StoryboardGrid:         () => import("./StoryboardGrid"),
  KeyframeGrid:           () => import("./KeyframeGrid"),
  BatchDiagnostics:       () => import("./BatchDiagnostics"),
  // Phase 17:
  RetryStrategyAnalysis:  () => import("./RetryStrategyAnalysis"),
  EvalCoverageReport:     () => import("./EvalCoverageReport"),
  AudioPlayer:            () => import("./AudioPlayer"),
};

export const REGISTERED_TEMPLATES = Object.keys(registry);

export async function loadTemplate(name: string): Promise<TemplateModule> {
  const loader = registry[name];
  if (!loader) throw new Error(`Unknown template: ${name}`);
  return loader();
}
```

### `ArtifactStage.tsx`

```typescript
import { createResource, createMemo, Show } from "solid-js";
import { store } from "../store";
import { loadTemplate } from "../templates/registry";
import type { TemplateModule } from "../templates/BaseTemplate";

export default function ArtifactStage() {
  // Reactive load: every time the mount template changes, reload the module
  const tplModule = createResource<TemplateModule | null>(
    () => store.mount().template,
    async (name) => name ? await loadTemplate(name) : null,
  );

  const Component = createMemo(() => tplModule[0]()?.default);
  const meta      = createMemo(() => tplModule[0]()?.meta);

  return (
    <main class="v2-stage" aria-label="Artifact Stage">
      <div class="v2-stage-chrome">
        <span class="v2-stage-label">{meta()?.label ?? "NO ARTIFACT MOUNTED"}</span>
        <span style={{ flex: 1 }} />
      </div>
      <div class="v2-stage-content">
        <Show when={Component()} fallback={<EmptyStage />}>
          {(C) => <C() context={store.mount().context} />}
        </Show>
      </div>
    </main>
  );
}

function EmptyStage() {
  return (
    <div class="v2-stage-empty"
         style={{ display:"flex","align-items":"center","justify-content":"center",
                  height:"100%", color:"var(--text-dim)",
                  "font-family":"var(--font-mono)", "font-size":"12px",
                  "letter-spacing":"1px" }}>
      SELECT A BEAT OR TYPE A COMMAND
    </div>
  );
}
```

### `EvalBadge.tsx` (shared component)

```typescript
import type { EvalBadgeState } from "../types.gen";

export default function EvalBadge(props: { state: EvalBadgeState; score?: number | null }) {
  const label = () => {
    if (props.state === "evaluated" && props.score != null) return `[${props.score.toFixed(2)}]`;
    return { evaluated: "[?.??]", skip: "[---]", error: "[!]", config_miss: "[?]" }[props.state];
  };
  const cls = () => `v2-eval-badge ${
    props.state === "evaluated"   ? "evaluated" :
    props.state === "skip"        ? "skip" :
    props.state === "error"       ? "error" : "config-miss"
  }`;
  return <span class={cls()}>{label()}</span>;
}
```

### `EpisodeOverview.tsx`

```typescript
import { createResource, For, Show } from "solid-js";
import { fetchBeats } from "../api";
import { store } from "../store";
import EvalBadge from "../components/EvalBadge";
import type { TemplateMeta } from "./BaseTemplate";

export const meta: TemplateMeta = {
  name: "EpisodeOverview", label: "EPISODE OVERVIEW",
  contextSchema: ["episode"],
};

export default function EpisodeOverview(props: { context: Record<string, unknown> }) {
  const project = () => store.activeProject() ?? "";
  const episode = () => (props.context.episode as string) || store.focus().episode || "";
  const [beats] = createResource(
    () => `${project()}::${episode()}`,
    () => project() && episode() ? fetchBeats(project(), episode()) : Promise.resolve([])
  );

  return (
    <div class="v2-tpl-episode-overview">
      <h2 style={{ "font-family":"var(--font-display)", "font-size":"14px", "letter-spacing":"4px",
                   margin:"0 0 16px", color:"var(--accent-cyan)" }}>
        {episode() || "—"}
      </h2>
      <Show when={beats()}>
        <table style={{ width:"100%", "border-collapse":"collapse",
                        "font-family":"var(--font-mono)", "font-size":"12px" }}>
          <thead>
            <tr style={{ color:"var(--text-dim)", "text-align":"left", "font-size":"10px",
                         "letter-spacing":"1px" }}>
              <th style={{ padding:"6px 8px" }}>BEAT</th>
              <th style={{ padding:"6px 8px" }}>SCENE</th>
              <th style={{ padding:"6px 8px" }}>STATUS</th>
              <th style={{ padding:"6px 8px" }}>EVAL</th>
              <th style={{ padding:"6px 8px", "text-align":"right" }}>COST</th>
              <th style={{ padding:"6px 8px", "text-align":"right" }}>TAKES</th>
            </tr>
          </thead>
          <tbody>
            <For each={beats()!}>{(b) => (
              <tr style={{ cursor:"pointer", "border-top":"1px solid var(--border-dim)" }}
                  onClick={() => {
                    store.setFocus({ episode: episode(), scene: b.scene_id, beat: b.beat_id });
                    store.setMount("TakesBrowser", { beat_id: b.beat_id });
                  }}>
                <td style={{ padding:"6px 8px", color:"var(--text-primary)" }}>{b.beat_id}</td>
                <td style={{ padding:"6px 8px", color:"var(--text-secondary)" }}>{b.scene_id ?? "—"}</td>
                <td style={{ padding:"6px 8px" }}>{b.status}</td>
                <td style={{ padding:"6px 8px" }}>
                  <EvalBadge state={b.eval_badge} score={b.eval_score} />
                </td>
                <td style={{ padding:"6px 8px", "text-align":"right",
                             color:"var(--text-dim)" }}>${b.cost_usd.toFixed(3)}</td>
                <td style={{ padding:"6px 8px", "text-align":"right",
                             color:"var(--text-dim)" }}>{b.take_count}</td>
              </tr>
            )}</For>
          </tbody>
        </table>
      </Show>
    </div>
  );
}
```

### `TakesBrowser.tsx`

```typescript
import { createResource, For, Show } from "solid-js";
import { fetchTakes } from "../api";
import { store } from "../store";
import EvalBadge from "../components/EvalBadge";
import type { TemplateMeta } from "./BaseTemplate";

export const meta: TemplateMeta = {
  name: "TakesBrowser", label: "TAKES BROWSER",
  contextSchema: ["beat_id"],
};

export default function TakesBrowser(props: { context: Record<string, unknown> }) {
  const project = () => store.activeProject() ?? "";
  const beatId  = () => (props.context.beat_id as string) || store.focus().beat || "";
  const episode = () => store.focus().episode ?? "";
  const [takes] = createResource(
    () => `${project()}::${episode()}::${beatId()}`,
    () => project() && episode() && beatId()
      ? fetchTakes(project(), episode(), beatId())
      : Promise.resolve([])
  );

  return (
    <div class="v2-tpl-takes-browser">
      <h2 style={{ "font-family":"var(--font-mono)", "font-size":"12px",
                   "letter-spacing":"1px", margin:"0 0 12px",
                   color:"var(--text-secondary)" }}>
        {beatId() || "—"} · {takes()?.length ?? 0} TAKES
      </h2>
      <Show when={takes()}>
        <div style={{ display:"grid",
                      "grid-template-columns":"repeat(auto-fill, minmax(180px, 1fr))",
                      gap:"12px" }}>
          <For each={takes()!}>{(t) => (
            <div onClick={() => store.setFocus({ take: t.take_id })}
                 style={{ background:"var(--bg-card)",
                          border: t.is_primary ? "2px solid var(--accent-cyan)"
                                               : "1px solid var(--border-dim)",
                          "border-radius":"var(--radius-md)", overflow:"hidden",
                          cursor:"pointer", "aspect-ratio":"9 / 16",
                          display:"flex", "flex-direction":"column" }}>
              <div style={{ flex:1, position:"relative",
                            background:"var(--bg-tertiary)" }}>
                <Show when={t.image_path}>
                  <img src={t.image_path!}
                       style={{ width:"100%", height:"100%", "object-fit":"cover" }} />
                </Show>
                <Show when={t.is_primary}>
                  <span style={{ position:"absolute", top:"4px", right:"4px",
                                 "font-family":"var(--font-mono)", "font-size":"10px",
                                 color:"var(--accent-cyan)" }}>★ PRIMARY</span>
                </Show>
              </div>
              <div style={{ padding:"6px 8px", display:"flex",
                            "justify-content":"space-between",
                            "align-items":"center" }}>
                <span style={{ "font-family":"var(--font-mono)", "font-size":"10px",
                               color:"var(--text-secondary)" }}>{t.take_id}</span>
                <EvalBadge state={t.eval_badge} score={t.eval_score} />
              </div>
            </div>
          )}</For>
        </div>
      </Show>
    </div>
  );
}
```

### `BeatOverview.tsx`

```typescript
import { createResource, For, Show } from "solid-js";
import { fetchBeats } from "../api";
import { store } from "../store";
import EvalBadge from "../components/EvalBadge";
import type { TemplateMeta } from "./BaseTemplate";

export const meta: TemplateMeta = {
  name: "BeatOverview", label: "BEAT GRID",
  contextSchema: ["episode"],
};

export default function BeatOverview(props: { context: Record<string, unknown> }) {
  const project = () => store.activeProject() ?? "";
  const episode = () => (props.context.episode as string) || store.focus().episode || "";
  const [beats] = createResource(
    () => `${project()}::${episode()}`,
    () => project() && episode() ? fetchBeats(project(), episode()) : Promise.resolve([])
  );

  return (
    <div class="v2-tpl-beat-overview">
      <Show when={beats()}>
        <div style={{ display:"grid",
                      "grid-template-columns":"repeat(auto-fill, minmax(140px, 1fr))",
                      gap:"8px" }}>
          <For each={beats()!}>{(b) => (
            <div onClick={() => {
                   store.setFocus({ episode: episode(), beat: b.beat_id });
                   store.setMount("TakesBrowser", { beat_id: b.beat_id });
                 }}
                 style={{ background:"var(--bg-card)",
                          border:"1px solid var(--border-dim)",
                          "border-radius":"var(--radius-sm)",
                          padding:"8px", cursor:"pointer",
                          display:"flex","flex-direction":"column", gap:"4px" }}>
              <div style={{ "font-family":"var(--font-mono)", "font-size":"11px",
                            color:"var(--text-primary)" }}>{b.beat_id}</div>
              <div style={{ display:"flex","justify-content":"space-between",
                            "align-items":"center" }}>
                <EvalBadge state={b.eval_badge} score={b.eval_score} />
                <span style={{ "font-size":"9px",
                               color:"var(--text-dim)" }}>{b.take_count}t</span>
              </div>
            </div>
          )}</For>
        </div>
      </Show>
    </div>
  );
}
```

### Scope boundary
- Templates ONLY read `props.context` and `store.*()` reads; they call `store.setFocus/setMount` to navigate
- Templates NEVER fetch state mutations directly (no calls to `saveFocus` etc.)
- Templates NEVER import from another template (no template-to-template coupling)
- Inline styles allowed for one-off layout; reusable patterns belong in `components.css`

### Validation
```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/recoil/pipeline/editors/v2 && \
test -f src/templates/BaseTemplate.tsx && \
test -f src/templates/registry.ts && \
test -f src/templates/EpisodeOverview.tsx && \
test -f src/templates/TakesBrowser.tsx && \
test -f src/templates/BeatOverview.tsx && \
test -f src/components/EvalBadge.tsx && \
grep -q "loadTemplate" src/components/ArtifactStage.tsx && \
grep -q "REGISTERED_TEMPLATES" src/templates/registry.ts && \
grep -q "EpisodeOverview" src/templates/registry.ts && \
grep -q "TakesBrowser"    src/templates/registry.ts && \
grep -q "BeatOverview"    src/templates/registry.ts && \
npm run typecheck && \
echo "Phase 13 OK"
```

---

## Phase 14: Chat Panel + Client Fast-Path + Proposal Cards
**depends_on:** 11, 13
**engine:** opus

### What already exists (from prior phases)
- `store.artifactContext()` returns the live ArtifactContext memo
- `api.ts` `postChat()` returns the SSE Response
- CSS classes `.v2-chat`, `.v2-chat-messages`, `.v2-msg-*`, `.v2-proposal-*`, `.v2-chat-input-row`

### Files to create
- `recoil/pipeline/editors/v2/src/fastpath.ts` — client-side regex fast-path
- `recoil/pipeline/editors/v2/src/components/ProposalCard.tsx`

### Files to modify
- `recoil/pipeline/editors/v2/src/components/ChatPanel.tsx`

### `fastpath.ts`

```typescript
/**
 * Client-side fast-path: typed `/commands` mount templates WITHOUT round-trip.
 * Mirrors v2/services/claude_chat.py::_FAST_PATH for non-typed inputs.
 */
import { store } from "./store";

type Match = { template: string; context: Record<string, unknown> };
type Builder = (m: RegExpMatchArray) => Match;

const PATTERNS: Array<[RegExp, Builder]> = [
  [/^\/audio\s+(\S+)/i,        (m) => ({ template: "AudioPlayer",       context: { take_id: m[1] } })],
  [/^\/takes\s+(\S+)/i,        (m) => ({ template: "TakesBrowser",      context: { beat_id: m[1] } })],
  [/^\/storyboard(?:\s+(\S+))?/i, (m) => ({ template: "StoryboardGrid", context: { episode: m[1] ?? "" } })],
  [/^\/script(?:\s+(\S+))?/i,  (m) => ({ template: "ScriptEditor",      context: { episode: m[1] ?? "" } })],
  [/^\/board(?:\s+(\S+))?/i,   (m) => ({ template: "EpisodeOverview",   context: { episode: m[1] ?? "" } })],
  [/^\/diagnostics/i,          ()  => ({ template: "BatchDiagnostics",  context: {} })],
  [/^\/eval/i,                 ()  => ({ template: "EvalCoverageReport", context: {} })],
];

export function tryFastPath(text: string): boolean {
  const t = text.trim();
  if (!t.startsWith("/")) return false;
  for (const [pat, build] of PATTERNS) {
    const m = t.match(pat);
    if (m) {
      const { template, context } = build(m);
      store.setMount(template, context);
      return true;
    }
  }
  return false;
}
```

### `ProposalCard.tsx`

```typescript
/**
 * Renders a SpecEditProposal. Cmd+Enter on the focused card → approve.
 * One visual treatment per proposal type; the discriminator drives the layout.
 */
import { createSignal, Show, Switch, Match } from "solid-js";
import { approveProposal } from "../api";
import { store } from "../store";
import type { SpecEditProposal } from "../types.gen";

export default function ProposalCard(props: { payload: SpecEditProposal }) {
  const [approved, setApproved] = createSignal(false);
  const [error,    setError]    = createSignal<string | null>(null);

  async function approve() {
    const project = store.activeProject();
    if (!project) { setError("no active project"); return; }
    try {
      await approveProposal(project, props.payload);
      setApproved(true);
    } catch (e) { setError(String(e)); }
  }

  function reject() { setApproved(true); /* hide; not persisted to audit */ }

  return (
    <Show when={!approved()}>
      <div class="v2-proposal-card" tabindex={0}
           onKeyDown={(e) => {
             if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); approve(); }
           }}>
        <span class="v2-proposal-type">{props.payload.type}</span>
        <Switch>
          <Match when={props.payload.type === "prompt_rewrite"}>
            <ProposalPromptRewrite payload={props.payload as any} />
          </Match>
          <Match when={props.payload.type === "script_edit"}>
            <ProposalScriptEdit payload={props.payload as any} />
          </Match>
          <Match when={props.payload.type === "parameter_change"}>
            <ProposalParameterChange payload={props.payload as any} />
          </Match>
          <Match when={["beat_insertion","extract_cutaway","ref_swap",
                        "multi_beat_directive","retry_strategy_edit"].includes(props.payload.type)}>
            <ProposalGeneric payload={props.payload as any} />
          </Match>
        </Switch>
        <Show when={props.payload.rationale}>
          <div style={{ "font-size":"11px","color":"var(--text-dim)" }}>
            {props.payload.rationale}
          </div>
        </Show>
        <div class="v2-proposal-actions">
          <button class="v2-proposal-approve" onClick={approve}>APPROVE (⌘↵)</button>
          <button class="v2-proposal-reject"  onClick={reject}>DISMISS</button>
        </div>
        <Show when={error()}>
          <div style={{ "color":"var(--accent-red)", "font-size":"11px" }}>{error()}</div>
        </Show>
      </div>
    </Show>
  );
}

function ProposalPromptRewrite(props: { payload: any }) {
  return <>
    <div class="v2-proposal-summary">
      <strong>{props.payload.beat_id}</strong> — prompt rewrite
    </div>
    <pre class="v2-proposal-diff">{props.payload.new_prompt}</pre>
  </>;
}
function ProposalScriptEdit(props: { payload: any }) {
  return <>
    <div class="v2-proposal-summary">
      <strong>{props.payload.beat_id}</strong> · {props.payload.field}
    </div>
    <pre class="v2-proposal-diff">{props.payload.new_value}</pre>
  </>;
}
function ProposalParameterChange(props: { payload: any }) {
  return <>
    <div class="v2-proposal-summary">
      Parameter change · {props.payload.beat_ids?.length ?? 0} beats
    </div>
    <pre class="v2-proposal-diff">{JSON.stringify(props.payload.parameters, null, 2)}</pre>
  </>;
}
function ProposalGeneric(props: { payload: any }) {
  return <>
    <div class="v2-proposal-summary">{props.payload.type}</div>
    <pre class="v2-proposal-diff">{JSON.stringify(props.payload, null, 2)}</pre>
  </>;
}
```

### `ChatPanel.tsx`

```typescript
import { createSignal, createEffect, For, Match, Switch } from "solid-js";
import { fetchChatHistory, postChat } from "../api";
import { tryFastPath } from "../fastpath";
import { store } from "../store";
import ProposalCard from "./ProposalCard";
import type { ChatStreamEvent } from "../types.gen";

type DisplayMessage =
  | { kind: "user"; text: string }
  | { kind: "assistant"; text: string; streaming: boolean }
  | { kind: "system"; text: string }
  | { kind: "proposal"; payload: any };

export default function ChatPanel() {
  const [messages, setMessages] = createSignal<DisplayMessage[]>([]);
  const [input, setInput] = createSignal("");
  const [busy,  setBusy]  = createSignal(false);
  let scrollRef: HTMLDivElement | undefined;

  // Switch threads when project/episode focus changes — fetch persisted
  // history from the server (Phase 8 GET /chat/history) and project it into
  // DisplayMessage form. Replaces the V1 setMessages([]) reset.
  createEffect(() => {
    const p = store.focus().project;
    const e = store.focus().episode;
    if (!p || !e) { setMessages([]); return; }
    fetchChatHistory(p, e).then((msgs) => {
      setMessages(msgs.map((m) => (
        m.role === "user"
          ? { kind: "user" as const, text: m.content }
          : { kind: "assistant" as const, text: m.content, streaming: false }
      )));
    }).catch(() => setMessages([]));
  });

  // Auto-scroll
  createEffect(() => { messages(); scrollRef?.scrollTo({ top: scrollRef.scrollHeight }); });

  async function send() {
    const text = input().trim();
    if (!text || busy()) return;
    setInput("");
    setMessages((m) => [...m, { kind: "user", text }]);

    // Client-side fast-path: skip Claude entirely
    if (tryFastPath(text)) {
      setMessages((m) => [...m, { kind: "system", text: `mounted via fast-path` }]);
      return;
    }

    const project = store.focus().project;
    const episode = store.focus().episode;
    if (!project || !episode) {
      setMessages((m) => [...m, { kind: "system", text: "select a project + episode first" }]);
      return;
    }

    setBusy(true);
    setMessages((m) => [...m, { kind: "assistant", text: "", streaming: true }]);
    try {
      const r = await postChat(project, episode, {
        message: text, artifact_context: store.artifactContext(),
      });
      if (!r.body) throw new Error("no SSE body");
      await consumeSSE(r.body, (evt) => handleEvt(evt, setMessages));
    } catch (e) {
      setMessages((m) => [...m, { kind: "system", text: `error: ${String(e)}` }]);
    } finally {
      setBusy(false);
      setMessages((m) => m.map((msg) =>
        msg.kind === "assistant" ? { ...msg, streaming: false } : msg));
    }
  }

  function onKeyDown(e: KeyboardEvent) {
    if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
      e.preventDefault(); send();
    }
  }

  return (
    <aside class="v2-chat" aria-label="Claude Chat">
      <div class="v2-chat-header"><span>CLAUDE</span></div>
      <div class="v2-chat-context-bar" title="Current workspace context">
        {contextSummary(store.artifactContext())}
      </div>
      <div class="v2-chat-messages" ref={scrollRef} role="log" aria-live="polite">
        <For each={messages()}>{(msg) => (
          <Switch>
            <Match when={msg.kind === "user"}>
              <div class="v2-msg v2-msg-user">{(msg as any).text}</div>
            </Match>
            <Match when={msg.kind === "assistant"}>
              <div class="v2-msg v2-msg-assistant"
                   classList={{ streaming: (msg as any).streaming }}>
                {(msg as any).text}
              </div>
            </Match>
            <Match when={msg.kind === "system"}>
              <div class="v2-msg-system">{(msg as any).text}</div>
            </Match>
            <Match when={msg.kind === "proposal"}>
              <ProposalCard payload={(msg as any).payload} />
            </Match>
          </Switch>
        )}</For>
      </div>
      <div class="v2-chat-input-row">
        <textarea
          class="v2-chat-input" rows={1}
          placeholder="Type or /command…"
          value={input()} onInput={(e) => setInput(e.currentTarget.value)}
          onKeyDown={onKeyDown}
        />
        <button class="v2-chat-send" disabled={busy()} onClick={send}>SEND</button>
      </div>
    </aside>
  );
}

function contextSummary(ctx: { active_project?: string | null; active_episode?: string | null;
                                active_beat?: string | null; active_take?: string | null;
                                selected_beats?: string[] | null }) {
  const parts: string[] = [];
  if (ctx.active_project) parts.push(ctx.active_project);
  if (ctx.active_episode) parts.push(ctx.active_episode);
  if (ctx.active_beat)    parts.push(ctx.active_beat);
  if (ctx.active_take)    parts.push(ctx.active_take);
  if (ctx.selected_beats && ctx.selected_beats.length) parts.push(`+${ctx.selected_beats.length}`);
  return parts.join(" · ") || "no selection";
}

async function consumeSSE(stream: ReadableStream<Uint8Array>,
                          onEvent: (e: ChatStreamEvent) => void) {
  const reader = stream.getReader();
  const decoder = new TextDecoder();
  let buf = "";
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buf += decoder.decode(value, { stream: true });
    const blocks = buf.split("\n\n");
    buf = blocks.pop() ?? "";
    for (const block of blocks) {
      const line = block.split("\n").find((l) => l.startsWith("data: "));
      if (!line) continue;
      try { onEvent(JSON.parse(line.slice(6)) as ChatStreamEvent); }
      catch (err) { console.warn("SSE parse fail", err, line); }
    }
  }
}

function handleEvt(evt: ChatStreamEvent,
                   setMessages: (fn: (m: DisplayMessage[]) => DisplayMessage[]) => void) {
  if (evt.type === "token") {
    setMessages((m) => {
      const last = m[m.length - 1];
      if (last && last.kind === "assistant") {
        return [...m.slice(0, -1), { ...last, text: last.text + evt.text }];
      }
      return [...m, { kind: "assistant", text: evt.text, streaming: true }];
    });
  } else if (evt.type === "mount") {
    store.setMount(evt.template, evt.context);
  } else if (evt.type === "focus") {
    store.setFocus(evt.focus);
  } else if (evt.type === "proposal") {
    setMessages((m) => [...m, { kind: "proposal", payload: evt.payload }]);
  } else if (evt.type === "error") {
    setMessages((m) => [...m, { kind: "system", text: `error: ${evt.text}` }]);
  }
  // 'done' is implicit (loop exits)
}
```

### Scope boundary
- Per-project chat threads: V1 visible-history reset on focus change; server-side history persists in SQLite; future enhancement re-renders history on focus change
- Push-to-talk (Cmd-hold): out of scope for V1; placeholder note in DESIGN_SYSTEM_V2.md only
- Proposal card edit-before-approve: out of scope for V1 (approve as-issued or dismiss)

### Validation
```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/recoil/pipeline/editors/v2 && \
grep -q "tryFastPath"     src/fastpath.ts && \
grep -q "ProposalCard"    src/components/ChatPanel.tsx && \
grep -q "consumeSSE"      src/components/ChatPanel.tsx && \
grep -q "store.setMount"  src/components/ChatPanel.tsx && \
grep -q "store.setFocus"  src/components/ChatPanel.tsx && \
grep -q "approveProposal" src/components/ProposalCard.tsx && \
npm run typecheck && \
echo "Phase 14 OK"
```

---

## Phase 15: Live Bay + App Core + Command Palette + Breadcrumb
**depends_on:** 11, 12, 13, 14
**engine:** opus

### What already exists (from prior phases)
- `sse.ts` `onBusEvent` / `createBusEventSignal` for typed bus subscription
- All five panel components are mounted in `App.tsx`
- `CSS classes for .v2-bay, .v2-bay-row, .v2-bay-toggle, .v2-breadcrumb, .v2-palette*` already in shell.css

### Files to modify
- `recoil/pipeline/editors/v2/src/components/LiveBay.tsx`
- `recoil/pipeline/editors/v2/src/components/Breadcrumb.tsx`
- `recoil/pipeline/editors/v2/src/components/CommandPalette.tsx`
- `recoil/pipeline/editors/v2/src/main.tsx` — add global keyboard listener for ⌘K
- `recoil/pipeline/editors/v2/src/components/ChatPanel.tsx` — react to `take_completed` events for cost bump

### `LiveBay.tsx`

```typescript
import { createSignal, For, Show } from "solid-js";
import { onBusEvent, type BusEvent } from "../sse";
import { store } from "../store";

interface BayRow {
  shot_id: string; status: string; project: string;
  cost_usd: number; ts: number; isFinal: boolean;
}

export default function LiveBay() {
  const [rows, setRows] = createSignal<BayRow[]>([]);
  const [expanded, setExpanded] = createSignal(false);

  onBusEvent((e: BusEvent) => {
    const d = e.data as Record<string, any>;
    if (e.type === "step_status") {
      setRows((r) => upsert(r, {
        shot_id: d.shot_id, status: d.status, project: d.project ?? "",
        cost_usd: 0, ts: d.ts, isFinal: false,
      }));
    } else if (e.type === "take_completed") {
      store.bumpCost(d.cost_usd ?? 0);
      setRows((r) => upsert(r, {
        shot_id: d.shot_id, status: "completed", project: d.project ?? "",
        cost_usd: d.cost_usd ?? 0, ts: d.ts, isFinal: true,
      }));
    } else if (e.type === "batch_summary") {
      setRows((r) => [{ shot_id: `batch:${d.episode}`, status: "summary",
                        project: d.project, cost_usd: d.total_cost,
                        ts: d.ts, isFinal: true }, ...r].slice(0, 100));
    }
  });

  function upsert(rs: BayRow[], next: BayRow): BayRow[] {
    const i = rs.findIndex((r) => r.shot_id === next.shot_id && r.project === next.project);
    if (i < 0) return [next, ...rs].slice(0, 100);
    const copy = rs.slice(); copy[i] = next; return copy;
  }

  const visible = () => {
    if (!expanded()) return rows().slice(0, 1);
    return rows();
  };

  return (
    <footer class="v2-bay" classList={{ expanded: expanded() }}>
      <div class="v2-bay-toggle" onClick={() => setExpanded(!expanded())}>
        <span style={{ "font-family":"var(--font-mono)", "font-size":"9px",
                       color:"var(--text-dim)", "letter-spacing":"1px", flex:1 }}>
          LIVE BAY · {rows().length}
        </span>
        <span style={{ "font-size":"9px", color:"var(--text-dim)" }}>
          {expanded() ? "▼" : "▲"}
        </span>
      </div>
      <div class="v2-bay-inner">
        <Show when={rows().length === 0}>
          <div style={{ "font-family":"var(--font-mono)", "font-size":"10px",
                        color:"var(--text-dim)", padding:"4px" }}>
            Idle — no active batches
          </div>
        </Show>
        <For each={visible()}>{(r) => (
          <div class={`v2-bay-row ${r.status === "completed" ? "completed"
                     : r.status.startsWith("failed") ? "failed" : "generating"}`}>
            <span class="v2-bay-id">{r.shot_id}</span>
            <span class="v2-bay-status">{r.status}</span>
            <span class="v2-bay-project" style={{ color:"var(--text-dim)" }}>
              {r.project}
            </span>
            <span class="v2-bay-cost">${r.cost_usd.toFixed(3)}</span>
          </div>
        )}</For>
      </div>
    </footer>
  );
}
```

### `Breadcrumb.tsx`

```typescript
import { Show } from "solid-js";
import { store } from "../store";

export default function Breadcrumb() {
  const f = store.focus;
  return (
    <nav class="v2-breadcrumb" aria-label="Workspace location">
      <Show when={f().project} fallback={
        <span class="v2-breadcrumb-seg current">CONSOLE v2</span>
      }>
        <Crumb label={f().project!} onClick={() => {/* drop to project root */}} />
        <Show when={f().episode}>
          <span class="v2-breadcrumb-sep">/</span>
          <Crumb label={f().episode!} onClick={() =>
            store.setMount("EpisodeOverview", { episode: f().episode })} />
        </Show>
        <Show when={f().scene}>
          <span class="v2-breadcrumb-sep">/</span>
          <Crumb label={f().scene!} />
        </Show>
        <Show when={f().beat}>
          <span class="v2-breadcrumb-sep">/</span>
          <Crumb label={f().beat!} onClick={() =>
            store.setMount("TakesBrowser", { beat_id: f().beat })} />
        </Show>
        <Show when={f().take}>
          <span class="v2-breadcrumb-sep">/</span>
          <Crumb label={f().take!} current />
        </Show>
      </Show>
    </nav>
  );
}

function Crumb(props: { label: string; current?: boolean; onClick?: () => void }) {
  return (
    <span class="v2-breadcrumb-seg" classList={{ current: props.current }}
          onClick={props.onClick}>
      {props.label}
    </span>
  );
}
```

### `CommandPalette.tsx`

```typescript
import { createSignal, createMemo, For, Show, onMount, onCleanup } from "solid-js";
import { store } from "../store";
import { REGISTERED_TEMPLATES } from "../templates/registry";
import { launchBatch } from "../api";

interface Command {
  id: string; label: string; kbd?: string; run: () => void | Promise<void>;
}

export default function CommandPalette() {
  const [open, setOpen] = createSignal(false);
  const [q, setQ] = createSignal("");
  const [active, setActive] = createSignal(0);
  let inputRef: HTMLInputElement | undefined;

  const baseCommands = (): Command[] => [
    ...REGISTERED_TEMPLATES.map((t) => ({
      id: `mount:${t}`, label: `Mount: ${t}`,
      run: () => store.setMount(t, {}),
    })),
    {
      id: "batch:launch", label: "Launch batch (current episode)",
      run: () => {
        const p = store.focus().project, e = store.focus().episode;
        if (p && e) launchBatch(p, e).catch(console.error);
      },
    },
    { id: "selection:clear", label: "Clear selection",
      run: () => store.clearSelection() },
  ];

  const matched = createMemo<Command[]>(() => {
    const term = q().toLowerCase();
    if (!term) return baseCommands();
    return baseCommands().filter((c) => c.label.toLowerCase().includes(term));
  });

  function openPalette() { setOpen(true); setQ(""); setActive(0);
    setTimeout(() => inputRef?.focus(), 0); }
  function closePalette() { setOpen(false); }
  function run(i: number) { matched()[i]?.run(); closePalette(); }

  function onGlobalKey(e: KeyboardEvent) {
    if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); openPalette(); }
    if (e.key === "Escape" && open()) { e.preventDefault(); closePalette(); }
  }

  function onPaletteKey(e: KeyboardEvent) {
    if (e.key === "ArrowDown") { e.preventDefault();
      setActive((i) => Math.min(i + 1, matched().length - 1)); }
    else if (e.key === "ArrowUp") { e.preventDefault();
      setActive((i) => Math.max(i - 1, 0)); }
    else if (e.key === "Enter")   { e.preventDefault(); run(active()); }
  }

  onMount(() => {
    window.addEventListener("keydown", onGlobalKey);
    window.addEventListener("v2:open-palette", openPalette as EventListener);
  });
  onCleanup(() => {
    window.removeEventListener("keydown", onGlobalKey);
    window.removeEventListener("v2:open-palette", openPalette as EventListener);
  });

  return (
    <Show when={open()}>
      <div class="v2-palette-overlay" onClick={closePalette}>
        <div class="v2-palette" onClick={(e) => e.stopPropagation()} onKeyDown={onPaletteKey}>
          <input class="v2-palette-input" ref={inputRef} type="text"
                 placeholder="Search commands…"
                 value={q()} onInput={(e) => { setQ(e.currentTarget.value); setActive(0); }} />
          <div class="v2-palette-results" role="listbox">
            <For each={matched()}>{(c, i) => (
              <div class="v2-palette-item" classList={{ active: i() === active() }}
                   onClick={() => run(i())}>
                <span class="v2-palette-item-name">{c.label}</span>
                <Show when={c.kbd}><span class="v2-palette-item-kbd">{c.kbd}</span></Show>
              </div>
            )}</For>
          </div>
        </div>
      </div>
    </Show>
  );
}
```

### Scope boundary
- Live bay V1: in-memory only (no persistence). Page reload starts empty; SSE will replay from ring buffer.
- Command palette V1: built-in commands only (templates + batch launch + clear selection). Custom command registration deferred.
- No drag-resize on panels yet (V2). Resize handles render but don't drag — Phase 15 adds the no-op handler so the cursor still shows; full resize is V2 backlog.

### Validation
```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/recoil/pipeline/editors/v2 && \
grep -q "step_status"      src/components/LiveBay.tsx && \
grep -q "batch_summary"    src/components/LiveBay.tsx && \
grep -q "store.bumpCost"   src/components/LiveBay.tsx && \
grep -q "v2:open-palette"  src/components/CommandPalette.tsx && \
grep -q "REGISTERED_TEMPLATES" src/components/CommandPalette.tsx && \
grep -q "v2-breadcrumb-seg" src/components/Breadcrumb.tsx && \
npm run typecheck && \
echo "Phase 15 OK"
```

---

## Phase 16: Templates Set A (4 templates)
**depends_on:** 13
**engine:** gemini

### What already exists
- `BaseTemplate` contract; `registry.ts` already references all 4 imports (so this phase only fills in files)
- `EvalBadge` component
- `store`, `api.ts`, all read endpoints

### Files to create
- `recoil/pipeline/editors/v2/src/templates/ScriptEditor.tsx`
- `recoil/pipeline/editors/v2/src/templates/StoryboardGrid.tsx`
- `recoil/pipeline/editors/v2/src/templates/KeyframeGrid.tsx`
- `recoil/pipeline/editors/v2/src/templates/BatchDiagnostics.tsx`

### Pattern (each template follows this shape)

```typescript
import { Component } from "solid-js";
import type { TemplateMeta } from "./BaseTemplate";

export const meta: TemplateMeta = {
  name: "TemplateName", label: "DISPLAY LABEL",
  contextSchema: ["expected_context_keys"],
};

const TemplateName: Component<{ context: Record<string, unknown> }> = (props) => {
  // 1. Resolve identifiers from props.context with store.* fallback
  // 2. createResource to fetch data
  // 3. Render with Show fallback for loading
  // 4. Click handlers call store.setFocus / store.setMount
  return <div class="v2-tpl-template-name">{/* ... */}</div>;
};
export default TemplateName;
```

### Per-template requirements

**`ScriptEditor.tsx`** — Episode-scoped editable screenplay
- Context: `{ episode: string }`
- Source: per-beat `action`/`dialogue`/`character` from a beat detail endpoint (V1: read-only display from `BeatSummary` + first take's content; full edit in V2)
- Layout: numbered beat sections, action paragraphs in body font, dialogue centered with character above
- Gutter: each beat row shows EvalBadge (left margin)
- V1 read-only behaviour acceptable; sub-agent must make the structural HTML/CSS render correctly. Edit-in-place is V2 scope.
- Click on beat opens TakesBrowser for that beat

**`StoryboardGrid.tsx`** — Episode-scoped primary-take grid for narrative-flow review
- Context: `{ episode: string }`
- Fetch all beats in episode via `fetchBeats`
- For each beat, fetch primary take's `image_path` via `fetchTakes` (only first beat's takes prefetched per V1 — lazy below the fold OK, or fetch all in parallel via `Promise.all` — sub-agent picks)
- Layout: scene-by-scene horizontal strips, beat thumbnails left-to-right, primary star overlay
- Hover: shows beat_id + EvalBadge
- Click: focus beat + mount TakesBrowser

**`KeyframeGrid.tsx`** — Visual continuity check
- Context: `{ episode: string, character?: string }`
- Source: same as StoryboardGrid but filter to keyframe-source takes (beats where `t.image_path` and not video)
- Layout: 4-column dense grid, no scene grouping
- Click: focus take

**`BatchDiagnostics.tsx`** — Per-batch failure summary
- Context: `{}` (uses store.focus().project + episode)
- Source: SSE recent events filtered to current project (uses `createBusEventSignal` from sse.ts)
- Layout: Two columns
  - Left: per-failure-mode count table (failure_type, count, last seen)
  - Right: live event log (most recent 50 events, color-coded by type)

### Scope boundary
- Each template ≤ 200 lines of TS/JSX
- No new CSS variables (use existing v2-* tokens only)
- No mutation calls (read + navigate only)
- Sub-agent runs `npm run typecheck` after each template

### Validation
```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/recoil/pipeline/editors/v2 && \
for tpl in ScriptEditor StoryboardGrid KeyframeGrid BatchDiagnostics; do
  test -f src/templates/${tpl}.tsx || { echo "missing: ${tpl}"; exit 1; }
  grep -q "TemplateMeta" src/templates/${tpl}.tsx || { echo "no meta: ${tpl}"; exit 1; }
  grep -q "export default" src/templates/${tpl}.tsx || { echo "no default export: ${tpl}"; exit 1; }
done && \
npm run typecheck && \
echo "Phase 16 OK"
```

---

## Phase 17: Templates Set B + EvalCoverage Backend + Smoke Test
**depends_on:** 13, 7, 16
**engine:** opus (eval logic) + Gemini (template rendering)

### Files to create
- `recoil/pipeline/editors/v2/src/templates/RetryStrategyAnalysis.tsx`
- `recoil/pipeline/editors/v2/src/templates/EvalCoverageReport.tsx`
- `recoil/pipeline/editors/v2/src/templates/AudioPlayer.tsx`
- `recoil/pipeline/v2/services/eval_coverage.py`
- `recoil/pipeline/v2/routes/eval_coverage.py`
- `recoil/pipeline/v2/tests/test_eval_coverage.py`

### Files to modify
- `recoil/pipeline/v2/main.py` — register eval_coverage router

### `recoil/pipeline/v2/services/eval_coverage.py`

```python
"""
Eval coverage = compute the 4-state badge for every take in a scope,
under a project's EvalPolicy.
"""
from __future__ import annotations
import sys
from collections import Counter
from pathlib import Path
from typing import Dict, List

_RECOIL = Path(__file__).resolve().parent.parent.parent.parent
if str(_RECOIL) not in sys.path:
    sys.path.insert(0, str(_RECOIL))

from pydantic import BaseModel
from v2 import state
from v2.services.store_adapter import list_beats, list_takes
from v2.types import EvalBadgeState, EvalPolicy, TakeSummary


class CoverageRow(BaseModel):
    beat_id:        str
    take_id:        str
    step_type:      str
    badge:          EvalBadgeState

class CoverageReport(BaseModel):
    project:        str
    episode:        str
    rows:           List[CoverageRow]
    counts:         Dict[str, int]   # badge_state → count


def coverage_report(project: str, episode: str) -> CoverageReport:
    policy: EvalPolicy = state.load_policy(project)
    rule_by_step = {r.step_type: r for r in policy.rules}

    rows: List[CoverageRow] = []
    for beat in list_beats(project, episode):
        for take in list_takes(project, episode, beat.beat_id):
            step_type = _infer_step_type(take)
            badge = _compute_badge(take, rule_by_step.get(step_type))
            rows.append(CoverageRow(
                beat_id=beat.beat_id, take_id=take.take_id,
                step_type=step_type, badge=badge,
            ))

    counts = Counter(r.badge.value for r in rows)
    return CoverageReport(project=project, episode=episode, rows=rows,
                          counts={k: counts.get(k, 0) for k in
                                  ("evaluated", "skip", "error", "config_miss")})


def _infer_step_type(take: TakeSummary) -> str:
    """Crude inference from take fields. Enhance later via take.step_type if added."""
    if take.video_path: return "video_i2v"
    if take.image_path: return "image_t2i"
    return "unknown"


def _compute_badge(take: TakeSummary, rule) -> EvalBadgeState:
    if rule and rule.intentional_skip:
        return EvalBadgeState.SKIP
    if take.eval_score is not None:
        return EvalBadgeState.EVALUATED
    # No score and no rule (or rule requires eval) → config_miss
    return EvalBadgeState.CONFIG_MISS
```

### `recoil/pipeline/v2/routes/eval_coverage.py`

```python
from fastapi import APIRouter
from v2.services.eval_coverage import CoverageReport, coverage_report

router = APIRouter()

@router.get("/project/{project}/episode/{episode}/coverage", response_model=CoverageReport)
def get_coverage(project: str, episode: str):
    return coverage_report(project, episode)
```

### Modify `v2/main.py`
Add line:
```python
from v2.routes import eval_coverage as _r_coverage  # noqa: E402
app.include_router(_r_coverage.router, prefix="/v2/api")
```

### `v2/tests/test_eval_coverage.py`

```python
import pytest
from fastapi.testclient import TestClient

@pytest.fixture
def client(tmp_path):
    from v2 import state
    state.set_db_path(tmp_path / "test.db")
    from v2.main import app
    yield TestClient(app)
    state.set_db_path(None)

def test_coverage_returns_shape(client):
    r = client.get("/v2/api/project/p/episode/EP001/coverage")
    assert r.status_code == 200
    body = r.json()
    assert body["project"] == "p"
    assert body["episode"] == "EP001"
    assert isinstance(body["rows"], list)
    assert set(body["counts"]) == {"evaluated", "skip", "error", "config_miss"}
```

### Frontend templates

**`AudioPlayer.tsx`** — minimal native `<audio>` element with transcript display
- Context: `{ take_id: string }`
- Layout: waveform placeholder (V1: just `<audio controls src=...>`), transcript pre block

**`RetryStrategyAnalysis.tsx`** — strategy effectiveness across batches
- Context: `{}` (uses focus().project)
- Source: GET `/v2/api/project/{p}/batch/status` (V1)
- Layout: aggregated table by failure_mode → strategy → success_rate

**`EvalCoverageReport.tsx`** — uses the new `/coverage` endpoint
- Context: `{}` (uses focus().project + episode)
- Layout:
  - Top: 4-state count summary (evaluated/skip/error/config_miss) with totals
  - Body: per-beat rows showing all takes' badges
  - Action: "Add `eval_video_v1` panel for image_t2i" → emits a `policy_diff` proposal (V2 scope; V1 just shows the recommendation as text)

### CI smoke test (run last)

```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/recoil/pipeline && \
# 1. All Python tests pass
python3 -m pytest v2/tests/ -v && \
# 2. TS compiles
cd editors/v2 && npm run typecheck && \
# 3. Production build succeeds
npm run build && \
# 4. Codegen has no drift
cd ../.. && python3 -m v2.codegen.gen_types && \
git diff --exit-code editors/v2/src/types.gen.ts || \
  { echo "DRIFT: TS types out of sync with Pydantic"; exit 1; } && \
# 5. Server boots and answers /health
python3 -m v2.main &
SERVER_PID=$!
sleep 3
curl -sf http://127.0.0.1:8431/v2/api/health > /dev/null || \
  { kill $SERVER_PID; echo "server failed health check"; exit 1; }
kill $SERVER_PID
echo "Phase 17 OK — Console v2 build complete"
```

### Scope boundary
- EvalCoverage backend uses inferred step_type (V1 limitation). Replacing inference with a real `take.step_type` field requires adding it to `BeatSummary`/`TakeSummary` in `v2/types.py` and re-running codegen — V2 scope.
- AudioPlayer V1 = native `<audio>`; waveform visualization is V2.
- RetryStrategyAnalysis V1 = batch status snapshot table; rolling cross-batch aggregation is V2.

### Validation (per-phase, prior to smoke)
```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/recoil/pipeline && \
python3 -m pytest v2/tests/test_eval_coverage.py -v && \
test -f v2/services/eval_coverage.py && \
test -f v2/routes/eval_coverage.py && \
test -f editors/v2/src/templates/AudioPlayer.tsx && \
test -f editors/v2/src/templates/RetryStrategyAnalysis.tsx && \
test -f editors/v2/src/templates/EvalCoverageReport.tsx && \
echo "Phase 17 ready for smoke test"
```

---

## Post-build (manual, JT runs)

1. **Install LaunchAgent on Studio:**
   ```bash
   ssh joeturnerlin@100.105.59.118
   cp ~/Dropbox/CLAUDE_PROJECTS/recoil/pipeline/com.recoil.console-v2.plist ~/Library/LaunchAgents/
   launchctl load ~/Library/LaunchAgents/com.recoil.console-v2.plist
   curl -s http://127.0.0.1:8431/v2/api/health
   ```
2. **Tunnel from MBP** (if not on Studio):
   ```bash
   ssh -L 8431:127.0.0.1:8431 joeturnerlin@100.105.59.118
   open http://127.0.0.1:8431/v2
   ```
3. **First-run setup:** Browser opens to `/v2`. Pick a project from the navigator. Try a `/board` fast-path command in chat. Confirm the artifact stage mounts EpisodeOverview.

---

## Cutover plan (NOT in scope of this build — JT decides)

V2 ships in parallel with V1 (review_server.py on 8430 stays untouched). After 1–2 weeks of dogfooding:

- If V2 absorbs the workflow: deprecate V1 by removing the LaunchAgent / kill review_server.py
- If V2 needs more features: add them in V2; never reach back to modify review_server.py routes

---

## Open scope-exception flags (for spec-review consult)

1. **production_loop hook signature is not formalized** — `batch_runner.py` uses defensive dual-call (kwargs + setattr) because the production_loop hook contract isn't typed. Spec-review should confirm this is acceptable as bridge code OR demand a formal hook protocol in production_loop first.
2. **TakeSummary lacks `step_type`** — EvalCoverage infers it from image_path/video_path. Cleaner: add `step_type` field to TakeSummary + populate from execution store. V2 scope.
3. **Per-project chat history visible-rendering** — Phase 14 resets visible messages on focus change. Server retains history (SQLite). A "show history on switch" UX pass would replay the last N messages from `GET /v2/api/project/{p}/episode/{e}/chat/history` (endpoint not yet specified — add if spec-review asks).
4. **No drag-resize on panels** — handles render, no drag handler. V2 backlog.
5. **CSS transcription gap** — Phase 1 produces DESIGN_SYSTEM_V2.md; Phase 11 transcribes it into shell.css. Sub-agent could mistranscribe. Mitigation: Phase 11 sub-agent diff-checks "every CSS class name in the design doc appears in shell.css."

End of BUILD_SPEC.
