# BUILD_SPEC: Recoil Workspace POC

**Detail level:** max
**Target:** 8 phases, unattended overnight build
**Estimated LOC:** ~2,800
**Python:** 3.14+ (uuid7 available)
**Date:** 2026-04-12 (revised 2026-04-12 — spec review fixes applied)

---

## File Manifest

```
recoil/workspace/
├── __init__.py              # Empty init
├── DESIGN_SYSTEM.md         # Visual design reference (Phase 1)
├── state.py                 # Shared state manager (Phase 2)
├── session_log.py           # Append-only JSONL session logger (Phase 2)
├── mcp_server.py            # MCP stdio server — 12 tools (Phase 3)
├── server.py                # FastAPI app (Phase 4)
├── static/
│   ├── index.html           # HTML shell (Phase 5)
│   ├── workspace.css        # All styles (Phase 5)
│   └── workspace.js         # All frontend JS (Phase 6)
└── start_workspace.sh       # One-command startup (Phase 7)
```

## Dependency Map

```
state.py ← mcp_server.py, server.py
session_log.py ← mcp_server.py
execution.execution_store ← mcp_server.py, server.py
pipeline.lib.ops_log ← mcp_server.py, server.py
core.paths ← mcp_server.py, server.py
```

## Import Pattern

Every Python file that needs recoil modules must do this at the top:

```python
import sys
from pathlib import Path
# Add recoil root to sys.path so execution/, core/, pipeline/ are importable
_RECOIL_ROOT = Path(__file__).resolve().parent.parent
if str(_RECOIL_ROOT) not in sys.path:
    sys.path.insert(0, str(_RECOIL_ROOT))
```

## Key Paths (from core/paths.py)

- `RECOIL_ROOT` = `Path(__file__).resolve().parent.parent` (the recoil/ directory)
- `PROJECTS_ROOT` = `~/Dropbox/CLAUDE_PROJECTS/projects` (from pipeline_config.json)
- Shot JSON files: `projects/{project}/state/visual/shots/{shot_id}.json`
- Ops log: `projects/{project}/state/visual/ops.log.jsonl`
- Output: `projects/{project}/output/` (frames/, previs/, video/, refs/)
- Canonical refs: `projects/{project}/output/refs/_canonical/characters/`, `_canonical/locations/`
- Session logs: `projects/{project}/sessions/{date}.jsonl` (workspace creates this)
- Workspace state: `~/.recoil-workspace/state.json`

---

# Phase 1: Design System

**Creates:** `workspace/DESIGN_SYSTEM.md`
**Modifies:** nothing
**Depends on:** nothing

## File: workspace/DESIGN_SYSTEM.md

```markdown
# Recoil Workspace Design System

## Color Palette

Derived from the Production Console (`pipeline/editors/styles/console.css`) with
adjustments for the Blender/Logic Pro utilitarian density aesthetic.

### Backgrounds
| Token | Hex | Usage |
|-------|-----|-------|
| `--bg-base` | `#08080f` | Page background, deepest layer |
| `--bg-surface` | `#0e0e18` | Panels, cards |
| `--bg-raised` | `#161622` | Hover states, active items |
| `--bg-overlay` | `#1e1e2e` | Modals, dropdowns, tooltips |

### Borders
| Token | Hex | Usage |
|-------|-----|-------|
| `--border-dim` | `#1a1a2a` | Subtle separators |
| `--border-default` | `#2a2a3a` | Standard borders |
| `--border-focus` | `#8888cc` | Focus rings, active borders |

### Text
| Token | Hex | Usage |
|-------|-----|-------|
| `--text-primary` | `#cccccc` | Body text, labels |
| `--text-secondary` | `#888888` | Metadata, timestamps |
| `--text-dim` | `#555555` | Disabled, tertiary |
| `--text-bright` | `#eeeeee` | Headings, emphasis |

### Accents
| Token | Hex | Usage |
|-------|-----|-------|
| `--accent-blue` | `#8888cc` | Primary interactive, links, focus |
| `--accent-green` | `#88cc88` | Approved, success |
| `--accent-amber` | `#cccc88` | In-progress, warning |
| `--accent-red` | `#cc8888` | Rejected, error |
| `--accent-purple` | `#aa88cc` | Generation in-flight |

### Status Colors (Shot States)
| State | Color | Indicator |
|-------|-------|-----------|
| approved / video_complete | `--accent-green` | Filled circle |
| *_generating / *_submitted | `--accent-amber` | Pulsing circle |
| *_pending | `--text-dim` | Empty circle |
| *_failed / *_rejected | `--accent-red` | X mark |
| needs_review / icu_escalated | `--accent-purple` | Diamond |

## Typography

| Context | Font | Size | Weight |
|---------|------|------|--------|
| UI chrome, labels, tabs | System monospace | 11px | 600 |
| Data values, shot IDs | System monospace | 12px | 400 |
| Body text, descriptions | System sans-serif | 13px | 400 |
| Section headers | System monospace | 11px | 700, uppercase, letter-spacing 1px |
| Metadata, timestamps | System monospace | 10px | 400 |

Font stacks:
- Monospace: `'SF Mono', 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace`
- Sans-serif: `-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif`

## Component Patterns

### Panel
- Background: `--bg-surface`
- Border: 1px solid `--border-dim`
- Border-radius: 0 (flush panels, no rounded corners)
- Padding: 8px internal

### Tab Bar
- Background: `--bg-base`
- Active tab: bottom border 2px `--accent-blue`, text `--text-bright`
- Inactive tab: no border, text `--text-secondary`
- Tab padding: 8px 16px
- Font: monospace 11px uppercase

### Tree Item (File Browser)
- Height: 24px
- Padding-left: 16px per nesting level
- Status dot: 8px circle, left-aligned
- Shot ID: monospace 12px
- Hover: background `--bg-raised`
- Selected: background `--bg-raised`, left border 2px `--accent-blue`
- Multi-selected: background `--bg-raised` with `--accent-blue` at 10% opacity

### Inspector Section
- Header: monospace 11px uppercase, `--text-secondary`, bottom border `--border-dim`
- Content: 4px padding
- Collapsible: chevron rotates on expand
- Key-value pairs: grid layout, key `--text-secondary`, value `--text-primary`

### Activity Item
- Height: 20px
- Left: pulsing `--accent-amber` dot (in-flight) or `--accent-green` checkmark (completed)
- Text: shot ID monospace 12px, elapsed time right-aligned
- Failed: `--accent-red` X

### Button
- Background: transparent
- Border: 1px solid `--border-default`
- Color: `--text-primary`
- Hover: border `--accent-blue`, color `--text-bright`
- Border-radius: 2px
- Padding: 4px 12px
- Font: monospace 11px

### Divider (draggable)
- Width: 4px
- Background: `--border-dim`
- Hover: `--accent-blue` at 40% opacity
- Cursor: col-resize
- No decorative grip dots

## Layout Rules
- No margin > 8px anywhere
- No padding > 12px anywhere
- No border-radius > 4px
- No gradients
- No shadows
- No decorative elements
- Information density > whitespace
- Every interactive element has a visible keyboard shortcut hint

## Keyboard Shortcut Display
- Displayed as dim monospace text next to labels: `[Esc]`, `[B]`, `[Space]`
- Color: `--text-dim`
- Font: monospace 10px
```

## Validation

```bash
# Phase 1 validation: file exists and has required sections
test -f workspace/DESIGN_SYSTEM.md && echo "PASS: file exists" || echo "FAIL"
grep -q "Color Palette" workspace/DESIGN_SYSTEM.md && echo "PASS: has colors" || echo "FAIL"
grep -q "Typography" workspace/DESIGN_SYSTEM.md && echo "PASS: has typography" || echo "FAIL"
grep -q "Component Patterns" workspace/DESIGN_SYSTEM.md && echo "PASS: has components" || echo "FAIL"
grep -q "Keyboard Shortcut" workspace/DESIGN_SYSTEM.md && echo "PASS: has shortcuts" || echo "FAIL"
```

## Do NOT
- Add any Python code
- Create the static/ directory yet
- Reference external font CDNs (system fonts only)

---

# Phase 2: Scaffold + State + Session Log

**Creates:** `workspace/__init__.py`, `workspace/state.py`, `workspace/session_log.py`
**Modifies:** nothing
**Depends on:** Phase 1 (for design reference only)

## What Already Exists
- `workspace/DESIGN_SYSTEM.md` (Phase 1)

## File: workspace/__init__.py

```python
"""Recoil Workspace — shared visual workspace for JT + Claude Code."""
```

## File: workspace/state.py

```python
"""Shared workspace state — viewer state, selection, project context.

State is persisted to ~/.recoil-workspace/state.json. Both the MCP server
and the FastAPI server read/write this file. Atomic writes via tempfile +
os.replace() (same pattern as ExecutionStore).

State schema:
{
    "project": "tartarus",
    "selection": ["EP001_SH03", "EP001_SH04"],
    "viewer": {
        "shot_id": "EP001_SH03",
        "take_index": 2,
        "file_path": "output/previs/ep_001/shot_003_take2.png",
        "media_type": "image",
        "context": "Reviewing framing"
    },
    "browse_tab_active": true,
    "updated_at": "2026-04-12T01:30:00Z"
}
"""

import json
import os
import tempfile
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional

_STATE_DIR = Path.home() / ".recoil-workspace"
_STATE_PATH = _STATE_DIR / "state.json"
_LOCK = threading.Lock()

_DEFAULT_STATE = {
    "project": None,
    "selection": [],
    "viewer": {
        "shot_id": None,
        "take_index": None,
        "file_path": None,
        "media_type": None,
        "context": None,
    },
    "browse_tab_active": True,
    "updated_at": None,
}


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


def _ensure_dir() -> None:
    _STATE_DIR.mkdir(parents=True, exist_ok=True)


def read_state() -> dict:
    """Read the current workspace state. Returns default state if file missing."""
    if not _STATE_PATH.is_file():
        return dict(_DEFAULT_STATE)
    try:
        data = json.loads(_STATE_PATH.read_text(encoding="utf-8"))
        # Merge with defaults to handle schema evolution
        merged = dict(_DEFAULT_STATE)
        merged.update(data)
        return merged
    except (json.JSONDecodeError, IOError):
        return dict(_DEFAULT_STATE)


def write_state(state: dict) -> None:
    """Atomically write workspace state to disk."""
    _ensure_dir()
    state["updated_at"] = _now_iso()
    with _LOCK:
        fd, tmp = tempfile.mkstemp(dir=str(_STATE_DIR), prefix=".state_", suffix=".json")
        try:
            with os.fdopen(fd, "w", encoding="utf-8") as f:
                json.dump(state, f, indent=2)
            os.replace(tmp, str(_STATE_PATH))
        except Exception:
            try:
                os.unlink(tmp)
            except OSError:
                pass
            raise


def get_project() -> Optional[str]:
    """Get the current project name."""
    return read_state().get("project")


def set_project(project: str) -> None:
    """Set the active project."""
    state = read_state()
    state["project"] = project
    state["selection"] = []
    state["viewer"] = dict(_DEFAULT_STATE["viewer"])
    write_state(state)


def get_selection() -> list[str]:
    """Get the list of currently selected shot IDs."""
    return read_state().get("selection", [])


def set_selection(shot_ids: list[str]) -> None:
    """Set the selection to a list of shot IDs."""
    state = read_state()
    state["selection"] = shot_ids
    write_state(state)


def get_viewer_state() -> dict:
    """Get the current viewer state."""
    return read_state().get("viewer", dict(_DEFAULT_STATE["viewer"]))


def set_viewer_state(
    shot_id: Optional[str] = None,
    take_index: Optional[int] = None,
    file_path: Optional[str] = None,
    media_type: Optional[str] = None,
    context: Optional[str] = None,
) -> None:
    """Update the viewer state."""
    state = read_state()
    viewer = state.get("viewer", {})
    if shot_id is not None:
        viewer["shot_id"] = shot_id
    if take_index is not None:
        viewer["take_index"] = take_index
    if file_path is not None:
        viewer["file_path"] = file_path
        # Auto-detect media type from extension
        ext = Path(file_path).suffix.lower()
        if ext in (".mp4", ".mov", ".webm"):
            viewer["media_type"] = "video"
        elif ext in (".png", ".jpg", ".jpeg", ".webp"):
            viewer["media_type"] = "image"
    if media_type is not None:
        viewer["media_type"] = media_type
    if context is not None:
        viewer["context"] = context
    state["viewer"] = viewer
    write_state(state)


def set_browse_tab(active: bool) -> None:
    """Switch between Browse and Inspect tabs."""
    state = read_state()
    state["browse_tab_active"] = active
    write_state(state)


def detect_dropbox_conflicts(shots_dir: Path) -> list[str]:
    """Check for Dropbox conflicted copy files in the shots directory.

    Returns list of conflict file paths (empty if clean).
    """
    conflicts = []
    if not shots_dir.is_dir():
        return conflicts
    for path in shots_dir.iterdir():
        if "conflicted copy" in path.name.lower():
            conflicts.append(str(path))
    return conflicts
```

## File: workspace/session_log.py

```python
"""Append-only JSONL session logger for the workspace learning loop.

Log path: projects/{project}/sessions/{YYYY-MM-DD}.jsonl
One file per day. Each line is a JSON object with:
- timestamp (ISO 8601)
- type (feedback, action, prime, query, generation_submitted, generation_completed)
- shot_id (optional)
- take_id (optional)
- provenance_hash (optional — from take's inputs_snapshot if available)
- data (type-specific payload)

Categories for feedback entries:
- pattern: recurring issue across shots ("kling-v3 always under-lights interiors")
- correction: specific fix ("this shot needs tighter framing")
- observation: notable finding ("the window reflection looks great here")
- preference: JT taste signal ("I prefer the warmer grade")
- trivial: low-value, don't synthesize ("ok" / "looks fine")

NOTE: generation_completed entries will be written by a file watcher in v2,
when actual generation execution is connected. The POC only logs generation_submitted.
"""

import json
import os
import threading
from datetime import datetime, timezone, date
from pathlib import Path
from typing import Any, Optional

_LOCK = threading.Lock()


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


def _log_path(project: str, projects_root: Path) -> Path:
    """Return today's session log path for the project."""
    today = date.today().isoformat()  # YYYY-MM-DD
    return projects_root / project / "sessions" / f"{today}.jsonl"


def append_entry(
    project: str,
    projects_root: Path,
    entry_type: str,
    *,
    shot_id: Optional[str] = None,
    take_id: Optional[str] = None,
    provenance_hash: Optional[str] = None,
    data: Optional[dict[str, Any]] = None,
) -> dict:
    """Append an entry to today's session log. Returns the written record."""
    log_path = _log_path(project, projects_root)
    log_path.parent.mkdir(parents=True, exist_ok=True)

    record = {
        "timestamp": _now_iso(),
        "type": entry_type,
    }
    if shot_id is not None:
        record["shot_id"] = shot_id
    if take_id is not None:
        record["take_id"] = take_id
    if provenance_hash is not None:
        record["provenance_hash"] = provenance_hash
    if data is not None:
        record["data"] = data

    line = json.dumps(record, separators=(",", ":")) + "\n"
    with _LOCK:
        with log_path.open("a", encoding="utf-8") as f:
            f.write(line)
            f.flush()
            os.fsync(f.fileno())

    return record


def read_entries(
    project: str,
    projects_root: Path,
    since: Optional[str] = None,
) -> list[dict]:
    """Read session log entries, optionally filtering by timestamp.

    Args:
        project: Project name.
        projects_root: Path to projects root directory.
        since: ISO 8601 timestamp. Only return entries after this time.

    Returns:
        List of log entries (dicts), newest last.
    """
    log_path = _log_path(project, projects_root)
    if not log_path.is_file():
        return []

    entries = []
    with log_path.open(encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                record = json.loads(line)
            except (json.JSONDecodeError, TypeError):
                continue
            if since is not None:
                ts = record.get("timestamp", "")
                if ts and ts <= since:
                    continue
            entries.append(record)

    return entries


def read_all_session_files(
    project: str,
    projects_root: Path,
    limit: int = 200,
) -> list[dict]:
    """Read entries across all session files for a project (most recent first).

    Used for context recovery after compaction. Returns at most `limit` entries.
    """
    sessions_dir = projects_root / project / "sessions"
    if not sessions_dir.is_dir():
        return []

    # Sort session files by name (YYYY-MM-DD.jsonl sorts chronologically)
    files = sorted(sessions_dir.glob("*.jsonl"), reverse=True)

    entries = []
    for session_file in files:
        if len(entries) >= limit:
            break
        try:
            with session_file.open(encoding="utf-8") as f:
                file_entries = []
                for line in f:
                    line = line.strip()
                    if not line:
                        continue
                    try:
                        file_entries.append(json.loads(line))
                    except (json.JSONDecodeError, TypeError):
                        continue
                # Add in reverse so newest entries come first
                for entry in reversed(file_entries):
                    entries.append(entry)
                    if len(entries) >= limit:
                        break
        except IOError:
            continue

    return entries
```

## Validation

```bash
# Phase 2 validation
cd /path/to/recoil

# Syntax check
python3 -c "import ast; ast.parse(open('workspace/__init__.py').read()); print('PASS: __init__.py syntax')"
python3 -c "import ast; ast.parse(open('workspace/state.py').read()); print('PASS: state.py syntax')"
python3 -c "import ast; ast.parse(open('workspace/session_log.py').read()); print('PASS: session_log.py syntax')"

# Structural checks
grep -q "read_state" workspace/state.py && echo "PASS: read_state exists" || echo "FAIL"
grep -q "write_state" workspace/state.py && echo "PASS: write_state exists" || echo "FAIL"
grep -q "get_selection" workspace/state.py && echo "PASS: get_selection exists" || echo "FAIL"
grep -q "set_viewer_state" workspace/state.py && echo "PASS: set_viewer_state exists" || echo "FAIL"
grep -q "detect_dropbox_conflicts" workspace/state.py && echo "PASS: detect_dropbox_conflicts exists" || echo "FAIL"
grep -q "append_entry" workspace/session_log.py && echo "PASS: append_entry exists" || echo "FAIL"
grep -q "read_entries" workspace/session_log.py && echo "PASS: read_entries exists" || echo "FAIL"

# Import check (no external deps needed)
python3 -c "
import sys; sys.path.insert(0, '.')
from workspace.state import read_state, write_state, get_selection, set_selection, get_viewer_state, set_viewer_state, detect_dropbox_conflicts
print('PASS: state imports')
"
python3 -c "
import sys; sys.path.insert(0, '.')
from workspace.session_log import append_entry, read_entries, read_all_session_files
print('PASS: session_log imports')
"
```

## Do NOT
- Create the `static/` directory yet
- Import from `execution` or `pipeline` modules (those come in Phase 3)
- Add FastAPI or uvicorn code
- Add MCP protocol code

---

# Phase 3: MCP Server -- All 12 Tools

**Creates:** `workspace/mcp_server.py`
**Modifies:** nothing
**Depends on:** Phase 2 (state.py, session_log.py)

## What Already Exists
- `workspace/DESIGN_SYSTEM.md` (Phase 1)
- `workspace/__init__.py` (Phase 2)
- `workspace/state.py` (Phase 2) — `read_state`, `write_state`, `get_selection`, `set_selection`, `get_viewer_state`, `set_viewer_state`, `set_project`, `detect_dropbox_conflicts`
- `workspace/session_log.py` (Phase 2) — `append_entry`, `read_entries`, `read_all_session_files`

## File: workspace/mcp_server.py

This phase creates the COMPLETE MCP server with all 12 tools and the JSON-RPC stdio protocol handler in a single file. Do NOT split this across phases — output the entire file at once.

Since the `mcp` pip package is not installed, we implement a minimal JSON-RPC 2.0 stdio server that speaks the MCP protocol. Claude Code sends JSON-RPC messages over stdin; we respond on stdout.

```python
#!/usr/bin/env python3
"""Recoil Workspace MCP Server — stdio JSON-RPC 2.0.

Provides 12 tools for Claude Code to interact with the workspace.
Reads JSON-RPC from stdin, writes responses to stdout.

Usage (in .claude.json mcpServers config):
    {
        "workspace": {
            "command": "python3",
            "args": ["/path/to/recoil/workspace/mcp_server.py"]
        }
    }
"""

import json
import logging
import sys
from pathlib import Path
from typing import Any, Optional

# ── Path setup ──────────────────────────────────────────────────
_RECOIL_ROOT = Path(__file__).resolve().parent.parent
if str(_RECOIL_ROOT) not in sys.path:
    sys.path.insert(0, str(_RECOIL_ROOT))

from core.paths import PROJECTS_ROOT
from execution.execution_store import ExecutionStore
from workspace import state as ws_state
from workspace import session_log

# ── Logging (stderr only — stdout is the JSON-RPC channel) ─────
logging.basicConfig(
    stream=sys.stderr,
    level=logging.INFO,
    format="[workspace-mcp] %(levelname)s %(message)s",
)
log = logging.getLogger("workspace-mcp")

# ── Tool Registry ───────────────────────────────────────────────

_TOOLS: dict[str, dict] = {}


def _register_tool(name: str, description: str, input_schema: dict):
    """Decorator to register an MCP tool."""
    def decorator(fn):
        _TOOLS[name] = {
            "name": name,
            "description": description,
            "inputSchema": input_schema,
            "handler": fn,
        }
        return fn
    return decorator


# ── Helper: get ExecutionStore for a project ────────────────────

def _get_store(project: str) -> ExecutionStore:
    """Return an ExecutionStore for the given project."""
    return ExecutionStore(project=project)


def _get_ops_log_path(project: str) -> Path:
    """Return the ops.log.jsonl path for a project."""
    return PROJECTS_ROOT / project / "state" / "visual" / "ops.log.jsonl"


def _get_canonical_refs(project: str) -> dict:
    """Get character and location hero image paths from _canonical/."""
    refs = {"characters": {}, "locations": {}}
    canonical_dir = PROJECTS_ROOT / project / "output" / "refs" / "_canonical"

    char_dir = canonical_dir / "characters"
    if char_dir.is_dir():
        for img in sorted(char_dir.iterdir()):
            if img.suffix.lower() in (".png", ".jpg", ".jpeg", ".webp"):
                # Name without extension becomes the character key
                refs["characters"][img.stem] = str(img.relative_to(PROJECTS_ROOT))

    loc_dir = canonical_dir / "locations"
    if loc_dir.is_dir():
        for img in sorted(loc_dir.iterdir()):
            if img.suffix.lower() in (".png", ".jpg", ".jpeg", ".webp"):
                refs["locations"][img.stem] = str(img.relative_to(PROJECTS_ROOT))

    return refs


def _shot_status_color(status: str) -> str:
    """Map shot status to a status category for display."""
    if status in ("approved", "video_complete"):
        return "green"
    elif "generating" in status or "submitted" in status or "processing" in status or "downloading" in status:
        return "amber"
    elif "failed" in status or "rejected" in status:
        return "red"
    elif status in ("needs_review", "icu_escalated", "pending_qc"):
        return "purple"
    else:
        return "gray"


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  CONTEXT TOOLS (Tools 1, 5, 6)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ── Tool 1: prime_project ───────────────────────────────────────

@_register_tool(
    name="prime_project",
    description=(
        "Load project context for the workspace session. Returns shot counts by status, "
        "pending review count, character and location names with hero image paths, "
        "active episodes, and last 10 ops log entries. Text only — no images loaded."
    ),
    input_schema={
        "type": "object",
        "properties": {
            "name": {
                "type": "string",
                "description": "Project name (e.g. 'tartarus', 'the-afterimage')",
            },
        },
        "required": ["name"],
    },
)
def tool_prime_project(params: dict) -> dict:
    project = params["name"]

    # Validate project exists
    project_dir = PROJECTS_ROOT / project
    if not project_dir.is_dir():
        return {"error": f"Project '{project}' not found at {project_dir}"}

    # Set as active project in workspace state
    ws_state.set_project(project)

    # Health checks
    shots_dir = PROJECTS_ROOT / project / "state" / "visual" / "shots"
    sessions_dir = PROJECTS_ROOT / project / "sessions"
    health = {
        "shots_dir_readable": shots_dir.is_dir(),
        "sessions_dir_writable": True,
    }
    try:
        sessions_dir.mkdir(parents=True, exist_ok=True)
    except OSError:
        health["sessions_dir_writable"] = False

    # Check for Dropbox conflicts
    conflicts = ws_state.detect_dropbox_conflicts(shots_dir)

    # Get shot summary
    store = _get_store(project)
    summary = store.summary()
    all_shots = store.get_all_shots()
    store.close()

    # Group by episode
    episodes = {}
    for shot in all_shots:
        ep = shot.get("episode_id", "unknown")
        if ep not in episodes:
            episodes[ep] = {"total": 0, "by_status": {}}
        episodes[ep]["total"] += 1
        status = shot.get("status", "previs_pending")
        episodes[ep]["by_status"][status] = episodes[ep]["by_status"].get(status, 0) + 1

    # Pending review count
    pending_statuses = {
        "previs_generated", "keyframe_generated", "video_complete",
        "needs_review", "icu_escalated", "pending_qc",
    }
    pending_review = sum(
        1 for shot in all_shots if shot.get("status") in pending_statuses
    )

    # Get canonical refs (paths only, no images)
    refs = _get_canonical_refs(project)

    # Get last 10 ops log entries
    ops_log_path = _get_ops_log_path(project)
    recent_ops = []
    if ops_log_path.is_file():
        lines = ops_log_path.read_text(encoding="utf-8").strip().split("\n")
        for line in reversed(lines[-20:]):  # Read last 20, take 10 after dedup
            try:
                record = json.loads(line)
                recent_ops.append(record)
                if len(recent_ops) >= 10:
                    break
            except (json.JSONDecodeError, TypeError):
                continue

    # Check for existing session log (context recovery)
    existing_session = session_log.read_entries(project, PROJECTS_ROOT)
    session_status = (
        f"Resuming session ({len(existing_session)} entries today)"
        if existing_session
        else "New session"
    )

    # Log the prime event
    session_log.append_entry(
        project, PROJECTS_ROOT, "prime",
        data={"total_shots": summary["total_shots"], "pending_review": pending_review},
    )

    result = {
        "project": project,
        "session_status": session_status,
        "health": health,
        "conflicts": conflicts,
        "summary": {
            "total_shots": summary["total_shots"],
            "total_cost": summary["total_cost"],
            "by_status": summary["by_status"],
        },
        "episodes": episodes,
        "pending_review": pending_review,
        "characters": refs["characters"],
        "locations": refs["locations"],
        "recent_ops": recent_ops,
    }

    if conflicts:
        result["warning"] = (
            f"Dropbox conflicts detected: {len(conflicts)} file(s). "
            "Do NOT auto-resolve — surface to JT for manual resolution."
        )

    return result


# ── Tool 5: get_shot_detail ─────────────────────────────────────

@_register_tool(
    name="get_shot_detail",
    description=(
        "Get full shot state JSON including all takes with provenance, "
        "gate results, cost, status, and take file paths. Also checks for "
        "Dropbox conflict files for this shot."
    ),
    input_schema={
        "type": "object",
        "properties": {
            "shot_id": {
                "type": "string",
                "description": "Shot identifier (e.g. 'EP001_SH03')",
            },
        },
        "required": ["shot_id"],
    },
)
def tool_get_shot_detail(params: dict) -> dict:
    shot_id = params["shot_id"]
    project = ws_state.get_project()
    if not project:
        return {"error": "No project active. Call prime_project first."}

    store = _get_store(project)
    shot = store.get_shot(shot_id)
    store.close()

    if shot is None:
        return {"error": f"Shot '{shot_id}' not found in project '{project}'"}

    # Resolve take file paths to absolute paths for viewer
    takes = shot.get("takes", [])
    for take in takes:
        fp = take.get("file_path", "")
        if fp and not fp.startswith("/"):
            take["absolute_path"] = str(PROJECTS_ROOT / project / fp)

    # Check for Dropbox conflicts on this specific shot
    shots_dir = PROJECTS_ROOT / project / "state" / "visual" / "shots"
    shot_conflicts = [
        str(p) for p in shots_dir.iterdir()
        if shot_id in p.name and "conflicted copy" in p.name.lower()
    ] if shots_dir.is_dir() else []

    result = {
        "shot_id": shot_id,
        "project": project,
        "episode_id": shot.get("episode_id", ""),
        "status": shot.get("status", "previs_pending"),
        "status_color": _shot_status_color(shot.get("status", "previs_pending")),
        "pipeline": shot.get("pipeline"),
        "model": shot.get("model"),
        "cost_incurred": shot.get("cost_incurred", 0),
        "retry_waste_cost": shot.get("retry_waste_cost", 0),
        "attempts": shot.get("attempts", 0),
        "max_attempts": shot.get("max_attempts", 3),
        "gate_results": shot.get("gate_results", {}),
        "output_path": shot.get("output_path"),
        "error_message": shot.get("error_message"),
        "takes": takes,
        "take_count": len(takes),
        "is_coverage": shot.get("is_coverage", False),
        "coverage_of": shot.get("coverage_of"),
        "updated_at": shot.get("updated_at"),
    }

    if shot_conflicts:
        result["dropbox_conflicts"] = shot_conflicts
        result["warning"] = (
            f"Dropbox conflict detected for {shot_id}. "
            "Manual resolution required."
        )

    return result


# ── Tool 6: get_shot_neighbors ──────────────────────────────────

@_register_tool(
    name="get_shot_neighbors",
    description=(
        "Get previous and next shot IDs in episode sequence with status and "
        "latest take path. For continuity reasoning — helps Claude understand "
        "what comes before and after this shot."
    ),
    input_schema={
        "type": "object",
        "properties": {
            "shot_id": {
                "type": "string",
                "description": "Shot identifier (e.g. 'EP001_SH03')",
            },
        },
        "required": ["shot_id"],
    },
)
def tool_get_shot_neighbors(params: dict) -> dict:
    shot_id = params["shot_id"]
    project = ws_state.get_project()
    if not project:
        return {"error": "No project active. Call prime_project first."}

    store = _get_store(project)
    shot = store.get_shot(shot_id)
    if shot is None:
        store.close()
        return {"error": f"Shot '{shot_id}' not found"}

    episode_id = shot.get("episode_id", "")
    episode_shots = store.get_shots_by_episode(episode_id)
    store.close()

    # Sort by shot_id for sequence ordering
    episode_shots.sort(key=lambda s: s.get("shot_id", ""))

    # Find the current shot's index
    idx = None
    for i, s in enumerate(episode_shots):
        if s.get("shot_id") == shot_id:
            idx = i
            break

    if idx is None:
        return {"error": f"Shot '{shot_id}' not found in episode '{episode_id}' list"}

    def _shot_summary(s: dict) -> dict:
        takes = s.get("takes", [])
        latest_take_path = None
        if takes:
            last = takes[-1]
            fp = last.get("file_path", "")
            if fp and not fp.startswith("/"):
                latest_take_path = str(PROJECTS_ROOT / project / fp)
            else:
                latest_take_path = fp
        return {
            "shot_id": s.get("shot_id"),
            "status": s.get("status", "previs_pending"),
            "status_color": _shot_status_color(s.get("status", "previs_pending")),
            "latest_take_path": latest_take_path,
            "take_count": len(takes),
        }

    result = {
        "shot_id": shot_id,
        "episode_id": episode_id,
        "position": f"{idx + 1} of {len(episode_shots)}",
        "previous": _shot_summary(episode_shots[idx - 1]) if idx > 0 else None,
        "next": _shot_summary(episode_shots[idx + 1]) if idx < len(episode_shots) - 1 else None,
    }

    return result


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  SELECTION + VIEWER TOOLS (Tools 2, 3, 4)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ── Tool 2: get_selection ───────────────────────────────────────

@_register_tool(
    name="get_selection",
    description=(
        "Returns the list of currently selected shot IDs in the workspace file browser. "
        "Supports multi-select — may return 0 or more IDs."
    ),
    input_schema={
        "type": "object",
        "properties": {},
    },
)
def tool_get_selection(params: dict) -> dict:
    project = ws_state.get_project()
    if not project:
        return {"error": "No project active. Call prime_project first."}

    selection = ws_state.get_selection()
    return {
        "project": project,
        "selection": selection,
        "count": len(selection),
    }


# ── Tool 3: get_viewer_state ───────────────────────────────────

@_register_tool(
    name="get_viewer_state",
    description=(
        "Returns the current viewer state: what shot/take is being displayed, "
        "the file path, media type (image/video), and any context annotation."
    ),
    input_schema={
        "type": "object",
        "properties": {},
    },
)
def tool_get_viewer_state(params: dict) -> dict:
    project = ws_state.get_project()
    if not project:
        return {"error": "No project active. Call prime_project first."}

    viewer = ws_state.get_viewer_state()
    return {
        "project": project,
        "viewer": viewer,
    }


# ── Tool 4: show_in_viewer ─────────────────────────────────────

@_register_tool(
    name="show_in_viewer",
    description=(
        "Push a specific file (image or video) to the workspace viewer pane. "
        "Can display any media file from the project. Optional context annotation "
        "describes why this is being shown."
    ),
    input_schema={
        "type": "object",
        "properties": {
            "path": {
                "type": "string",
                "description": (
                    "File path to display. Can be absolute or relative to project root "
                    "(e.g. 'output/previs/ep_001/shot_003_take2.png')."
                ),
            },
            "context": {
                "type": "string",
                "description": "Optional context annotation (e.g. 'Comparing framing with previous take').",
            },
        },
        "required": ["path"],
    },
)
def tool_show_in_viewer(params: dict) -> dict:
    project = ws_state.get_project()
    if not project:
        return {"error": "No project active. Call prime_project first."}

    path = params["path"]
    context = params.get("context")

    # Resolve relative paths against project root
    if not path.startswith("/"):
        abs_path = str(PROJECTS_ROOT / project / path)
    else:
        abs_path = path

    # Validate file exists
    if not Path(abs_path).is_file():
        return {"error": f"File not found: {abs_path}"}

    # Compute the path relative to PROJECTS_ROOT for the media endpoint
    # Let set_viewer_state auto-detect media_type from extension
    try:
        rel_path = str(Path(abs_path).relative_to(PROJECTS_ROOT))
    except ValueError:
        rel_path = path

    ws_state.set_viewer_state(
        file_path=rel_path,
        context=context,
    )

    # Log to session
    session_log.append_entry(
        project, PROJECTS_ROOT, "action",
        data={
            "action": "show_in_viewer",
            "path": rel_path,
            "context": context,
        },
    )

    # Read back the auto-detected media_type
    viewer = ws_state.get_viewer_state()

    return {
        "displayed": rel_path,
        "media_type": viewer.get("media_type"),
        "context": context,
    }


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  ACTION TOOLS (Tools 7, 8, 9)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ── Tool 7: approve_shot ────────────────────────────────────────

@_register_tool(
    name="approve_shot",
    description=(
        "Mark a shot take as approved. Updates the shot status in ExecutionStore "
        "and logs the approval with provenance hash to the session log."
    ),
    input_schema={
        "type": "object",
        "properties": {
            "shot_id": {
                "type": "string",
                "description": "Shot identifier (e.g. 'EP001_SH03')",
            },
            "take_id": {
                "type": "string",
                "description": "Take identifier (e.g. 'EP001_SH03_T11195'). If not provided, approves the latest take.",
            },
            "reason": {
                "type": "string",
                "description": "Optional reason for approval.",
            },
        },
        "required": ["shot_id"],
    },
)
def tool_approve_shot(params: dict) -> dict:
    shot_id = params["shot_id"]
    take_id = params.get("take_id")
    reason = params.get("reason", "")
    project = ws_state.get_project()
    if not project:
        return {"error": "No project active. Call prime_project first."}

    store = _get_store(project)
    shot = store.get_shot(shot_id)
    if shot is None:
        store.close()
        return {"error": f"Shot '{shot_id}' not found"}

    current_status = shot.get("status", "previs_pending")

    # Determine the target approved status based on current status
    if "previs" in current_status:
        target_status = "previs_approved"
    elif "keyframe" in current_status:
        target_status = "keyframe_approved"
    elif "video" in current_status:
        target_status = "approved"
    else:
        target_status = "approved"

    # Find the take
    takes = shot.get("takes", [])
    if not takes:
        store.close()
        return {"error": f"Shot '{shot_id}' has no takes"}

    if take_id:
        matching = [t for t in takes if t.get("take_id") == take_id]
        if not matching:
            store.close()
            return {"error": f"Take '{take_id}' not found on shot '{shot_id}'"}
        take = matching[0]
    else:
        take = takes[-1]
        take_id = take.get("take_id", f"take_{len(takes)}")

    # Extract provenance hash if available
    provenance_hash = take.get("provenance_hash") or take.get("inputs_snapshot_hash")

    # FIX (Gemini #3, Opus H3): Single atomic update — status + gate_results
    # in one call, not two separate store opens.
    try:
        store.update_shot(
            shot_id,
            status=target_status,
            gate_results={"hero_frame": take.get("file_path")},
        )
    except Exception as e:
        store.close()
        return {"error": f"Failed to update shot status: {e}"}
    store.close()

    # Log approval to session log with provenance
    session_log.append_entry(
        project, PROJECTS_ROOT, "action",
        shot_id=shot_id,
        take_id=take_id,
        provenance_hash=provenance_hash,
        data={
            "action": "approve_shot",
            "from_status": current_status,
            "to_status": target_status,
            "reason": reason,
            "take_file": take.get("file_path"),
        },
    )

    return {
        "shot_id": shot_id,
        "take_id": take_id,
        "previous_status": current_status,
        "new_status": target_status,
        "provenance_hash": provenance_hash,
        "reason": reason,
    }


# ── Tool 8: reject_shot ─────────────────────────────────────────

@_register_tool(
    name="reject_shot",
    description=(
        "Mark a shot take as rejected. Updates the shot status and logs "
        "the rejection with reason to the session log."
    ),
    input_schema={
        "type": "object",
        "properties": {
            "shot_id": {
                "type": "string",
                "description": "Shot identifier",
            },
            "take_id": {
                "type": "string",
                "description": "Take identifier. If not provided, rejects the latest take.",
            },
            "reason": {
                "type": "string",
                "description": "Reason for rejection (e.g. 'flat lighting', 'wrong framing').",
            },
        },
        "required": ["shot_id"],
    },
)
def tool_reject_shot(params: dict) -> dict:
    shot_id = params["shot_id"]
    take_id = params.get("take_id")
    reason = params.get("reason", "")
    project = ws_state.get_project()
    if not project:
        return {"error": "No project active. Call prime_project first."}

    store = _get_store(project)
    shot = store.get_shot(shot_id)
    if shot is None:
        store.close()
        return {"error": f"Shot '{shot_id}' not found"}

    current_status = shot.get("status", "previs_pending")

    # Determine the target rejected status
    if "previs" in current_status:
        target_status = "previs_rejected"
    elif "keyframe" in current_status:
        target_status = "keyframe_rejected"
    elif "video" in current_status:
        target_status = "video_rejected"
    else:
        target_status = "rejected"

    # Find the take
    takes = shot.get("takes", [])
    if take_id:
        matching = [t for t in takes if t.get("take_id") == take_id]
        # FIX (Opus H5): Return error if take_id specified but not found,
        # instead of silently falling through with empty take.
        if not matching:
            store.close()
            return {"error": f"Take '{take_id}' not found on shot '{shot_id}'"}
        take = matching[0]
    else:
        take = takes[-1] if takes else {}
        take_id = take.get("take_id", f"take_{len(takes)}")

    provenance_hash = take.get("provenance_hash") or take.get("inputs_snapshot_hash")

    # Mark the take as rejected in the takes list
    for t in takes:
        tid = t.get("take_id", "")
        if tid == take_id:
            t["rejected"] = True
            t["rejection_reason"] = reason
            break

    try:
        store.update_shot(shot_id, status=target_status, takes=takes)
    except Exception as e:
        store.close()
        return {"error": f"Failed to update shot: {e}"}
    store.close()

    # Log rejection
    session_log.append_entry(
        project, PROJECTS_ROOT, "action",
        shot_id=shot_id,
        take_id=take_id,
        provenance_hash=provenance_hash,
        data={
            "action": "reject_shot",
            "from_status": current_status,
            "to_status": target_status,
            "reason": reason,
        },
    )

    return {
        "shot_id": shot_id,
        "take_id": take_id,
        "previous_status": current_status,
        "new_status": target_status,
        "reason": reason,
    }


# ── Tool 9: submit_generation ───────────────────────────────────

@_register_tool(
    name="submit_generation",
    description=(
        "Submit a new generation take for a shot. Returns an op_id immediately — "
        "the generation runs asynchronously. Use get_activity() to poll status. "
        "Adjustments are structured (framing, lighting, mood, action, continuity) — "
        "they get concatenated into the prompt pipeline as an override section."
    ),
    input_schema={
        "type": "object",
        "properties": {
            "shot_id": {
                "type": "string",
                "description": "Shot identifier",
            },
            "adjustments": {
                "type": "object",
                "description": (
                    "Structured adjustments dict. Keys: framing, lighting, mood, "
                    "action, continuity, or any freeform key. Values are natural "
                    "language strings describing the desired change."
                ),
            },
            "model": {
                "type": "string",
                "description": "Override model (e.g. 'kling-v3', 'seeddance-2.0'). Uses current shot model if not specified.",
            },
        },
        "required": ["shot_id"],
    },
)
def tool_submit_generation(params: dict) -> dict:
    shot_id = params["shot_id"]
    adjustments = params.get("adjustments", {})
    model_override = params.get("model")
    project = ws_state.get_project()
    if not project:
        return {"error": "No project active. Call prime_project first."}

    store = _get_store(project)
    shot = store.get_shot(shot_id)
    if shot is None:
        store.close()
        return {"error": f"Shot '{shot_id}' not found"}

    # FIX (Gemini #4, Opus H4): Record that generation was requested on the shot.
    # We do NOT transition to a "generating" status since the POC doesn't actually
    # execute generation — that would confuse the state machine. Instead we set a
    # generation_requested field so the UI can reflect that intent was logged.
    try:
        store.update_shot(shot_id, generation_requested=True)
    except Exception:
        pass  # Non-fatal — field may not be recognized by all store versions
    store.close()

    # Generate op_id
    from pipeline.lib.ops_log import make_op_id, log_op_started
    op_id = make_op_id()

    # Build the adjustment override text
    override_parts = []
    for key, value in adjustments.items():
        override_parts.append(f"[{key.upper()}] {value}")
    override_text = " | ".join(override_parts) if override_parts else ""

    # Log to ops log
    ops_log_path = _get_ops_log_path(project)
    log_op_started(
        ops_log_path,
        op_id=op_id,
        name="workspace_generation",
        args={
            "shot_id": shot_id,
            "adjustments": adjustments,
            "model": model_override or shot.get("model"),
            "override_text": override_text,
        },
        context={"operator": "workspace_mcp"},
    )

    # Log to session log synchronously before returning
    session_log.append_entry(
        project, PROJECTS_ROOT, "generation_submitted",
        shot_id=shot_id,
        data={
            "op_id": op_id,
            "adjustments": adjustments,
            "model": model_override or shot.get("model"),
            "override_text": override_text,
        },
    )

    # NOTE: Actual generation execution is NOT implemented in the POC.
    # In production, this would call StepRunner in a background thread.
    # For the POC, we log the intent and return the op_id.
    # The operator (Claude Code or JT) would then use the Production Console
    # or CLI tools to actually run the generation.
    # generation_completed entries will be written by a file watcher in v2.

    return {
        "op_id": op_id,
        "shot_id": shot_id,
        "status": "submitted",
        "model": model_override or shot.get("model"),
        "adjustments": adjustments,
        "override_text": override_text,
        "note": (
            "Generation intent logged. POC does not execute generation directly — "
            "use Production Console or StepRunner CLI to process. "
            "The op_id is tracked in ops.log.jsonl."
        ),
    }


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  FEEDBACK + ACTIVITY TOOLS (Tools 10, 11, 12)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ── Tool 10: log_feedback ───────────────────────────────────────

@_register_tool(
    name="log_feedback",
    description=(
        "Log structured feedback linked to a shot's provenance hash. "
        "This is the primary learning loop data: JT's comments tied to "
        "the exact combination of prompt, model, refs, and settings that "
        "produced the output. Categories: pattern, correction, observation, "
        "preference, trivial."
    ),
    input_schema={
        "type": "object",
        "properties": {
            "shot_id": {
                "type": "string",
                "description": "Shot identifier",
            },
            "take_id": {
                "type": "string",
                "description": "Take identifier",
            },
            "text": {
                "type": "string",
                "description": "The feedback text (JT's comment or Claude's observation)",
            },
            "action": {
                "type": "string",
                "description": "Action taken in response (e.g. 'approve', 'reject', 'reroll', 'adjust_prompt')",
            },
            "category": {
                "type": "string",
                "enum": ["pattern", "correction", "observation", "preference", "trivial"],
                "description": (
                    "Feedback category. pattern=recurring issue, correction=specific fix, "
                    "observation=notable finding, preference=taste signal, trivial=low-value"
                ),
            },
        },
        "required": ["shot_id", "take_id", "text", "action", "category"],
    },
)
def tool_log_feedback(params: dict) -> dict:
    shot_id = params["shot_id"]
    take_id = params["take_id"]
    text = params["text"]
    action = params["action"]
    category = params["category"]
    project = ws_state.get_project()
    if not project:
        return {"error": "No project active. Call prime_project first."}

    # Look up the take's provenance hash
    store = _get_store(project)
    shot = store.get_shot(shot_id)
    store.close()

    provenance_hash = None
    if shot:
        for t in shot.get("takes", []):
            if t.get("take_id") == take_id:
                provenance_hash = t.get("provenance_hash") or t.get("inputs_snapshot_hash")
                break

    entry = session_log.append_entry(
        project, PROJECTS_ROOT, "feedback",
        shot_id=shot_id,
        take_id=take_id,
        provenance_hash=provenance_hash,
        data={
            "text": text,
            "action": action,
            "category": category,
            "model": shot.get("model") if shot else None,
            "status": shot.get("status") if shot else None,
        },
    )

    return {
        "logged": True,
        "shot_id": shot_id,
        "take_id": take_id,
        "category": category,
        "provenance_hash": provenance_hash,
        "entry": entry,
    }


# ── Tool 11: get_session_log ───────────────────────────────────

@_register_tool(
    name="get_session_log",
    description=(
        "Get session history for context recovery after compaction. "
        "Returns recent session log entries. If 'since' is provided, "
        "only returns entries after that timestamp."
    ),
    input_schema={
        "type": "object",
        "properties": {
            "since": {
                "type": "string",
                "description": "ISO 8601 timestamp. Only return entries after this time.",
            },
        },
    },
)
def tool_get_session_log(params: dict) -> dict:
    since = params.get("since")
    project = ws_state.get_project()
    if not project:
        return {"error": "No project active. Call prime_project first."}

    entries = session_log.read_entries(project, PROJECTS_ROOT, since=since)

    # Summarize by type
    by_type = {}
    for e in entries:
        t = e.get("type", "unknown")
        by_type[t] = by_type.get(t, 0) + 1

    return {
        "project": project,
        "total_entries": len(entries),
        "by_type": by_type,
        "entries": entries[-50:],  # Last 50 to avoid context bloat
        "truncated": len(entries) > 50,
    }


# ── Tool 12: get_activity ──────────────────────────────────────

@_register_tool(
    name="get_activity",
    description=(
        "Get in-flight generations and recent completions/failures. "
        "Reads the ops log to find pending operations (no completion record) "
        "and recent completed/failed operations."
    ),
    input_schema={
        "type": "object",
        "properties": {},
    },
)
def tool_get_activity(params: dict) -> dict:
    project = ws_state.get_project()
    if not project:
        return {"error": "No project active. Call prime_project first."}

    ops_log_path = _get_ops_log_path(project)

    # Get dangling (in-flight) operations
    from pipeline.lib.ops_log import scan_for_dangling_ops
    in_flight = scan_for_dangling_ops(ops_log_path)

    # Get recent completed/failed (last 20 lines of ops log)
    recent = []
    if ops_log_path.is_file():
        lines = ops_log_path.read_text(encoding="utf-8").strip().split("\n")
        for line in reversed(lines[-40:]):
            try:
                record = json.loads(line)
                if record.get("status") in ("completed", "failed", "crashed"):
                    recent.append(record)
                    if len(recent) >= 10:
                        break
            except (json.JSONDecodeError, TypeError):
                continue

    return {
        "project": project,
        "in_flight": in_flight,
        "in_flight_count": len(in_flight),
        "recent_completed": [r for r in recent if r.get("status") == "completed"][:5],
        "recent_failed": [r for r in recent if r.get("status") in ("failed", "crashed")][:5],
    }


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  JSON-RPC 2.0 PROTOCOL HANDLER
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

def _build_tools_list() -> list[dict]:
    """Build the MCP tools/list response."""
    return [
        {
            "name": t["name"],
            "description": t["description"],
            "inputSchema": t["inputSchema"],
        }
        for t in _TOOLS.values()
    ]


def _handle_request(request: dict) -> dict:
    """Handle a single JSON-RPC request and return the response."""
    method = request.get("method", "")
    req_id = request.get("id")
    params = request.get("params", {})

    if method == "initialize":
        return {
            "jsonrpc": "2.0",
            "id": req_id,
            "result": {
                "protocolVersion": "2024-11-05",
                "capabilities": {"tools": {}},
                "serverInfo": {
                    "name": "recoil-workspace",
                    "version": "0.1.0",
                },
            },
        }

    if method == "notifications/initialized":
        # Notification — no response needed
        return None

    if method == "tools/list":
        return {
            "jsonrpc": "2.0",
            "id": req_id,
            "result": {"tools": _build_tools_list()},
        }

    if method == "tools/call":
        tool_name = params.get("name", "")
        tool_args = params.get("arguments", {})

        tool_def = _TOOLS.get(tool_name)
        if tool_def is None:
            return {
                "jsonrpc": "2.0",
                "id": req_id,
                "result": {
                    "content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}],
                    "isError": True,
                },
            }

        try:
            result = tool_def["handler"](tool_args)
            text = json.dumps(result, indent=2, default=str)
            return {
                "jsonrpc": "2.0",
                "id": req_id,
                "result": {
                    "content": [{"type": "text", "text": text}],
                    "isError": False,
                },
            }
        except Exception as e:
            log.exception("Tool %s failed", tool_name)
            return {
                "jsonrpc": "2.0",
                "id": req_id,
                "result": {
                    "content": [{"type": "text", "text": f"Error: {e}"}],
                    "isError": True,
                },
            }

    # Unknown method
    return {
        "jsonrpc": "2.0",
        "id": req_id,
        "error": {"code": -32601, "message": f"Method not found: {method}"},
    }


def _main_loop():
    """Read JSON-RPC messages from stdin, write responses to stdout."""
    log.info("Recoil Workspace MCP server starting...")

    # Health check: verify projects root is accessible
    if not PROJECTS_ROOT.is_dir():
        log.error("PROJECTS_ROOT not found: %s", PROJECTS_ROOT)
        sys.exit(1)

    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        try:
            request = json.loads(line)
        except json.JSONDecodeError as e:
            log.warning("Invalid JSON: %s", e)
            response = {
                "jsonrpc": "2.0",
                "id": None,
                "error": {"code": -32700, "message": f"Parse error: {e}"},
            }
            sys.stdout.write(json.dumps(response) + "\n")
            sys.stdout.flush()
            continue

        response = _handle_request(request)
        if response is not None:
            sys.stdout.write(json.dumps(response) + "\n")
            sys.stdout.flush()


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

## Validation

```bash
cd /path/to/recoil

# Syntax check
python3 -c "import ast; ast.parse(open('workspace/mcp_server.py').read()); print('PASS: mcp_server.py syntax')"

# All 12 tools
grep -c "@_register_tool" workspace/mcp_server.py | xargs -I{} test {} -eq 12 && echo "PASS: 12 tools registered" || echo "FAIL"
grep -q "prime_project" workspace/mcp_server.py && echo "PASS: prime_project" || echo "FAIL"
grep -q "get_selection" workspace/mcp_server.py && echo "PASS: get_selection" || echo "FAIL"
grep -q "get_viewer_state" workspace/mcp_server.py && echo "PASS: get_viewer_state" || echo "FAIL"
grep -q "show_in_viewer" workspace/mcp_server.py && echo "PASS: show_in_viewer" || echo "FAIL"
grep -q "get_shot_detail" workspace/mcp_server.py && echo "PASS: get_shot_detail" || echo "FAIL"
grep -q "get_shot_neighbors" workspace/mcp_server.py && echo "PASS: get_shot_neighbors" || echo "FAIL"
grep -q "approve_shot" workspace/mcp_server.py && echo "PASS: approve_shot" || echo "FAIL"
grep -q "reject_shot" workspace/mcp_server.py && echo "PASS: reject_shot" || echo "FAIL"
grep -q "submit_generation" workspace/mcp_server.py && echo "PASS: submit_generation" || echo "FAIL"
grep -q "log_feedback" workspace/mcp_server.py && echo "PASS: log_feedback" || echo "FAIL"
grep -q "get_session_log" workspace/mcp_server.py && echo "PASS: get_session_log" || echo "FAIL"
grep -q "get_activity" workspace/mcp_server.py && echo "PASS: get_activity" || echo "FAIL"
grep -q "_main_loop" workspace/mcp_server.py && echo "PASS: main loop" || echo "FAIL"
grep -q "_handle_request" workspace/mcp_server.py && echo "PASS: request handler" || echo "FAIL"
grep -q "tools/list" workspace/mcp_server.py && echo "PASS: tools/list handler" || echo "FAIL"
grep -q "tools/call" workspace/mcp_server.py && echo "PASS: tools/call handler" || echo "FAIL"
grep -q "scan_for_dangling_ops" workspace/mcp_server.py && echo "PASS: imports scan_for_dangling_ops" || echo "FAIL"
grep -q "make_op_id" workspace/mcp_server.py && echo "PASS: imports make_op_id" || echo "FAIL"

# Full import check
python3 -c "
import sys; sys.path.insert(0, '.')
import workspace.mcp_server as mcp
tools = list(mcp._TOOLS.keys())
expected = [
    'prime_project', 'get_selection', 'get_viewer_state', 'show_in_viewer',
    'get_shot_detail', 'get_shot_neighbors', 'approve_shot', 'reject_shot',
    'submit_generation', 'log_feedback', 'get_session_log', 'get_activity',
]
for name in expected:
    assert name in tools, f'Missing tool: {name}'
print(f'PASS: all 12 tools registered: {tools}')
"
```

## Do NOT
- Split MCP tools across multiple files or phases
- Start on the FastAPI server
- Create frontend files
- Modify state.py or session_log.py
- Add any tools beyond the 12 specified

---

# Phase 4: FastAPI Server

**Creates:** `workspace/server.py`
**Modifies:** nothing
**Depends on:** Phases 2-3

## What Already Exists
- `workspace/state.py` — read_state, write_state, set_selection, set_viewer_state
- `workspace/session_log.py` — read_entries
- `workspace/mcp_server.py` — complete with 12 tools
- `execution.execution_store.ExecutionStore` — get_shot, get_all_shots, get_shots_by_episode
- `core.paths` — PROJECTS_ROOT, RECOIL_ROOT

## File: workspace/server.py

```python
#!/usr/bin/env python3
"""Recoil Workspace FastAPI Server.

Serves the workspace frontend (static files), media files from project output
directories, and state API endpoints. The frontend polls these endpoints;
the MCP server reads/writes the same state.json file.

# Port allocation: Pre-Prod=8420, Production Console=8430, Workspace=8450

Usage:
    python3 workspace/server.py --project tartarus
    python3 workspace/server.py --project tartarus --port 8450

Endpoints:
    GET  /                          — Redirect to /workspace
    GET  /workspace                 — Serve index.html
    GET  /api/health                — Health check
    GET  /api/state                 — Current viewer state + selection
    POST /api/state/selection       — Update selection (from frontend click)
    POST /api/state/viewer          — Update viewer state (from frontend)
    GET  /api/shots/{project}       — All shots grouped by episode
    GET  /api/shot/{project}/{shot_id} — Single shot detail
    GET  /api/activity/{project}    — In-flight generations from ops log
    GET  /media/{path:path}         — Serve any media file from projects root
    GET  /static/{path:path}        — Serve static frontend files
"""

import argparse
import json
import logging
import sys
from pathlib import Path
from typing import Optional

# ── Path setup ──────────────────────────────────────────────────
_RECOIL_ROOT = Path(__file__).resolve().parent.parent
if str(_RECOIL_ROOT) not in sys.path:
    sys.path.insert(0, str(_RECOIL_ROOT))

from core.paths import PROJECTS_ROOT

try:
    from fastapi import FastAPI, Request
    from fastapi.responses import (
        FileResponse,
        HTMLResponse,
        JSONResponse,
        RedirectResponse,
    )
    from fastapi.staticfiles import StaticFiles
except ImportError:
    print("FastAPI not installed. Run: pip install fastapi uvicorn", file=sys.stderr)
    sys.exit(1)

from workspace import state as ws_state
from workspace import session_log
from execution.execution_store import ExecutionStore

logging.basicConfig(level=logging.INFO, format="[workspace] %(levelname)s %(message)s")
log = logging.getLogger("workspace")

# ── App ─────────────────────────────────────────────────────────

app = FastAPI(title="Recoil Workspace", version="0.1.0")

_STATIC_DIR = Path(__file__).parent / "static"

# CLI default — can be overridden by --project flag
_DEFAULT_PROJECT: str = "tartarus"


def _get_store(project: str) -> ExecutionStore:
    return ExecutionStore(project=project)


def _get_ops_log_path(project: str) -> Path:
    return PROJECTS_ROOT / project / "state" / "visual" / "ops.log.jsonl"


def _shot_status_color(status: str) -> str:
    if status in ("approved", "video_complete"):
        return "green"
    elif "generating" in status or "submitted" in status or "processing" in status or "downloading" in status:
        return "amber"
    elif "failed" in status or "rejected" in status:
        return "red"
    elif status in ("needs_review", "icu_escalated", "pending_qc"):
        return "purple"
    return "gray"


# ── Routes: Static + Root ──────────────────────────────────────

@app.get("/")
async def root():
    return RedirectResponse(url="/workspace")


@app.get("/workspace")
async def workspace_page():
    index = _STATIC_DIR / "index.html"
    if not index.is_file():
        return HTMLResponse("<h1>Workspace not built yet</h1><p>Run the build phases first.</p>", status_code=404)
    return FileResponse(index, media_type="text/html")


# Mount static files
if _STATIC_DIR.is_dir():
    app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")


# ── Routes: Health ─────────────────────────────────────────────

@app.get("/api/health")
async def health():
    project = ws_state.get_project() or _DEFAULT_PROJECT
    project_dir = PROJECTS_ROOT / project
    return JSONResponse({
        "status": "ok",
        "project": project,
        "projects_root": str(PROJECTS_ROOT),
        "project_exists": project_dir.is_dir(),
    })


# ── Routes: State ──────────────────────────────────────────────

@app.get("/api/state")
async def get_state():
    state = ws_state.read_state()
    return JSONResponse(state)


@app.post("/api/state/selection")
async def update_selection(request: Request):
    body = await request.json()
    shot_ids = body.get("shot_ids", [])
    ws_state.set_selection(shot_ids)

    # Auto-switch to Inspect tab when a shot is selected
    if len(shot_ids) == 1:
        ws_state.set_browse_tab(False)

    return JSONResponse({"selection": shot_ids})


@app.post("/api/state/viewer")
async def update_viewer(request: Request):
    body = await request.json()
    ws_state.set_viewer_state(
        shot_id=body.get("shot_id"),
        take_index=body.get("take_index"),
        file_path=body.get("file_path"),
        media_type=body.get("media_type"),
        context=body.get("context"),
    )
    return JSONResponse(ws_state.get_viewer_state())


# ── Routes: Shots ──────────────────────────────────────────────

@app.get("/api/shots/{project}")
async def get_shots(project: str):
    store = _get_store(project)
    all_shots = store.get_all_shots()
    store.close()

    # Group by episode
    episodes = {}
    for shot in all_shots:
        ep = shot.get("episode_id", "unknown")
        if ep not in episodes:
            episodes[ep] = []

        takes = shot.get("takes", [])
        latest_take_path = None
        if takes:
            fp = takes[-1].get("file_path", "")
            if fp:
                latest_take_path = fp

        episodes[ep].append({
            "shot_id": shot.get("shot_id"),
            "status": shot.get("status", "previs_pending"),
            "status_color": _shot_status_color(shot.get("status", "previs_pending")),
            "take_count": len(takes),
            "latest_take_path": latest_take_path,
            "model": shot.get("model"),
            "is_coverage": shot.get("is_coverage", False),
            "coverage_of": shot.get("coverage_of"),
        })

    return JSONResponse({
        "project": project,
        "episodes": episodes,
        "total_shots": len(all_shots),
    })


@app.get("/api/shot/{project}/{shot_id}")
async def get_shot(project: str, shot_id: str):
    store = _get_store(project)
    shot = store.get_shot(shot_id)
    store.close()

    if shot is None:
        return JSONResponse({"error": f"Shot '{shot_id}' not found"}, status_code=404)

    # Resolve take paths
    takes = shot.get("takes", [])
    for take in takes:
        fp = take.get("file_path", "")
        if fp and not fp.startswith("/"):
            take["media_url"] = f"/media/{project}/{fp}"

    # FIX (Opus C3): Include attempts, max_attempts, coverage_of in response
    # so the inspector can display them correctly.
    return JSONResponse({
        "shot_id": shot_id,
        "project": project,
        "episode_id": shot.get("episode_id", ""),
        "status": shot.get("status", "previs_pending"),
        "status_color": _shot_status_color(shot.get("status", "previs_pending")),
        "pipeline": shot.get("pipeline"),
        "model": shot.get("model"),
        "cost_incurred": shot.get("cost_incurred", 0),
        "attempts": shot.get("attempts", 0),
        "max_attempts": shot.get("max_attempts", 3),
        "gate_results": shot.get("gate_results", {}),
        "output_path": shot.get("output_path"),
        "error_message": shot.get("error_message"),
        "takes": takes,
        "take_count": len(takes),
        "is_coverage": shot.get("is_coverage", False),
        "coverage_of": shot.get("coverage_of"),
        "updated_at": shot.get("updated_at"),
    })


# ── Routes: Activity ───────────────────────────────────────────

@app.get("/api/activity/{project}")
async def get_activity(project: str):
    from pipeline.lib.ops_log import scan_for_dangling_ops

    ops_log_path = _get_ops_log_path(project)
    in_flight = scan_for_dangling_ops(ops_log_path)

    recent = []
    if ops_log_path.is_file():
        lines = ops_log_path.read_text(encoding="utf-8").strip().split("\n")
        for line in reversed(lines[-40:]):
            try:
                record = json.loads(line)
                if record.get("status") in ("completed", "failed", "crashed"):
                    recent.append(record)
                    if len(recent) >= 10:
                        break
            except (json.JSONDecodeError, TypeError):
                continue

    return JSONResponse({
        "project": project,
        "in_flight": in_flight,
        "in_flight_count": len(in_flight),
        "recent": recent,
    })


# ── Routes: Media ──────────────────────────────────────────────

@app.get("/media/{path:path}")
async def serve_media(path: str):
    """Serve media files from the projects root.

    Path format: {project}/output/previs/ep_001/shot_001.png
    or just: {project}/{relative_path}
    """
    full_path = PROJECTS_ROOT / path
    if not full_path.is_file():
        return JSONResponse({"error": f"File not found: {path}"}, status_code=404)

    # Security: ensure path is within PROJECTS_ROOT
    try:
        full_path.resolve().relative_to(PROJECTS_ROOT.resolve())
    except ValueError:
        return JSONResponse({"error": "Access denied"}, status_code=403)

    # Determine content type
    ext = full_path.suffix.lower()
    content_types = {
        ".png": "image/png",
        ".jpg": "image/jpeg",
        ".jpeg": "image/jpeg",
        ".webp": "image/webp",
        ".mp4": "video/mp4",
        ".mov": "video/quicktime",
        ".webm": "video/webm",
    }
    ct = content_types.get(ext, "application/octet-stream")

    return FileResponse(full_path, media_type=ct)


# ── CLI Entry Point ────────────────────────────────────────────

def main():
    global _DEFAULT_PROJECT

    parser = argparse.ArgumentParser(description="Recoil Workspace Server")
    parser.add_argument("--project", default="tartarus", help="Default project name")
    parser.add_argument("--port", type=int, default=8450, help="Server port")
    parser.add_argument("--host", default="127.0.0.1", help="Server host")
    args = parser.parse_args()

    _DEFAULT_PROJECT = args.project

    # Set the project in workspace state on startup
    ws_state.set_project(args.project)

    log.info("Starting Recoil Workspace on %s:%d (project: %s)", args.host, args.port, args.project)
    log.info("Projects root: %s", PROJECTS_ROOT)

    import uvicorn
    uvicorn.run(app, host=args.host, port=args.port, log_level="info")


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

## Validation

```bash
cd /path/to/recoil

# Syntax check
python3 -c "import ast; ast.parse(open('workspace/server.py').read()); print('PASS: server.py syntax')"

# Structural checks
grep -q "FastAPI" workspace/server.py && echo "PASS: uses FastAPI" || echo "FAIL"
grep -q "/api/health" workspace/server.py && echo "PASS: health endpoint" || echo "FAIL"
grep -q "/api/state" workspace/server.py && echo "PASS: state endpoint" || echo "FAIL"
grep -q "/api/shots/" workspace/server.py && echo "PASS: shots endpoint" || echo "FAIL"
grep -q "/api/shot/" workspace/server.py && echo "PASS: shot detail endpoint" || echo "FAIL"
grep -q "/api/activity/" workspace/server.py && echo "PASS: activity endpoint" || echo "FAIL"
grep -q "/media/" workspace/server.py && echo "PASS: media endpoint" || echo "FAIL"
grep -q "uvicorn" workspace/server.py && echo "PASS: uvicorn runner" || echo "FAIL"
grep -q "8450" workspace/server.py && echo "PASS: default port 8450" || echo "FAIL"
grep -q "attempts" workspace/server.py && echo "PASS: attempts field in shot detail" || echo "FAIL"
grep -q "max_attempts" workspace/server.py && echo "PASS: max_attempts field in shot detail" || echo "FAIL"
grep -q "coverage_of" workspace/server.py && echo "PASS: coverage_of field in shot detail" || echo "FAIL"

# Import check (FastAPI must be installed)
python3 -c "
import sys; sys.path.insert(0, '.')
import workspace.server as srv
print(f'PASS: server imports, app={srv.app.title}')
" 2>&1 | head -5
```

## Do NOT
- Create the static/ directory or frontend files (Phase 5)
- Add WebSocket endpoints (deferred to v2)
- Add authentication
- Import or use xterm.js

---

# Phase 5: Frontend -- HTML Shell + CSS

**Creates:** `workspace/static/index.html`, `workspace/static/workspace.css`
**Modifies:** nothing
**Depends on:** Phase 1 (design system), Phase 4 (server routes)

## What Already Exists
- `workspace/server.py` — serves `/workspace` -> `static/index.html`, mounts `/static/`
- `workspace/DESIGN_SYSTEM.md` — color palette, typography, component patterns

## File: workspace/static/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 WORKSPACE</title>
<link rel="stylesheet" href="/static/workspace.css">
</head>
<body>

<!-- ── Top Bar ── -->
<header class="topbar">
  <div class="topbar-identity">RECOIL <span class="topbar-label">WORKSPACE</span></div>
  <div class="topbar-spacer"></div>
  <div class="topbar-status" id="topbar-status">NO PROJECT</div>
  <div class="topbar-shortcuts">
    <span class="shortcut-hint">[Esc] Browse</span>
    <span class="shortcut-hint">[B] A/B</span>
    <span class="shortcut-hint">[Space] Play</span>
    <span class="shortcut-hint">[&larr;&rarr;] Takes</span>
    <span class="shortcut-hint">[&uarr;&darr;] Shots</span>
  </div>
</header>

<!-- ── Main Layout ── -->
<div class="layout">

  <!-- Left Panel -->
  <div class="left-panel" id="left-panel">

    <!-- Tab Bar -->
    <div class="tab-bar">
      <button class="tab-btn active" id="tab-browse" data-tab="browse">BROWSE</button>
      <button class="tab-btn" id="tab-inspect" data-tab="inspect">INSPECT</button>
    </div>

    <!-- Browse Tab -->
    <div class="tab-content active" id="content-browse">
      <!-- Episode Tree -->
      <div class="section-header">SHOTS</div>
      <div class="file-tree" id="file-tree">
        <div class="tree-empty">No project loaded</div>
      </div>

      <!-- Activity Monitor -->
      <div class="section-header">ACTIVITY</div>
      <div class="activity-monitor" id="activity-monitor">
        <div class="activity-empty">No activity</div>
      </div>
    </div>

    <!-- Inspect Tab -->
    <div class="tab-content" id="content-inspect">
      <div class="inspector" id="inspector">
        <div class="inspector-empty">Select a shot to inspect</div>
      </div>
    </div>

  </div>

  <!-- Divider -->
  <div class="divider" id="divider"></div>

  <!-- Right Panel: Viewer -->
  <div class="right-panel" id="right-panel">
    <div class="viewer" id="viewer">
      <div class="viewer-empty">
        <div class="viewer-empty-text">No media selected</div>
        <div class="viewer-empty-hint">Select a shot from the file browser</div>
      </div>
      <img class="viewer-image" id="viewer-image" style="display:none" alt="Shot preview">
      <video class="viewer-video" id="viewer-video" style="display:none" loop></video>
    </div>
    <div class="viewer-footer" id="viewer-footer" style="display:none">
      <span class="viewer-shot-id" id="viewer-shot-id"></span>
      <span class="viewer-take-nav">
        <button class="nav-btn" id="btn-prev-take" title="Previous take [Left]">&larr;</button>
        <span class="viewer-take-num" id="viewer-take-num"></span>
        <button class="nav-btn" id="btn-next-take" title="Next take [Right]">&rarr;</button>
      </span>
      <span class="viewer-status" id="viewer-status"></span>
      <span class="viewer-model" id="viewer-model"></span>
      <span class="viewer-ab" id="viewer-ab" style="display:none">A/B</span>
      <span class="viewer-context" id="viewer-context"></span>
    </div>
  </div>

</div>

<script src="/static/workspace.js"></script>
</body>
</html>
```

## File: workspace/static/workspace.css

```css
/* ── Recoil Workspace Design System ── */
:root {
  --bg-base: #08080f;
  --bg-surface: #0e0e18;
  --bg-raised: #161622;
  --bg-overlay: #1e1e2e;

  --border-dim: #1a1a2a;
  --border-default: #2a2a3a;
  --border-focus: #8888cc;

  --text-primary: #cccccc;
  --text-secondary: #888888;
  --text-dim: #555555;
  --text-bright: #eeeeee;

  --accent-blue: #8888cc;
  --accent-green: #88cc88;
  --accent-amber: #cccc88;
  --accent-red: #cc8888;
  --accent-purple: #aa88cc;

  --font-mono: 'SF Mono', 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
  --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif;
}

* { box-sizing: border-box; margin: 0; padding: 0; }

html, body {
  height: 100%;
  background: var(--bg-base);
  color: var(--text-primary);
  font-family: var(--font-sans);
  font-size: 13px;
  line-height: 1.4;
  overflow: hidden;
}

body {
  display: flex;
  flex-direction: column;
}

/* ── Top Bar ── */
.topbar {
  display: flex;
  align-items: center;
  height: 36px;
  padding: 0 12px;
  background: var(--bg-base);
  border-bottom: 1px solid var(--border-dim);
  flex-shrink: 0;
  gap: 12px;
}

.topbar-identity {
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 2px;
  color: var(--text-dim);
}

.topbar-label {
  color: var(--text-bright);
}

.topbar-spacer {
  flex: 1;
}

.topbar-status {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--text-secondary);
  letter-spacing: 1px;
}

.topbar-shortcuts {
  display: flex;
  gap: 8px;
}

.shortcut-hint {
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--text-dim);
}

/* ── Layout ── */
.layout {
  display: flex;
  flex: 1;
  overflow: hidden;
}

.left-panel {
  display: flex;
  flex-direction: column;
  width: 320px;
  min-width: 200px;
  max-width: 600px;
  background: var(--bg-surface);
  border-right: 1px solid var(--border-dim);
  overflow: hidden;
}

.right-panel {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-width: 300px;
  background: var(--bg-base);
  overflow: hidden;
}

/* ── Divider ── */
.divider {
  width: 4px;
  background: var(--border-dim);
  cursor: col-resize;
  flex-shrink: 0;
  transition: background 150ms ease;
}

.divider:hover,
.divider.dragging {
  background: rgba(136, 136, 204, 0.4);
}

/* ── Tab Bar ── */
.tab-bar {
  display: flex;
  height: 32px;
  background: var(--bg-base);
  border-bottom: 1px solid var(--border-dim);
  flex-shrink: 0;
}

.tab-btn {
  flex: 1;
  background: transparent;
  border: none;
  border-bottom: 2px solid transparent;
  color: var(--text-secondary);
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 1px;
  cursor: pointer;
  padding: 0 16px;
  transition: color 150ms ease, border-color 150ms ease;
}

.tab-btn:hover {
  color: var(--text-primary);
}

.tab-btn.active {
  color: var(--text-bright);
  border-bottom-color: var(--accent-blue);
}

/* ── Tab Content ── */
.tab-content {
  display: none;
  flex: 1;
  overflow-y: auto;
  overflow-x: hidden;
}

.tab-content.active {
  display: flex;
  flex-direction: column;
}

/* ── Section Header ── */
.section-header {
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 1.5px;
  color: var(--text-dim);
  padding: 6px 8px 4px;
  border-bottom: 1px solid var(--border-dim);
  flex-shrink: 0;
  text-transform: uppercase;
}

/* ── File Tree ── */
.file-tree {
  flex: 1;
  overflow-y: auto;
  min-height: 100px;
}

.tree-empty,
.activity-empty,
.inspector-empty,
.viewer-empty-text,
.viewer-empty-hint {
  color: var(--text-dim);
  font-family: var(--font-mono);
  font-size: 11px;
  padding: 12px 8px;
  text-align: center;
}

.viewer-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100%;
  gap: 4px;
}

.viewer-empty-hint {
  font-size: 10px;
}

/* Episode group */
.episode-group {
  border-bottom: 1px solid var(--border-dim);
}

.episode-header {
  display: flex;
  align-items: center;
  padding: 4px 8px;
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 600;
  color: var(--text-secondary);
  gap: 6px;
  user-select: none;
}

.episode-header:hover {
  background: var(--bg-raised);
  color: var(--text-primary);
}

.episode-chevron {
  font-size: 9px;
  transition: transform 150ms ease;
  color: var(--text-dim);
}

.episode-chevron.collapsed {
  transform: rotate(-90deg);
}

.episode-shots {
  display: block;
}

.episode-shots.collapsed {
  display: none;
}

.episode-count {
  color: var(--text-dim);
  font-size: 10px;
  margin-left: auto;
}

/* Shot item */
.shot-item {
  display: flex;
  align-items: center;
  height: 24px;
  padding: 0 8px 0 24px;
  cursor: pointer;
  gap: 6px;
  font-family: var(--font-mono);
  font-size: 12px;
  user-select: none;
}

.shot-item:hover {
  background: var(--bg-raised);
}

.shot-item.selected {
  background: var(--bg-raised);
  border-left: 2px solid var(--accent-blue);
  padding-left: 22px;
}

.shot-item.multi-selected {
  background: rgba(136, 136, 204, 0.08);
}

.shot-item.is-coverage {
  padding-left: 36px;
  opacity: 0.7;
  font-size: 11px;
}

/* Status dot */
.status-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  flex-shrink: 0;
}

.status-dot.green { background: var(--accent-green); }
.status-dot.amber { background: var(--accent-amber); }
.status-dot.red { background: var(--accent-red); }
.status-dot.purple { background: var(--accent-purple); }
.status-dot.gray { background: var(--text-dim); border: 1px solid var(--text-dim); }

.status-dot.amber {
  animation: pulse 2s ease-in-out infinite;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}

.shot-id {
  color: var(--text-primary);
}

.shot-takes {
  color: var(--text-dim);
  font-size: 10px;
  margin-left: auto;
}

/* ── Activity Monitor ── */
.activity-monitor {
  max-height: 160px;
  overflow-y: auto;
  border-top: 1px solid var(--border-dim);
  flex-shrink: 0;
}

.activity-item {
  display: flex;
  align-items: center;
  height: 20px;
  padding: 0 8px;
  gap: 6px;
  font-family: var(--font-mono);
  font-size: 11px;
}

.activity-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  flex-shrink: 0;
}

.activity-dot.in-flight {
  background: var(--accent-amber);
  animation: pulse 2s ease-in-out infinite;
}

.activity-dot.completed {
  background: var(--accent-green);
}

.activity-dot.failed {
  background: var(--accent-red);
}

.activity-name {
  color: var(--text-primary);
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.activity-time {
  color: var(--text-dim);
  font-size: 10px;
}

/* ── Inspector ── */
.inspector {
  flex: 1;
  overflow-y: auto;
  padding: 0;
}

.inspector-section {
  border-bottom: 1px solid var(--border-dim);
}

.inspector-section-header {
  display: flex;
  align-items: center;
  padding: 6px 8px;
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 1px;
  color: var(--text-secondary);
  gap: 6px;
  user-select: none;
  text-transform: uppercase;
}

.inspector-section-header:hover {
  background: var(--bg-raised);
}

.inspector-chevron {
  font-size: 8px;
  transition: transform 150ms ease;
}

.inspector-chevron.collapsed {
  transform: rotate(-90deg);
}

.inspector-section-body {
  padding: 4px 8px 8px;
}

.inspector-section-body.collapsed {
  display: none;
}

.inspector-row {
  display: grid;
  grid-template-columns: 100px 1fr;
  gap: 4px;
  padding: 2px 0;
  font-size: 12px;
}

.inspector-key {
  color: var(--text-secondary);
  font-family: var(--font-mono);
  font-size: 11px;
  text-align: right;
  padding-right: 8px;
}

.inspector-value {
  color: var(--text-primary);
  word-break: break-all;
}

.inspector-value.status-green { color: var(--accent-green); }
.inspector-value.status-amber { color: var(--accent-amber); }
.inspector-value.status-red { color: var(--accent-red); }
.inspector-value.status-purple { color: var(--accent-purple); }

.inspector-takes-list {
  list-style: none;
  padding: 0;
}

.inspector-take-item {
  display: flex;
  align-items: center;
  padding: 3px 4px;
  gap: 6px;
  cursor: pointer;
  border-radius: 2px;
  font-family: var(--font-mono);
  font-size: 11px;
}

.inspector-take-item:hover {
  background: var(--bg-raised);
}

.inspector-take-item.current {
  background: var(--bg-raised);
  border-left: 2px solid var(--accent-blue);
}

.inspector-take-item.rejected {
  color: var(--accent-red);
  text-decoration: line-through;
}

.inspector-take-num {
  color: var(--text-dim);
  min-width: 20px;
}

.inspector-take-model {
  color: var(--text-dim);
  font-size: 10px;
  margin-left: auto;
}

/* ── Viewer ── */
.viewer {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  background: var(--bg-base);
}

.viewer-image {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
}

.viewer-video {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
}

/* ── Viewer Footer ── */
.viewer-footer {
  display: flex;
  align-items: center;
  height: 28px;
  padding: 0 8px;
  background: var(--bg-surface);
  border-top: 1px solid var(--border-dim);
  gap: 12px;
  flex-shrink: 0;
  font-family: var(--font-mono);
  font-size: 11px;
}

.viewer-shot-id {
  color: var(--text-bright);
  font-weight: 600;
}

.viewer-take-nav {
  display: flex;
  align-items: center;
  gap: 4px;
}

.nav-btn {
  background: transparent;
  border: 1px solid var(--border-default);
  color: var(--text-secondary);
  width: 22px;
  height: 20px;
  font-size: 12px;
  cursor: pointer;
  border-radius: 2px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.nav-btn:hover {
  border-color: var(--accent-blue);
  color: var(--text-bright);
}

.viewer-take-num {
  color: var(--text-secondary);
  min-width: 60px;
  text-align: center;
}

.viewer-status {
  color: var(--text-secondary);
}

.viewer-model {
  color: var(--text-dim);
}

.viewer-ab {
  background: var(--accent-blue);
  color: var(--bg-base);
  padding: 1px 6px;
  border-radius: 2px;
  font-size: 10px;
  font-weight: 700;
}

.viewer-context {
  color: var(--text-dim);
  font-size: 10px;
  margin-left: auto;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ── Scrollbar Styling ── */
::-webkit-scrollbar {
  width: 6px;
}

::-webkit-scrollbar-track {
  background: var(--bg-surface);
}

::-webkit-scrollbar-thumb {
  background: var(--border-default);
  border-radius: 3px;
}

::-webkit-scrollbar-thumb:hover {
  background: var(--border-focus);
}
```

## Validation

```bash
cd /path/to/recoil

# Files exist
test -f workspace/static/index.html && echo "PASS: index.html exists" || echo "FAIL"
test -f workspace/static/workspace.css && echo "PASS: workspace.css exists" || echo "FAIL"

# HTML structure
grep -q "RECOIL WORKSPACE" workspace/static/index.html && echo "PASS: title" || echo "FAIL"
grep -q "file-tree" workspace/static/index.html && echo "PASS: file tree div" || echo "FAIL"
grep -q "activity-monitor" workspace/static/index.html && echo "PASS: activity monitor div" || echo "FAIL"
grep -q "inspector" workspace/static/index.html && echo "PASS: inspector div" || echo "FAIL"
grep -q "viewer-image" workspace/static/index.html && echo "PASS: viewer image" || echo "FAIL"
grep -q "viewer-video" workspace/static/index.html && echo "PASS: viewer video" || echo "FAIL"
grep -q "divider" workspace/static/index.html && echo "PASS: divider" || echo "FAIL"
grep -q "workspace.css" workspace/static/index.html && echo "PASS: CSS link" || echo "FAIL"
grep -q "workspace.js" workspace/static/index.html && echo "PASS: JS link" || echo "FAIL"

# CSS structure
grep -q "\-\-bg-base" workspace/static/workspace.css && echo "PASS: CSS variables" || echo "FAIL"
grep -q "\.divider" workspace/static/workspace.css && echo "PASS: divider styles" || echo "FAIL"
grep -q "\.shot-item" workspace/static/workspace.css && echo "PASS: shot item styles" || echo "FAIL"
grep -q "\.viewer-footer" workspace/static/workspace.css && echo "PASS: viewer footer styles" || echo "FAIL"
grep -q "@keyframes pulse" workspace/static/workspace.css && echo "PASS: pulse animation" || echo "FAIL"
```

## Do NOT
- Write any JavaScript (that's Phase 6)
- Add external font CDN links (system fonts only)
- Add any framework dependencies

---

# Phase 6: Frontend JS -- Complete

**Creates:** `workspace/static/workspace.js`
**Modifies:** nothing
**Depends on:** Phase 5 (HTML/CSS)

## What Already Exists
- `workspace/static/index.html` — HTML shell with all container divs and IDs
- `workspace/static/workspace.css` — all styles
- `workspace/server.py` — API endpoints: `/api/state`, `/api/shots/{project}`, `/api/activity/{project}`, `/api/shot/{project}/{shot_id}`, `/api/state/selection`, `/api/state/viewer`

## File: workspace/static/workspace.js

This phase outputs the COMPLETE workspace.js in a single file. Do NOT split this across phases — output the entire file at once.

```javascript
/* ── Recoil Workspace Frontend ──
 *
 * Vanilla JS, no framework, no build step.
 * Polls server for state updates every 3 seconds.
 * Communicates selection/viewer changes via POST to /api/state/*.
 */

'use strict';

// ── State ──────────────────────────────────────────────────────

const WS = {
  project: null,
  episodes: {},       // { ep_id: [shot, ...] }
  totalShots: 0,
  selection: [],      // shot IDs
  viewerState: null,  // { shot_id, take_index, file_path, media_type, context }
  shotDetail: null,   // full shot detail for inspector
  abMode: false,      // A/B toggle state
  abTakeIndex: null,  // alternate take index for A/B
  pollTimer: null,
  activityTimer: null,
  expandedEpisodes: new Set(),
};


// ── API Helpers ────────────────────────────────────────────────

async function apiFetch(url, options) {
  try {
    const resp = await fetch(url, options);
    if (!resp.ok) {
      console.error(`API error: ${resp.status} ${resp.statusText}`);
      return null;
    }
    return await resp.json();
  } catch (e) {
    console.error(`API fetch failed: ${url}`, e);
    return null;
  }
}

async function apiGet(url) {
  return apiFetch(url);
}

async function apiPost(url, body) {
  return apiFetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
}


// ── Initialization ─────────────────────────────────────────────

async function init() {
  // Load current state from server
  const state = await apiGet('/api/state');
  if (state && state.project) {
    WS.project = state.project;
    WS.selection = state.selection || [];
    WS.viewerState = state.viewer || null;
    updateTopbar();
    await loadShots();
    if (WS.viewerState && WS.viewerState.file_path) {
      displayInViewer(WS.viewerState);
    }
    // If there's a selection, load inspector for first selected shot
    if (WS.selection.length === 1) {
      await loadShotDetail(WS.selection[0]);
      switchTab('inspect');
    }
  }

  // Start polling
  WS.pollTimer = setInterval(pollShots, 3000);
  WS.activityTimer = setInterval(pollActivity, 3000);

  // Wire up UI
  setupTabs();
  setupDivider();

  // Initial activity load
  pollActivity();
}


// ── Top Bar ────────────────────────────────────────────────────

function updateTopbar() {
  const el = document.getElementById('topbar-status');
  if (WS.project) {
    el.textContent = WS.project.toUpperCase() + ' \u2014 ' + WS.totalShots + ' shots';
  } else {
    el.textContent = 'NO PROJECT';
  }
}


// ── Tab Switching ──────────────────────────────────────────────

function setupTabs() {
  document.getElementById('tab-browse').addEventListener('click', () => switchTab('browse'));
  document.getElementById('tab-inspect').addEventListener('click', () => switchTab('inspect'));
}

function switchTab(tabName) {
  const browseBtn = document.getElementById('tab-browse');
  const inspectBtn = document.getElementById('tab-inspect');
  const browseContent = document.getElementById('content-browse');
  const inspectContent = document.getElementById('content-inspect');

  if (tabName === 'browse') {
    browseBtn.classList.add('active');
    inspectBtn.classList.remove('active');
    browseContent.classList.add('active');
    inspectContent.classList.remove('active');
  } else {
    browseBtn.classList.remove('active');
    inspectBtn.classList.add('active');
    browseContent.classList.remove('active');
    inspectContent.classList.add('active');
  }
}


// ── Divider (draggable) ────────────────────────────────────────

function setupDivider() {
  const divider = document.getElementById('divider');
  const leftPanel = document.getElementById('left-panel');
  let isDragging = false;
  let startX = 0;
  let startWidth = 0;

  divider.addEventListener('mousedown', (e) => {
    isDragging = true;
    startX = e.clientX;
    startWidth = leftPanel.offsetWidth;
    divider.classList.add('dragging');
    document.body.style.cursor = 'col-resize';
    document.body.style.userSelect = 'none';
    e.preventDefault();
  });

  document.addEventListener('mousemove', (e) => {
    if (!isDragging) return;
    const delta = e.clientX - startX;
    const newWidth = Math.max(200, Math.min(600, startWidth + delta));
    leftPanel.style.width = newWidth + 'px';
  });

  document.addEventListener('mouseup', () => {
    if (!isDragging) return;
    isDragging = false;
    divider.classList.remove('dragging');
    document.body.style.cursor = '';
    document.body.style.userSelect = '';
  });
}


// ── File Browser ───────────────────────────────────────────────

async function loadShots() {
  if (!WS.project) return;
  const data = await apiGet('/api/shots/' + WS.project);
  if (!data) return;

  WS.episodes = data.episodes || {};
  WS.totalShots = data.total_shots || 0;
  updateTopbar();
  renderFileTree();
}

async function pollShots() {
  if (!WS.project) return;
  const data = await apiGet('/api/shots/' + WS.project);
  if (!data) return;

  const oldTotal = WS.totalShots;
  // FIX (Opus C2): Capture old episodes BEFORE overwriting so the
  // change-detection comparison actually compares old vs new.
  const oldEpisodes = WS.episodes;
  WS.episodes = data.episodes || {};
  WS.totalShots = data.total_shots || 0;

  // Only re-render if data changed
  if (WS.totalShots !== oldTotal || JSON.stringify(data.episodes) !== JSON.stringify(oldEpisodes)) {
    updateTopbar();
    renderFileTree();
  }
}

function renderFileTree() {
  const container = document.getElementById('file-tree');
  if (Object.keys(WS.episodes).length === 0) {
    container.innerHTML = '<div class="tree-empty">No shots found</div>';
    return;
  }

  let html = '';
  const sortedEpisodes = Object.keys(WS.episodes).sort();

  for (const epId of sortedEpisodes) {
    const shots = WS.episodes[epId];
    const isExpanded = WS.expandedEpisodes.has(epId);

    // Auto-expand first episode on initial load
    if (WS.expandedEpisodes.size === 0 && epId === sortedEpisodes[0]) {
      WS.expandedEpisodes.add(epId);
    }
    const expanded = WS.expandedEpisodes.has(epId);

    html += '<div class="episode-group">';
    html += '<div class="episode-header" onclick="toggleEpisode(\'' + epId + '\')">';
    html += '<span class="episode-chevron' + (expanded ? '' : ' collapsed') + '">\u25BC</span>';
    html += '<span>' + epId + '</span>';
    html += '<span class="episode-count">' + shots.length + '</span>';
    html += '</div>';
    html += '<div class="episode-shots' + (expanded ? '' : ' collapsed') + '" id="shots-' + epId + '">';

    for (const shot of shots) {
      const isSelected = WS.selection.includes(shot.shot_id);
      const isMultiSelected = WS.selection.length > 1 && isSelected;
      let cls = 'shot-item';
      if (isSelected && !isMultiSelected) cls += ' selected';
      if (isMultiSelected) cls += ' multi-selected';
      if (shot.is_coverage) cls += ' is-coverage';

      html += '<div class="' + cls + '" ';
      html += 'data-shot-id="' + shot.shot_id + '" ';
      html += 'onclick="selectShot(\'' + shot.shot_id + '\', event)">';
      html += '<span class="status-dot ' + shot.status_color + '"></span>';
      html += '<span class="shot-id">' + shot.shot_id + '</span>';
      if (shot.take_count > 0) {
        html += '<span class="shot-takes">' + shot.take_count + 'T</span>';
      }
      html += '</div>';
    }

    html += '</div></div>';
  }

  container.innerHTML = html;
}

function toggleEpisode(epId) {
  if (WS.expandedEpisodes.has(epId)) {
    WS.expandedEpisodes.delete(epId);
  } else {
    WS.expandedEpisodes.add(epId);
  }
  renderFileTree();
}

async function selectShot(shotId, event) {
  if (event && (event.metaKey || event.ctrlKey)) {
    // Multi-select: toggle the shot in selection
    const idx = WS.selection.indexOf(shotId);
    if (idx >= 0) {
      WS.selection.splice(idx, 1);
    } else {
      WS.selection.push(shotId);
    }
  } else {
    // Single select
    WS.selection = [shotId];
  }

  // Update server state
  await apiPost('/api/state/selection', { shot_ids: WS.selection });

  // Re-render tree to show selection
  renderFileTree();

  // If single selection, load detail and show in viewer
  if (WS.selection.length === 1) {
    await loadShotDetail(shotId);
    switchTab('inspect');

    // Show latest take in viewer
    if (WS.shotDetail && WS.shotDetail.takes && WS.shotDetail.takes.length > 0) {
      const lastTake = WS.shotDetail.takes[WS.shotDetail.takes.length - 1];
      const takePath = lastTake.media_url || lastTake.file_path;
      if (takePath) {
        const viewerData = {
          shot_id: shotId,
          take_index: WS.shotDetail.takes.length - 1,
          file_path: takePath,
          media_type: detectMediaType(takePath),
        };
        await apiPost('/api/state/viewer', viewerData);
        WS.viewerState = viewerData;
        displayInViewer(viewerData);
      }
    }
  }
}


// ── Activity Monitor ───────────────────────────────────────────

async function pollActivity() {
  if (!WS.project) return;
  const data = await apiGet('/api/activity/' + WS.project);
  if (!data) return;
  renderActivity(data);
}

function renderActivity(data) {
  const container = document.getElementById('activity-monitor');
  const inFlight = data.in_flight || [];
  const recent = data.recent || [];

  if (inFlight.length === 0 && recent.length === 0) {
    container.innerHTML = '<div class="activity-empty">No activity</div>';
    return;
  }

  let html = '';

  for (const op of inFlight) {
    const name = op.name || 'generation';
    const shotId = (op.args && op.args.shot_id) || op.id || '';
    const ts = op.ts || '';
    html += '<div class="activity-item">';
    html += '<span class="activity-dot in-flight"></span>';
    html += '<span class="activity-name">' + escapeHtml(shotId + ' ' + name) + '</span>';
    html += '<span class="activity-time">' + formatTimestamp(ts) + '</span>';
    html += '</div>';
  }

  for (const op of recent.slice(0, 5)) {
    const status = op.status || 'unknown';
    const dotClass = status === 'completed' ? 'completed' : 'failed';
    const ts = op.ts || '';
    const id = op.id || '';
    html += '<div class="activity-item">';
    html += '<span class="activity-dot ' + dotClass + '"></span>';
    html += '<span class="activity-name">' + escapeHtml(id) + '</span>';
    html += '<span class="activity-time">' + formatTimestamp(ts) + '</span>';
    html += '</div>';
  }

  container.innerHTML = html;
}


// ── Utility Functions ──────────────────────────────────────────

function detectMediaType(path) {
  if (!path) return 'unknown';
  const ext = path.split('.').pop().toLowerCase();
  if (['mp4', 'mov', 'webm'].includes(ext)) return 'video';
  if (['png', 'jpg', 'jpeg', 'webp'].includes(ext)) return 'image';
  return 'unknown';
}

function escapeHtml(str) {
  const div = document.createElement('div');
  div.textContent = str;
  return div.innerHTML;
}

function formatTimestamp(ts) {
  if (!ts) return '';
  try {
    const d = new Date(ts);
    return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
  } catch {
    return ts;
  }
}


// ── Inspector ──────────────────────────────────────────────────

async function loadShotDetail(shotId) {
  if (!WS.project) return;
  const data = await apiGet('/api/shot/' + WS.project + '/' + shotId);
  if (data && !data.error) {
    WS.shotDetail = data;
    WS.abMode = false;
    WS.abTakeIndex = null;
    renderInspector(data);
  }
}

function renderInspector(data) {
  const container = document.getElementById('inspector');
  if (!data) {
    container.innerHTML = '<div class="inspector-empty">Select a shot to inspect</div>';
    return;
  }

  let html = '';

  // ── Status Section (always expanded) ──
  html += '<div class="inspector-section">';
  html += '<div class="inspector-section-header" onclick="toggleInspectorSection(this)">';
  html += '<span class="inspector-chevron">\u25BC</span> STATUS';
  html += '</div>';
  html += '<div class="inspector-section-body">';
  html += inspectorRow('Shot', data.shot_id);
  html += inspectorRow('Episode', data.episode_id);
  html += inspectorRow('Status', '<span class="inspector-value status-' + data.status_color + '">' + escapeHtml(data.status) + '</span>', true);
  html += inspectorRow('Pipeline', data.pipeline || 'none');
  html += inspectorRow('Model', data.model || 'none');
  html += inspectorRow('Cost', '$' + (data.cost_incurred || 0).toFixed(4));
  html += inspectorRow('Attempts', (data.attempts != null ? data.attempts : 0) + '/' + (data.max_attempts || 3));
  if (data.is_coverage) {
    html += inspectorRow('Coverage', 'Yes (of ' + (data.coverage_of || 'unknown') + ')');
  }
  html += '</div></div>';

  // ── Takes Section (always expanded) ──
  html += '<div class="inspector-section">';
  html += '<div class="inspector-section-header" onclick="toggleInspectorSection(this)">';
  html += '<span class="inspector-chevron">\u25BC</span> TAKES (' + (data.take_count || 0) + ')';
  html += '</div>';
  html += '<div class="inspector-section-body">';

  if (data.takes && data.takes.length > 0) {
    html += '<ul class="inspector-takes-list">';
    for (let i = 0; i < data.takes.length; i++) {
      const take = data.takes[i];
      const isCurrent = WS.viewerState && WS.viewerState.take_index === i;
      const isRejected = take.rejected;
      let cls = 'inspector-take-item';
      if (isCurrent) cls += ' current';
      if (isRejected) cls += ' rejected';

      const takeNum = take.take_number || (i + 1);
      const model = take.model || '';
      const cost = take.cost_usd ? '$' + take.cost_usd.toFixed(3) : (take.cost ? '$' + take.cost.toFixed(3) : '');

      html += '<li class="' + cls + '" onclick="viewTake(' + i + ')">';
      html += '<span class="inspector-take-num">T' + takeNum + '</span>';
      html += '<span>' + (isRejected ? 'REJECTED' : (take.disposition || '')) + '</span>';
      if (cost) html += '<span class="inspector-take-model">' + cost + '</span>';
      html += '<span class="inspector-take-model">' + escapeHtml(model) + '</span>';
      html += '</li>';
    }
    html += '</ul>';
  } else {
    html += '<div class="inspector-empty">No takes yet</div>';
  }
  html += '</div></div>';

  // ── Gate Results Section (collapsed by default) ──
  const gates = data.gate_results || {};
  if (Object.keys(gates).length > 0) {
    html += '<div class="inspector-section">';
    html += '<div class="inspector-section-header" onclick="toggleInspectorSection(this)">';
    html += '<span class="inspector-chevron collapsed">\u25BC</span> GATE RESULTS';
    html += '</div>';
    html += '<div class="inspector-section-body collapsed">';
    for (const [key, val] of Object.entries(gates)) {
      html += inspectorRow(key, typeof val === 'object' ? JSON.stringify(val) : String(val));
    }
    html += '</div></div>';
  }

  // ── Error Section (only if error) ──
  if (data.error_message) {
    html += '<div class="inspector-section">';
    html += '<div class="inspector-section-header" onclick="toggleInspectorSection(this)">';
    html += '<span class="inspector-chevron">\u25BC</span> ERROR';
    html += '</div>';
    html += '<div class="inspector-section-body">';
    html += '<div style="color:var(--accent-red);font-size:12px;word-break:break-all;">' + escapeHtml(data.error_message) + '</div>';
    html += '</div></div>';
  }

  container.innerHTML = html;
}

function inspectorRow(key, value, isHtml) {
  // FIX (Opus M2): Escape value by default. When HTML is intentionally needed
  // (e.g. the Status row), pass isHtml=true.
  var rendered = isHtml ? value : escapeHtml(String(value != null ? value : ''));
  return '<div class="inspector-row"><span class="inspector-key">' + escapeHtml(key) + '</span><span class="inspector-value">' + rendered + '</span></div>';
}

function toggleInspectorSection(header) {
  const chevron = header.querySelector('.inspector-chevron');
  const body = header.nextElementSibling;
  chevron.classList.toggle('collapsed');
  body.classList.toggle('collapsed');
}


// ── Viewer ─────────────────────────────────────────────────────

function displayInViewer(viewerData) {
  if (!viewerData || !viewerData.file_path) {
    hideViewer();
    return;
  }

  const imgEl = document.getElementById('viewer-image');
  const vidEl = document.getElementById('viewer-video');
  const emptyEl = document.querySelector('.viewer-empty');
  const footer = document.getElementById('viewer-footer');

  // Build media URL
  let mediaUrl = viewerData.file_path;
  if (!mediaUrl.startsWith('/media/') && !mediaUrl.startsWith('http')) {
    // If it's a relative path, prefix with /media/{project}/
    if (WS.project && !mediaUrl.startsWith(WS.project)) {
      mediaUrl = '/media/' + WS.project + '/' + mediaUrl;
    } else {
      mediaUrl = '/media/' + mediaUrl;
    }
  }

  const type = viewerData.media_type || detectMediaType(mediaUrl);

  if (type === 'image') {
    imgEl.src = mediaUrl;
    imgEl.style.display = 'block';
    vidEl.style.display = 'none';
    vidEl.pause();
  } else if (type === 'video') {
    vidEl.src = mediaUrl;
    vidEl.style.display = 'block';
    imgEl.style.display = 'none';
    vidEl.play().catch(() => {});
  } else {
    hideViewer();
    return;
  }

  if (emptyEl) emptyEl.style.display = 'none';
  footer.style.display = 'flex';

  // Update footer
  document.getElementById('viewer-shot-id').textContent = viewerData.shot_id || '';
  updateTakeNav();
  updateViewerStatus();
}

function hideViewer() {
  document.getElementById('viewer-image').style.display = 'none';
  document.getElementById('viewer-video').style.display = 'none';
  document.getElementById('viewer-video').pause();
  const emptyEl = document.querySelector('.viewer-empty');
  if (emptyEl) emptyEl.style.display = 'flex';
  document.getElementById('viewer-footer').style.display = 'none';
}

function updateTakeNav() {
  if (!WS.shotDetail || !WS.viewerState) return;
  const takes = WS.shotDetail.takes || [];
  const idx = WS.viewerState.take_index;
  const total = takes.length;
  document.getElementById('viewer-take-num').textContent =
    'Take ' + ((idx != null ? idx : 0) + 1) + '/' + total;

  document.getElementById('btn-prev-take').disabled = (idx <= 0);
  document.getElementById('btn-next-take').disabled = (idx >= total - 1);
}

function updateViewerStatus() {
  if (!WS.shotDetail) return;
  document.getElementById('viewer-status').textContent = WS.shotDetail.status || '';
  document.getElementById('viewer-model').textContent = WS.shotDetail.model || '';
  document.getElementById('viewer-context').textContent =
    (WS.viewerState && WS.viewerState.context) || '';

  const abEl = document.getElementById('viewer-ab');
  abEl.style.display = WS.abMode ? 'inline' : 'none';
}


// ── Take Navigation ────────────────────────────────────────────

async function viewTake(index) {
  if (!WS.shotDetail || !WS.shotDetail.takes) return;
  const takes = WS.shotDetail.takes;
  if (index < 0 || index >= takes.length) return;

  const take = takes[index];
  const path = take.media_url || take.file_path;
  if (!path) return;

  const viewerData = {
    shot_id: WS.shotDetail.shot_id,
    take_index: index,
    file_path: path,
    media_type: detectMediaType(path),
  };

  WS.viewerState = viewerData;
  await apiPost('/api/state/viewer', viewerData);
  displayInViewer(viewerData);

  // FIX (Gemini #5): Instead of re-rendering the entire inspector on take
  // change (which collapses expanded sections), only update active take
  // styling via DOM class toggling.
  document.querySelectorAll('.inspector-take-item').forEach(function(el, i) {
    el.classList.toggle('current', i === index);
  });
}

function prevTake() {
  if (!WS.viewerState || WS.viewerState.take_index == null) return;
  viewTake(WS.viewerState.take_index - 1);
}

function nextTake() {
  if (!WS.viewerState || WS.viewerState.take_index == null) return;
  viewTake(WS.viewerState.take_index + 1);
}


// ── A/B Toggle ─────────────────────────────────────────────────

function toggleAB() {
  if (!WS.shotDetail || !WS.viewerState) return;
  const takes = WS.shotDetail.takes || [];
  if (takes.length < 2) return;  // Need at least 2 takes for A/B

  if (!WS.abMode) {
    // Enter A/B mode: store current take as A, show previous as B
    WS.abMode = true;
    const currentIdx = WS.viewerState.take_index;
    // B is the take before current (or last if current is first)
    WS.abTakeIndex = currentIdx > 0 ? currentIdx - 1 : takes.length - 1;
    viewTake(WS.abTakeIndex);
  } else {
    // Toggle between A and B
    const tempIdx = WS.viewerState.take_index;
    viewTake(WS.abTakeIndex);
    WS.abTakeIndex = tempIdx;
  }

  updateViewerStatus();
}

function exitAB() {
  WS.abMode = false;
  WS.abTakeIndex = null;
  updateViewerStatus();
}


// ── Shot Navigation (Up/Down) ──────────────────────────────────

function navigateShot(direction) {
  // direction: -1 = up (previous), 1 = down (next)
  if (WS.selection.length !== 1) return;
  const currentId = WS.selection[0];

  // FIX (Gemini #8, Opus M4): Build flat list from ALL episodes regardless
  // of expansion state. If the target shot is in a collapsed episode,
  // auto-expand it before selecting.
  const allShots = [];
  const sortedEps = Object.keys(WS.episodes).sort();
  for (const epId of sortedEps) {
    for (const shot of WS.episodes[epId]) {
      allShots.push({ shot_id: shot.shot_id, epId: epId });
    }
  }

  const idx = allShots.findIndex(function(s) { return s.shot_id === currentId; });
  if (idx < 0) return;

  const newIdx = idx + direction;
  if (newIdx < 0 || newIdx >= allShots.length) return;

  // Auto-expand the target episode if it's collapsed
  WS.expandedEpisodes.add(allShots[newIdx].epId);

  selectShot(allShots[newIdx].shot_id, null);
}


// ── Keyboard Shortcuts ─────────────────────────────────────────

document.addEventListener('keydown', function(e) {
  // Don't capture if typing in an input
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;

  switch (e.key) {
    case 'Escape':
      exitAB();
      switchTab('browse');
      break;
    case 'b':
    case 'B':
      toggleAB();
      break;
    case ' ':
      e.preventDefault();
      togglePlayPause();
      break;
    case 'ArrowLeft':
      e.preventDefault();
      prevTake();
      break;
    case 'ArrowRight':
      e.preventDefault();
      nextTake();
      break;
    case 'ArrowUp':
      e.preventDefault();
      navigateShot(-1);
      break;
    case 'ArrowDown':
      e.preventDefault();
      navigateShot(1);
      break;
  }
});

function togglePlayPause() {
  const vid = document.getElementById('viewer-video');
  if (vid.style.display !== 'none') {
    if (vid.paused) {
      vid.play().catch(() => {});
    } else {
      vid.pause();
    }
  }
}


// ── Wire up footer nav buttons ─────────────────────────────────

document.addEventListener('DOMContentLoaded', function() {
  document.getElementById('btn-prev-take').addEventListener('click', prevTake);
  document.getElementById('btn-next-take').addEventListener('click', nextTake);
});


// ── Boot ───────────────────────────────────────────────────────

document.addEventListener('DOMContentLoaded', init);
```

## Validation

```bash
cd /path/to/recoil

# File exists
test -f workspace/static/workspace.js && echo "PASS: workspace.js exists" || echo "FAIL"

# Syntax check — parse JS
node -e "
const fs = require('fs');
const code = fs.readFileSync('workspace/static/workspace.js', 'utf8');
try { new Function(code); console.log('PASS: JS syntax valid'); } catch(e) { console.log('FAIL: ' + e.message); }
"

# Structure checks — all features present
grep -q "const WS" workspace/static/workspace.js && echo "PASS: WS state object" || echo "FAIL"
grep -q "async function init" workspace/static/workspace.js && echo "PASS: init function" || echo "FAIL"
grep -q "setupDivider" workspace/static/workspace.js && echo "PASS: divider setup" || echo "FAIL"
grep -q "renderFileTree" workspace/static/workspace.js && echo "PASS: file tree render" || echo "FAIL"
grep -q "selectShot" workspace/static/workspace.js && echo "PASS: shot selection" || echo "FAIL"
grep -q "pollActivity" workspace/static/workspace.js && echo "PASS: activity polling" || echo "FAIL"
grep -q "toggleEpisode" workspace/static/workspace.js && echo "PASS: episode toggle" || echo "FAIL"
grep -q "DOMContentLoaded" workspace/static/workspace.js && echo "PASS: boot listener" || echo "FAIL"
grep -q "renderInspector" workspace/static/workspace.js && echo "PASS: renderInspector" || echo "FAIL"
grep -q "displayInViewer" workspace/static/workspace.js && echo "PASS: displayInViewer" || echo "FAIL"
grep -q "toggleAB" workspace/static/workspace.js && echo "PASS: A/B toggle" || echo "FAIL"
grep -q "navigateShot" workspace/static/workspace.js && echo "PASS: shot navigation" || echo "FAIL"
grep -q "togglePlayPause" workspace/static/workspace.js && echo "PASS: play/pause" || echo "FAIL"
grep -q "keydown" workspace/static/workspace.js && echo "PASS: keyboard handler" || echo "FAIL"
grep -q "inspectorRow" workspace/static/workspace.js && echo "PASS: inspector row helper" || echo "FAIL"
grep -q "toggleInspectorSection" workspace/static/workspace.js && echo "PASS: section toggle" || echo "FAIL"
grep -q "viewTake" workspace/static/workspace.js && echo "PASS: viewTake" || echo "FAIL"
grep -q "prevTake" workspace/static/workspace.js && echo "PASS: prevTake" || echo "FAIL"
grep -q "nextTake" workspace/static/workspace.js && echo "PASS: nextTake" || echo "FAIL"

# Verify no placeholder functions remain
grep -q "Inspector loading" workspace/static/workspace.js && echo "FAIL: placeholder not replaced" || echo "PASS: no placeholders"
```

## Do NOT
- Add WebSocket push (polling is fine for POC)
- Add grid mode or multi-take comparison view
- Add any npm dependencies or build steps
- Modify the HTML or CSS files

---

# Phase 7: Startup Script + Integration Wiring

**Creates:** `workspace/start_workspace.sh`
**Modifies:** nothing
**Depends on:** Phases 2-6

## What Already Exists
- Complete Python backend: `state.py`, `session_log.py`, `mcp_server.py`, `server.py`
- Complete frontend: `index.html`, `workspace.css`, `workspace.js`

## File: workspace/start_workspace.sh

```bash
#!/bin/bash
# ── Recoil Workspace Launcher ──
#
# One-command startup for the Recoil Workspace POC.
#
# Usage:
#   ./workspace/start_workspace.sh [project]
#   ./workspace/start_workspace.sh tartarus
#   ./workspace/start_workspace.sh the-afterimage --port 8450
#
# Port allocation: Pre-Prod=8420, Production Console=8430, Workspace=8450
#
# What it does:
# 1. Checks Python dependencies (fastapi, uvicorn)
# 2. Starts the FastAPI workspace server
# 3. Opens the browser
# 4. Prints MCP configuration instructions
#
# The MCP server (mcp_server.py) is NOT started here — it's started by
# Claude Code via the .claude.json mcpServers configuration.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RECOIL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
PORT="8450"

# FIX (Opus C1): Proper while/shift argument parsing instead of broken
# for-loop with shift_next pattern.
PROJECT=""
PROJECT_SET=""
while [[ $# -gt 0 ]]; do
  case "$1" in
    --port=*) PORT="${1#*=}" ;;
    --port) PORT="$2"; shift ;;
    *) if [ -z "${PROJECT_SET:-}" ]; then PROJECT="$1"; PROJECT_SET=1; fi ;;
  esac
  shift
done
PROJECT="${PROJECT:-tartarus}"

echo "╔══════════════════════════════════════════╗"
echo "║        RECOIL WORKSPACE v0.1.0           ║"
echo "╚══════════════════════════════════════════╝"
echo ""
echo "  Project:  $PROJECT"
echo "  Port:     $PORT"
echo "  Recoil:   $RECOIL_ROOT"
echo ""

# ── Check dependencies ──
echo "[1/4] Checking dependencies..."
python3 -c "import fastapi" 2>/dev/null || {
  echo "  ERROR: FastAPI not installed. Run:"
  echo "    pip install fastapi uvicorn"
  exit 1
}
python3 -c "import uvicorn" 2>/dev/null || {
  echo "  ERROR: uvicorn not installed. Run:"
  echo "    pip install uvicorn"
  exit 1
}
echo "  OK: fastapi, uvicorn installed"

# ── Verify project exists ──
echo "[2/4] Verifying project..."
PROJECTS_ROOT=$(python3 -c "
import sys; sys.path.insert(0, '$RECOIL_ROOT')
from core.paths import PROJECTS_ROOT; print(PROJECTS_ROOT)
")
if [ ! -d "$PROJECTS_ROOT/$PROJECT" ]; then
  echo "  ERROR: Project '$PROJECT' not found at $PROJECTS_ROOT/$PROJECT"
  echo "  Available projects:"
  ls "$PROJECTS_ROOT" | grep -v "^_" | sed 's/^/    /'
  exit 1
fi
echo "  OK: $PROJECTS_ROOT/$PROJECT"

# ── Check for MCP configuration ──
echo "[3/4] MCP configuration..."
MCP_CONFIG="$RECOIL_ROOT/.claude.json"
if [ -f "$MCP_CONFIG" ]; then
  if grep -q "workspace" "$MCP_CONFIG" 2>/dev/null; then
    echo "  OK: workspace MCP server configured in .claude.json"
  else
    echo "  WARN: .claude.json exists but 'workspace' MCP not configured"
    echo "  Add this to .claude.json mcpServers:"
    echo ""
    echo "    \"workspace\": {"
    echo "      \"command\": \"python3\","
    echo "      \"args\": [\"$SCRIPT_DIR/mcp_server.py\"]"
    echo "    }"
    echo ""
  fi
else
  echo "  WARN: No .claude.json found at $RECOIL_ROOT"
  echo "  Create $MCP_CONFIG with:"
  echo ""
  echo "  {"
  echo "    \"mcpServers\": {"
  echo "      \"workspace\": {"
  echo "        \"command\": \"python3\","
  echo "        \"args\": [\"$SCRIPT_DIR/mcp_server.py\"]"
  echo "      }"
  echo "    }"
  echo "  }"
  echo ""
fi

# ── Start server ──
echo "[4/4] Starting workspace server..."
echo ""
echo "  URL: http://127.0.0.1:$PORT/workspace"
echo "  API: http://127.0.0.1:$PORT/api/health"
echo ""
echo "  Press Ctrl+C to stop"
echo ""

# Open browser after a short delay
(sleep 2 && open "http://127.0.0.1:$PORT/workspace" 2>/dev/null) &

# Start the server
cd "$RECOIL_ROOT"
exec python3 -m workspace.server --project "$PROJECT" --port "$PORT"
```

## Validation

```bash
cd /path/to/recoil

# File exists and is executable-ready
test -f workspace/start_workspace.sh && echo "PASS: script exists" || echo "FAIL"

# Make it executable
chmod +x workspace/start_workspace.sh

# Structure checks
grep -q "RECOIL WORKSPACE" workspace/start_workspace.sh && echo "PASS: banner" || echo "FAIL"
grep -q "fastapi" workspace/start_workspace.sh && echo "PASS: dep check" || echo "FAIL"
grep -q "uvicorn" workspace/start_workspace.sh && echo "PASS: uvicorn check" || echo "FAIL"
grep -q "mcpServers" workspace/start_workspace.sh && echo "PASS: MCP config check" || echo "FAIL"
grep -q "mcp_server.py" workspace/start_workspace.sh && echo "PASS: MCP server path" || echo "FAIL"
grep -q "exec python3" workspace/start_workspace.sh && echo "PASS: exec server" || echo "FAIL"
grep -q "while" workspace/start_workspace.sh && echo "PASS: while/shift arg parsing" || echo "FAIL"

# Syntax check (bash)
bash -n workspace/start_workspace.sh && echo "PASS: bash syntax" || echo "FAIL"
```

## Do NOT
- Start the MCP server here (Claude Code starts it via .claude.json)
- Create .claude.json automatically (it may already have other MCP servers)
- Add any service management (systemd, launchd, etc.)
- Add TLS/HTTPS

---

# Phase 8: Acceptance Tests

**Creates:** `workspace/tests/test_workspace.py`
**Modifies:** nothing
**Depends on:** Phases 1-7

## What Already Exists
- Complete workspace codebase in `workspace/`

## File: workspace/tests/test_workspace.py

```python
#!/usr/bin/env python3
"""Acceptance tests for Recoil Workspace POC.

Tests are designed to run without a live server — they test the Python
modules directly. Frontend tests are structural (file existence, content
checks) rather than browser-based.

Run: python3 -m pytest workspace/tests/test_workspace.py -v
"""

import json
import os
import re
import sys
import tempfile
from pathlib import Path

import pytest

# ── Path setup ──────────────────────────────────────────────────
_RECOIL_ROOT = Path(__file__).resolve().parent.parent.parent
if str(_RECOIL_ROOT) not in sys.path:
    sys.path.insert(0, str(_RECOIL_ROOT))


# ── Test: state.py ──────────────────────────────────────────────

class TestWorkspaceState:
    """Test workspace state read/write."""

    def setup_method(self):
        """Use a temp directory for state."""
        self._original_dir = None
        self._original_path = None
        import workspace.state as ws_state
        self._original_dir = ws_state._STATE_DIR
        self._original_path = ws_state._STATE_PATH
        self._tmp = Path(tempfile.mkdtemp())
        ws_state._STATE_DIR = self._tmp
        ws_state._STATE_PATH = self._tmp / "state.json"

    def teardown_method(self):
        import workspace.state as ws_state
        ws_state._STATE_DIR = self._original_dir
        ws_state._STATE_PATH = self._original_path
        import shutil
        shutil.rmtree(self._tmp, ignore_errors=True)

    def test_default_state(self):
        from workspace.state import read_state
        state = read_state()
        assert state["project"] is None
        assert state["selection"] == []
        assert state["viewer"]["shot_id"] is None

    def test_set_project(self):
        from workspace.state import set_project, get_project
        set_project("tartarus")
        assert get_project() == "tartarus"

    def test_selection(self):
        from workspace.state import set_selection, get_selection
        set_selection(["EP001_SH01", "EP001_SH02"])
        assert get_selection() == ["EP001_SH01", "EP001_SH02"]

    def test_viewer_state(self):
        from workspace.state import set_viewer_state, get_viewer_state
        set_viewer_state(
            shot_id="EP001_SH03",
            take_index=2,
            file_path="output/previs/ep_001/shot_003_take2.png",
        )
        viewer = get_viewer_state()
        assert viewer["shot_id"] == "EP001_SH03"
        assert viewer["take_index"] == 2
        assert viewer["media_type"] == "image"  # Auto-detected from .png

    def test_video_media_type_detection(self):
        from workspace.state import set_viewer_state, get_viewer_state
        set_viewer_state(file_path="output/video/shot.mp4")
        assert get_viewer_state()["media_type"] == "video"

    def test_set_project_clears_selection(self):
        from workspace.state import set_project, set_selection, get_selection
        set_selection(["EP001_SH01"])
        set_project("new_project")
        assert get_selection() == []

    def test_dropbox_conflict_detection(self):
        from workspace.state import detect_dropbox_conflicts
        # Create a fake conflict file
        conflict_dir = self._tmp / "shots"
        conflict_dir.mkdir()
        (conflict_dir / "EP001_SH01.json").touch()
        (conflict_dir / "EP001_SH01 (Joe's conflicted copy).json").touch()
        conflicts = detect_dropbox_conflicts(conflict_dir)
        assert len(conflicts) == 1
        assert "conflicted copy" in conflicts[0].lower()


# ── Test: session_log.py ────────────────────────────────────────

class TestSessionLog:
    """Test session log append and read."""

    def setup_method(self):
        self._tmp = Path(tempfile.mkdtemp())

    def teardown_method(self):
        import shutil
        shutil.rmtree(self._tmp, ignore_errors=True)

    def test_append_and_read(self):
        from workspace.session_log import append_entry, read_entries
        entry = append_entry(
            "test_project", self._tmp,
            "feedback",
            shot_id="EP001_SH03",
            take_id="T001",
            data={"text": "looks great", "category": "observation"},
        )
        assert entry["type"] == "feedback"
        assert entry["shot_id"] == "EP001_SH03"

        entries = read_entries("test_project", self._tmp)
        assert len(entries) == 1
        assert entries[0]["type"] == "feedback"

    def test_since_filter(self):
        from workspace.session_log import append_entry, read_entries
        append_entry("test_project", self._tmp, "action", data={"a": 1})
        # Read with a future timestamp should return nothing
        entries = read_entries("test_project", self._tmp, since="9999-12-31T23:59:59Z")
        assert len(entries) == 0

    def test_multiple_entries(self):
        from workspace.session_log import append_entry, read_entries
        for i in range(5):
            append_entry("test_project", self._tmp, "action", data={"i": i})
        entries = read_entries("test_project", self._tmp)
        assert len(entries) == 5


# ── Test: mcp_server.py (tool registry) ────────────────────────

class TestMCPTools:
    """Test that all 12 MCP tools are registered."""

    def test_all_tools_registered(self):
        from workspace.mcp_server import _TOOLS
        expected = [
            "prime_project", "get_selection", "get_viewer_state",
            "show_in_viewer", "get_shot_detail", "get_shot_neighbors",
            "approve_shot", "reject_shot", "submit_generation",
            "log_feedback", "get_session_log", "get_activity",
        ]
        for name in expected:
            assert name in _TOOLS, f"Missing tool: {name}"
        assert len(_TOOLS) == 12

    def test_tools_have_handlers(self):
        from workspace.mcp_server import _TOOLS
        for name, tool in _TOOLS.items():
            assert "handler" in tool, f"Tool {name} missing handler"
            assert callable(tool["handler"]), f"Tool {name} handler not callable"

    def test_tools_have_schemas(self):
        from workspace.mcp_server import _TOOLS
        for name, tool in _TOOLS.items():
            assert "inputSchema" in tool, f"Tool {name} missing inputSchema"
            assert "type" in tool["inputSchema"], f"Tool {name} schema missing 'type'"

    def test_build_tools_list(self):
        from workspace.mcp_server import _build_tools_list
        tools_list = _build_tools_list()
        assert len(tools_list) == 12
        for t in tools_list:
            assert "name" in t
            assert "description" in t
            assert "inputSchema" in t
            assert "handler" not in t  # Handler should not be in the list response


# ── Test: Frontend files ────────────────────────────────────────

class TestFrontend:
    """Structural checks on frontend files."""

    def test_index_html_exists(self):
        path = _RECOIL_ROOT / "workspace" / "static" / "index.html"
        assert path.is_file(), f"index.html not found at {path}"

    def test_workspace_css_exists(self):
        path = _RECOIL_ROOT / "workspace" / "static" / "workspace.css"
        assert path.is_file(), f"workspace.css not found at {path}"

    def test_workspace_js_exists(self):
        path = _RECOIL_ROOT / "workspace" / "static" / "workspace.js"
        assert path.is_file(), f"workspace.js not found at {path}"

    def test_html_links_css(self):
        html = (_RECOIL_ROOT / "workspace" / "static" / "index.html").read_text()
        assert "workspace.css" in html

    def test_html_links_js(self):
        html = (_RECOIL_ROOT / "workspace" / "static" / "index.html").read_text()
        assert "workspace.js" in html

    def test_html_has_viewer(self):
        html = (_RECOIL_ROOT / "workspace" / "static" / "index.html").read_text()
        assert re.search(r'id=["\']viewer["\']', html), "Missing viewer element"

    def test_html_has_file_tree(self):
        html = (_RECOIL_ROOT / "workspace" / "static" / "index.html").read_text()
        assert re.search(r'id=["\']file-tree["\']', html), "Missing file-tree element"

    def test_html_has_inspector(self):
        html = (_RECOIL_ROOT / "workspace" / "static" / "index.html").read_text()
        assert re.search(r'id=["\']inspector["\']', html), "Missing inspector element"

    def test_css_has_design_tokens(self):
        css = (_RECOIL_ROOT / "workspace" / "static" / "workspace.css").read_text()
        assert "--bg-base" in css
        assert "--accent-blue" in css
        assert "--text-primary" in css

    def test_js_has_core_functions(self):
        js = (_RECOIL_ROOT / "workspace" / "static" / "workspace.js").read_text()
        assert "function init" in js
        assert "renderFileTree" in js
        assert "selectShot" in js
        assert "displayInViewer" in js
        assert "renderInspector" in js
        assert "toggleAB" in js
        assert "navigateShot" in js

    def test_js_has_keyboard_handler(self):
        js = (_RECOIL_ROOT / "workspace" / "static" / "workspace.js").read_text()
        assert "keydown" in js
        assert "Escape" in js
        assert "ArrowLeft" in js
        assert "ArrowRight" in js


# ── Test: server.py ─────────────────────────────────────────────

class TestServer:
    """Test that server.py imports and defines routes."""

    def test_server_imports(self):
        from workspace.server import app
        assert app.title == "Recoil Workspace"

    def test_server_has_routes(self):
        from workspace.server import app
        routes = [r.path for r in app.routes if hasattr(r, 'path')]
        assert "/api/health" in routes
        assert "/api/state" in routes
        assert "/api/shots/{project}" in routes
        assert "/api/shot/{project}/{shot_id}" in routes
        assert "/api/activity/{project}" in routes


# ── Test: Startup script ───────────────────────────────────────

class TestStartup:
    """Check startup script exists and is valid."""

    def test_script_exists(self):
        path = _RECOIL_ROOT / "workspace" / "start_workspace.sh"
        assert path.is_file()

    def test_script_is_bash(self):
        content = (_RECOIL_ROOT / "workspace" / "start_workspace.sh").read_text()
        assert content.startswith("#!/bin/bash")

    def test_script_references_server(self):
        content = (_RECOIL_ROOT / "workspace" / "start_workspace.sh").read_text()
        assert "workspace.server" in content or "server.py" in content


# ── Test: Design system ────────────────────────────────────────

class TestDesignSystem:
    """Check design system doc exists."""

    def test_design_system_exists(self):
        path = _RECOIL_ROOT / "workspace" / "DESIGN_SYSTEM.md"
        assert path.is_file()

    def test_design_system_has_colors(self):
        content = (_RECOIL_ROOT / "workspace" / "DESIGN_SYSTEM.md").read_text()
        assert "Color Palette" in content
        assert "#08080f" in content or "#0e0e18" in content
```

Create the tests directory:

```bash
mkdir -p workspace/tests
touch workspace/tests/__init__.py
```

## Validation

```bash
cd /path/to/recoil

# Files exist
test -f workspace/tests/__init__.py && echo "PASS: tests __init__.py" || echo "FAIL"
test -f workspace/tests/test_workspace.py && echo "PASS: test file exists" || echo "FAIL"

# Syntax check
python3 -c "import ast; ast.parse(open('workspace/tests/test_workspace.py').read()); print('PASS: test syntax')"

# Run the tests
python3 -m pytest workspace/tests/test_workspace.py -v --tb=short 2>&1 | tail -30

# Count test classes
grep -c "class Test" workspace/tests/test_workspace.py | xargs -I{} echo "PASS: {} test classes"

# Count test methods
grep -c "def test_" workspace/tests/test_workspace.py | xargs -I{} echo "PASS: {} test methods"
```

## Do NOT
- Add integration tests that require a running server
- Add browser automation tests (Playwright, Selenium)
- Modify any source files

---

# Post-Build Verification

After all 8 phases complete, run this final check:

```bash
cd /path/to/recoil

echo "=== Recoil Workspace POC — Post-Build Verification ==="
echo ""

# File manifest
echo "── File Manifest ──"
for f in \
  workspace/__init__.py \
  workspace/DESIGN_SYSTEM.md \
  workspace/state.py \
  workspace/session_log.py \
  workspace/mcp_server.py \
  workspace/server.py \
  workspace/static/index.html \
  workspace/static/workspace.css \
  workspace/static/workspace.js \
  workspace/start_workspace.sh \
  workspace/tests/__init__.py \
  workspace/tests/test_workspace.py; do
  test -f "$f" && echo "  OK $f" || echo "  MISSING $f"
done

echo ""
echo "── Python Syntax ──"
for f in workspace/__init__.py workspace/state.py workspace/session_log.py workspace/mcp_server.py workspace/server.py workspace/tests/test_workspace.py; do
  python3 -c "import ast; ast.parse(open('$f').read())" 2>&1 && echo "  OK $f" || echo "  FAIL $f"
done

echo ""
echo "── MCP Tools ──"
python3 -c "
import sys; sys.path.insert(0, '.')
from workspace.mcp_server import _TOOLS
print(f'  {len(_TOOLS)} tools registered')
for name in sorted(_TOOLS.keys()):
    print(f'    - {name}')
"

echo ""
echo "── Tests ──"
python3 -m pytest workspace/tests/test_workspace.py -v --tb=short 2>&1 | tail -40

echo ""
echo "── Line Count ──"
wc -l workspace/*.py workspace/static/* workspace/start_workspace.sh workspace/tests/*.py 2>/dev/null | tail -1

echo ""
echo "── Startup Check ──"
bash -n workspace/start_workspace.sh && echo "  OK: bash syntax valid" || echo "  FAIL: bash syntax"
chmod +x workspace/start_workspace.sh

echo ""
echo "=== Verification Complete ==="
```

---

# Summary

| Phase | Files | LOC (est.) | What |
|-------|-------|-----------|------|
| 1 | DESIGN_SYSTEM.md | ~120 | Design tokens, component patterns |
| 2 | __init__.py, state.py, session_log.py | ~250 | Shared state + session logging |
| 3 | mcp_server.py (COMPLETE) | ~750 | MCP scaffold + all 12 tools + JSON-RPC handler |
| 4 | server.py | ~300 | FastAPI server |
| 5 | index.html, workspace.css | ~500 | HTML shell + all CSS |
| 6 | workspace.js (COMPLETE) | ~600 | Core, file browser, activity, inspector, viewer, A/B, keyboard |
| 7 | start_workspace.sh | ~80 | Startup script |
| 8 | tests/test_workspace.py | ~250 | Acceptance tests |
| **Total** | **12 files** | **~2,850** | |

## Spec Review Fixes Applied

| Fix | Severity | Source | What Changed |
|-----|----------|--------|-------------|
| A | STRUCTURAL | Both reviewers | Phases 3-6 collapsed into single Phase 3 (complete mcp_server.py) |
| B | STRUCTURAL | Both reviewers | Phases 9-10 collapsed into single Phase 6 (complete workspace.js) |
| C | CRITICAL | Opus C2 | pollShots() captures oldEpisodes before overwriting WS.episodes |
| D | CRITICAL | Opus C1 | start_workspace.sh uses while/shift arg parsing instead of broken for loop |
| E | CRITICAL | Opus C3 | /api/shot response includes attempts, max_attempts, coverage_of |
| F | HIGH | Both reviewers | approve_shot uses single atomic store.update_shot() call |
| G | HIGH | Both reviewers | submit_generation sets generation_requested field on shot |
| H | HIGH | Opus H5 | reject_shot returns error when take_id not found |
| I | MEDIUM | Gemini #5 | viewTake() uses DOM class toggling instead of full re-render |
| J | MEDIUM | Gemini #8 | navigateShot() walks all episodes, auto-expands collapsed targets |
