# BUILD_SPEC — compare_in_viewer MCP Tool

**Generated:** 2026-04-18
**Input:** conversation design (approved by JT)
**Detail level:** max
**Visual design:** no (uses existing footer "N/M" indicator + existing arrow-key delegation)
**Phases:** 2
**Estimated build time:** 25-40 min
**Review status:** PASSED — Opus 2026-04-19T00:00:00Z (2 critical fixes applied: init() old_string realigned to actual workspace.js, pollState compare-exit clear branch added; 4 significant fixes: set_project mutable-default aliasing patched, validation gate strengthened with core-contract greps, grep count anchored to comment pattern, MCP restart pre-step added to live check; 6 minor fixes: type hints tightened, intentional asymmetries documented, dedup + R-M-W race added to out-of-scope)
**Adversarial review:** NOT RUN

## Validation command

```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/recoil && python3 -c "import ast; ast.parse(open('workspace/state.py').read()); ast.parse(open('workspace/server.py').read()); ast.parse(open('workspace/mcp_server.py').read())" && echo "syntax OK"
```

## Dependency Graph

```
Phase 1 (Backend: state + endpoint + MCP tool): none
Phase 2 (Frontend wiring):                       depends_on 1
```

## Summary

Add a `compare_in_viewer` MCP tool that accepts 2+ file paths and puts the workspace viewer into A/B toggle mode. Arrow keys cycle between items. Reuses existing frontend multi-select machinery (`WS.selection` + `selectionIndex` + `prevSelection`/`nextSelection`) — the only missing bridge is a way for the MCP to populate `WS.selection` from outside the sidebar click path. Solution: add `compare_paths` + `compare_version` to viewer state, version-counter guards against re-applying on every 2-second poll.

## Context: Why this design is minimal

- `prevTake()` already delegates to `prevSelection()` when `WS.selection.length > 1` (workspace.js line 1981)
- `updateTakeNav()` already renders "N/M" label for multi-select (workspace.js line 1899)
- `_showSelectionItem()` already POSTs viewer state + calls `displayInViewer()` (line 2060)
- `set_project()` already resets viewer to defaults on project switch (state.py line 122)
- `WS.selection` stores **project-relative** paths (no project prefix); `viewer.file_path` stores **PROJECTS_ROOT-relative** paths (with prefix) — the MCP tool writes the former into `compare_paths` and the latter into `file_path`

## Contract between backend and frontend

- `viewer.compare_paths`: `list[str]` of **project-relative** paths (e.g. `output/refs/a.png`, NOT `tartarus/output/refs/a.png`). Matches existing `WS.selection` format on frontend. Empty list = not in compare mode.
- `viewer.compare_version`: monotonic int. MCP writes bump it; frontend applies only when it changes.
- Any frontend `/api/state/viewer` POST that represents entering single-file mode MUST include `compare_paths: []` to clear compare mode on backend. Arrow-nav POSTs within compare mode (`_showSelectionItem`) MUST omit `compare_paths` to preserve it.

---

## Phase 1: Backend — state schema + endpoint + MCP tool

engine: claude
model: sonnet
depends_on: none

### Files to modify

- `recoil/workspace/state.py` — add `compare_paths`/`compare_version` to default viewer, extend `set_viewer_state`
- `recoil/workspace/server.py` — plumb `compare_paths` + `bump_compare_version` through `/api/state/viewer`
- `recoil/workspace/mcp_server.py` — add `compare_in_viewer` tool; extend `show_in_viewer` to clear compare mode

### Scope boundary

- Do NOT modify any other MCP tools beyond `show_in_viewer`'s two-line clear.
- Do NOT change the viewer state schema shape beyond adding the two new fields.
- Do NOT add authentication, rate limiting, or path-allowlist logic beyond what already exists.
- Do NOT touch `pipeline/`, `projects/`, or any non-workspace directory.

### Intentional asymmetries (documented, not bugs)

- **`show_in_viewer` preserves `context` on same-file repush.** If compare mode had `context="A/B test"` and `show_in_viewer(same_file)` is called with no context, the prior annotation lingers. Existing RISK-7 branch only clears context when file_path changes. Callers can pass `context=""` to force-clear. Not changed by this spec.
- **`tool_compare_in_viewer` calls `path.resolve()` before the PROJECTS_ROOT check; `tool_show_in_viewer` does not.** The new tool is stricter (symlinks escaping the project are rejected). This asymmetry is intentional — do not retro-tighten `show_in_viewer` in this spec. If desired, address in a follow-up.
- **`compare_in_viewer` return value `media_type` reflects only the first (displayed) path.** Mixed image/video compare is supported; the return's `media_type` is informational for the initial display only.

### Exact implementation

**1. `recoil/workspace/state.py` — add two fields to `_DEFAULT_STATE["viewer"]`:**

Replace the `_DEFAULT_STATE` block (currently at lines 37-49) with:

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

**2. `recoil/workspace/state.py` — extend `set_viewer_state` signature and body:**

Replace the `set_viewer_state` function (currently at lines 148-180) with:

```python
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,
    compare_paths: Optional[list[str]] = None,
    bump_compare_version: bool = False,
) -> None:
    """Update the viewer state.

    compare_paths semantics:
      - None → leave unchanged (arrow-nav and other viewer updates use this)
      - [] → explicitly clear compare mode (single-file pushes use this)
      - [p1, p2, ...] → enter/refresh compare mode with these paths

    bump_compare_version: increment compare_version counter. Set True when entering
    a fresh compare (MCP compare_in_viewer). The frontend watches this counter and
    re-syncs WS.selection only when it changes.
    """
    state = read_state()
    viewer = state.get("viewer", {})
    # RISK-7 fix: when file_path changes, reset stale fields
    if file_path is not None and file_path != viewer.get("file_path"):
        viewer["shot_id"] = None
        viewer["take_index"] = None
        viewer["context"] = None
    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
    if compare_paths is not None:
        viewer["compare_paths"] = list(compare_paths)
    if bump_compare_version:
        viewer["compare_version"] = int(viewer.get("compare_version", 0)) + 1
    state["viewer"] = viewer
    write_state(state)
```

**2b. `recoil/workspace/state.py` — patch `set_project` to avoid mutable-default aliasing:**

The existing `set_project` (lines 122-128) does `state["viewer"] = dict(_DEFAULT_STATE["viewer"])` — a shallow copy that aliases the inner `compare_paths: []` list to the module-level default. Today nothing mutates this list in place (the new `set_viewer_state` reassigns via `list(compare_paths)`), but belt-and-suspenders: replace the function body with an explicit fresh dict.

Replace:

```python
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)
```

With:

```python
def set_project(project: str) -> None:
    """Set the active project."""
    state = read_state()
    state["project"] = project
    state["selection"] = []
    state["viewer"] = {
        "shot_id": None,
        "take_index": None,
        "file_path": None,
        "media_type": None,
        "context": None,
        "compare_paths": [],
        "compare_version": 0,
    }
    write_state(state)
```

Also update the module docstring (lines 8-20) to include the two new fields in the schema example:

```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",
        "compare_paths": [],
        "compare_version": 0
    },
    "browse_tab_active": true,
    "updated_at": "2026-04-12T01:30:00Z"
}
"""
```

**3. `recoil/workspace/server.py` — plumb new fields through `/api/state/viewer`:**

Replace the `update_viewer` endpoint (currently at lines 152-162) with:

```python
@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"),
        compare_paths=body.get("compare_paths"),
        bump_compare_version=bool(body.get("bump_compare_version", False)),
    )
    return JSONResponse(ws_state.get_viewer_state())
```

**4. `recoil/workspace/mcp_server.py` — extend `show_in_viewer` to clear compare mode:**

Locate `tool_show_in_viewer` (currently at lines 495-542). Replace the `ws_state.set_viewer_state(...)` call (currently lines 520-523) with:

```python
    ws_state.set_viewer_state(
        file_path=rel_path,
        context=context,
        compare_paths=[],  # single-file push → clear compare mode
    )
```

**5. `recoil/workspace/mcp_server.py` — add new `compare_in_viewer` tool:**

Add the following block immediately after `tool_show_in_viewer` (after line 542, before the `━━━━ ACTION TOOLS ━━━━` banner at ~line 545):

```python

# ── Tool 5: compare_in_viewer ─────────────────────────────────────

@_register_tool(
    name="compare_in_viewer",
    description=(
        "Push two or more files (images or videos) into the workspace viewer in "
        "compare mode. The user toggles between them with the ← / → arrow keys. "
        "Use this to A/B test takes, compare reference vs. render, or cycle through "
        "a small set of candidates. For single-file display use show_in_viewer. "
        "All paths must be under PROJECTS_ROOT and under the active project."
    ),
    input_schema={
        "type": "object",
        "properties": {
            "paths": {
                "type": "array",
                "items": {"type": "string"},
                "minItems": 2,
                "description": (
                    "List of 2+ file paths to compare. Each path can be absolute "
                    "or relative to project root. All files must exist under the "
                    "active project (no cross-project compare)."
                ),
            },
            "context": {
                "type": "string",
                "description": "Optional annotation (e.g. 'Comparing take 2 vs take 3 framing').",
            },
        },
        "required": ["paths"],
    },
)
def tool_compare_in_viewer(params: dict) -> dict:
    project = ws_state.get_project()
    if not project:
        return {"error": "No project active. Call prime_project first."}

    paths = params.get("paths", [])
    context = params.get("context")

    if not isinstance(paths, list) or len(paths) < 2:
        return {"error": "paths must be a list with at least 2 items"}

    # Resolve each path and compute both forms:
    #   full_rel_paths     — relative to PROJECTS_ROOT, includes project prefix
    #                        (for viewer.file_path on backend, matches show_in_viewer format)
    #   project_rel_paths  — relative to the project directory, NO project prefix
    #                        (for viewer.compare_paths, matches WS.selection on frontend)
    project_rel_paths: list[str] = []
    full_rel_paths: list[str] = []
    projects_root_resolved = PROJECTS_ROOT.resolve()
    for p in paths:
        if not isinstance(p, str) or not p:
            return {"error": f"Invalid path: {p!r}"}
        abs_path = p if p.startswith("/") else str(PROJECTS_ROOT / project / p)
        path_obj = Path(abs_path)
        if not path_obj.is_file():
            return {"error": f"File not found: {abs_path}"}
        try:
            full_rel = path_obj.resolve().relative_to(projects_root_resolved)
        except ValueError:
            return {"error": f"Path outside PROJECTS_ROOT: {abs_path}"}
        full_rel_str = str(full_rel)
        parts = full_rel.parts
        if not parts or parts[0] != project:
            return {"error": f"Path not under active project '{project}': {full_rel_str}"}
        if len(parts) < 2:
            return {"error": f"Path resolves to project root, not a file: {full_rel_str}"}
        project_rel = str(Path(*parts[1:]))
        project_rel_paths.append(project_rel)
        full_rel_paths.append(full_rel_str)

    # Write state: first file displayed, all paths in compare array, version bumps.
    ws_state.set_viewer_state(
        file_path=full_rel_paths[0],
        compare_paths=project_rel_paths,
        context=context,
        bump_compare_version=True,
    )

    # Log to session
    session_log.append_entry(
        project, PROJECTS_ROOT, "action",
        data={
            "action": "compare_in_viewer",
            "paths": project_rel_paths,
            "context": context,
        },
    )

    viewer = ws_state.get_viewer_state()
    return {
        "displayed": full_rel_paths[0],
        "compare_paths": project_rel_paths,
        "compare_version": viewer.get("compare_version"),
        "media_type": viewer.get("media_type"),
        "count": len(project_rel_paths),
        "context": context,
    }
```

### Validation

```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/recoil && \
python3 -c "import ast; ast.parse(open('workspace/state.py').read())" && \
python3 -c "import ast; ast.parse(open('workspace/server.py').read())" && \
python3 -c "import ast; ast.parse(open('workspace/mcp_server.py').read())" && \
grep -q 'compare_paths' workspace/state.py && \
grep -q 'compare_version' workspace/state.py && \
grep -q 'bump_compare_version' workspace/state.py && \
grep -q 'compare_paths' workspace/server.py && \
grep -q 'bump_compare_version' workspace/server.py && \
grep -q 'tool_compare_in_viewer' workspace/mcp_server.py && \
grep -q 'name="compare_in_viewer"' workspace/mcp_server.py && \
grep -q 'compare_paths=\[\]' workspace/mcp_server.py && \
grep -q 'compare_paths=project_rel_paths' workspace/mcp_server.py && \
grep -q 'bump_compare_version=True' workspace/mcp_server.py && \
python3 -c "
import sys
sys.path.insert(0, '.')
from workspace import state as s
# Verify default shape
d = s._DEFAULT_STATE['viewer']
assert 'compare_paths' in d, 'compare_paths missing from default'
assert 'compare_version' in d, 'compare_version missing from default'
assert d['compare_paths'] == [], 'compare_paths default not empty list'
assert d['compare_version'] == 0, 'compare_version default not 0'
# Verify set_viewer_state accepts new params without error
import inspect
sig = inspect.signature(s.set_viewer_state)
assert 'compare_paths' in sig.parameters, 'set_viewer_state missing compare_paths param'
assert 'bump_compare_version' in sig.parameters, 'set_viewer_state missing bump_compare_version param'
print('backend state schema OK')
" && \
echo "Phase 1 OK"
```

---

## Phase 2: Frontend wiring — poll sync + init capture + single-file clear

engine: claude
model: sonnet
depends_on: 1

### Files to modify

- `recoil/workspace/static/workspace.js` — add `_lastCompareVersion` module var; extend `pollState` to sync compare_paths on version bump; extend `init` to capture initial version + restore compare mode if active; augment four single-file POST sites to clear compare mode

### What already exists (from Phase 1)

- Backend state has `viewer.compare_paths: list[str]` (project-relative paths) and `viewer.compare_version: int` (monotonic counter)
- `/api/state/viewer` POST accepts `compare_paths` (None/omit = unchanged, [] = clear, [...] = set) and `bump_compare_version: bool`
- `compare_in_viewer` MCP tool writes: `file_path = full-relative first path`, `compare_paths = [project-relative paths]`, bumps version
- `show_in_viewer` MCP tool writes: `compare_paths = []` (clears)

### What already works on the frontend (verify before building — DO NOT re-implement)

- `prevTake()` (line 1981) and `nextTake()` (line 1992) already delegate to `prevSelection()`/`nextSelection()` when `WS.selection.length > 1` — arrow-key A/B toggle works automatically once `WS.selection` is populated
- `updateTakeNav()` (line 1899) already renders "idx+1/total" label and sets prev/next button disabled state for multi-select
- `_showSelectionItem(idx)` (line 2060) already sets `WS.viewerState`, POSTs `/api/state/viewer` (without `compare_paths`, so backend leaves it untouched), and calls `displayInViewer()` — perfect for arrow-nav within compare

### Scope boundary

- Do NOT modify arrow key handler (line 2126-2152), `prevTake`/`nextTake` (lines 1980-2000), `prevSelection`/`nextSelection` (lines 2048-2058), `_showSelectionItem` (line 2060), or `updateTakeNav` (line 1899) — these already work as-is.
- Do NOT add any new UI elements (no A/B label — the existing "N/M" indicator is what we want).
- Do NOT change `/api/state/selection` or `WS.selection` semantics beyond what this phase adds.
- Do NOT alter `displayInViewer`, `hideViewer`, or media URL resolution.

### Exact implementation

**1. Add `_lastCompareVersion` near the existing `_lastViewerUrl` declaration:**

Locate `var _lastViewerUrl = null;` (currently at line 1835). Add immediately after:

```javascript
var _lastCompareVersion = null;
```

**2. Extend `init()` to capture initial compare state.**

Locate the existing init block (workspace.js lines 64-73):

```javascript
    WS.project = state.project;
    WS.selection = state.selection || [];
    WS.viewerState = state.viewer || null;
    updateTopbar();
    await loadProjectList();
    await loadShots();
    if (WS.viewerState && WS.viewerState.file_path) {
      displayInViewer(WS.viewerState);
    }
    if (WS.selection.length === 1) {
      await loadShotDetail(WS.selection[0]);
    }
```

Replace with:

```javascript
    WS.project = state.project;
    WS.selection = state.selection || [];
    WS.viewerState = state.viewer || null;
    WS.selectionIndex = 0;

    // Capture initial compare version so the next pollState doesn't re-apply
    // a compare we've already rendered (or intentionally moved past).
    if (state.viewer && typeof state.viewer.compare_version === 'number') {
      _lastCompareVersion = state.viewer.compare_version;
    }

    // If compare mode was active on the backend (compare_paths has 2+ items),
    // seed WS.selection so arrow keys work immediately after page load.
    if (state.viewer && Array.isArray(state.viewer.compare_paths)
        && state.viewer.compare_paths.length >= 2) {
      WS.selection = state.viewer.compare_paths.slice();
      WS.selectionIndex = 0;
    }

    updateTopbar();
    await loadProjectList();
    await loadShots();
    if (WS.viewerState && WS.viewerState.file_path) {
      displayInViewer(WS.viewerState);
    }
    if (WS.selection.length === 1) {
      await loadShotDetail(WS.selection[0]);
    }
```

Note: this also initializes `WS.selectionIndex = 0` in the happy-path branch (currently only initialized elsewhere — line 814, 990, 1307 — but not here). That's a pre-existing mild gap being cleaned up incidentally.

**3. Extend `pollState()` to sync compare mode on version bump.**

Locate `pollState` (currently at line 392-405). Replace the whole function with:

```javascript
// BUG-4 fix: poll state for MCP viewer updates
async function pollState() {
  if (!WS.project) return;
  const state = await apiGet('/api/state');
  if (!state || !state.viewer) return;

  const v = state.viewer;
  const current = WS.viewerState;
  // If MCP pushed a new file to the viewer, update
  if (v.file_path && (!current || v.file_path !== current.file_path)) {
    WS.viewerState = v;
    displayInViewer(v);
  }

  // If MCP pushed a new compare request (version bumped), sync WS.selection.
  // Using a monotonic version counter (not array equality) means repeated
  // compare_in_viewer calls with the SAME paths still re-apply (desired), and
  // local user clicks that clear WS.selection without bumping the backend
  // version are not clobbered by the poll.
  if (typeof v.compare_version === 'number'
      && v.compare_version !== _lastCompareVersion) {
    _lastCompareVersion = v.compare_version;
    if (Array.isArray(v.compare_paths) && v.compare_paths.length >= 2) {
      WS.selection = v.compare_paths.slice();
      WS.selectionIndex = 0;
      _showSelectionItem(0);
    }
  }

  // Backend cleared compare mode (e.g. show_in_viewer from another Claude
  // session, or a single-file POST whose state has propagated). Drop any
  // multi-item WS.selection that originated from compare so arrow keys don't
  // cycle through stale paths. Guard: only clear if the backend's current
  // file_path is NOT one of our WS.selection entries (in which case we're
  // likely mid-arrow-nav inside compare and must preserve).
  if (Array.isArray(v.compare_paths) && v.compare_paths.length === 0
      && WS.selection.length >= 2) {
    const current = v.file_path || '';
    const stillMatches = WS.selection.some(function(p) { return current.endsWith(p); });
    if (!stillMatches) {
      WS.selection = [];
      WS.selectionIndex = 0;
    }
  }
}
```

**4. Augment four single-file POST sites to clear compare mode on backend.**

Four locations POST `/api/state/viewer` to enter single-file viewing. Each must add `compare_paths: []` to its `viewerData` payload so the backend exits compare mode. (The fifth POST site at line 2072 inside `_showSelectionItem` MUST NOT be touched — it needs to preserve `compare_paths`.)

**4a. `selectFile` — user clicks single file in tree (around line 1270-1276):**

Locate this block:
```javascript
    const viewerData = {
      shot_id: fileNode.shot_id || selectedPath,
      take_index: 0,
      file_path: mediaUrl,
      media_type: detectMediaType(mediaUrl),
    };
    await apiPost('/api/state/viewer', viewerData);
    WS.viewerState = viewerData;
    displayInViewer(viewerData);
```

Replace the `viewerData` object construction with:
```javascript
    const viewerData = {
      shot_id: fileNode.shot_id || selectedPath,
      take_index: 0,
      file_path: mediaUrl,
      media_type: detectMediaType(mediaUrl),
      compare_paths: [],  // single-file click exits compare mode
    };
```

**4b. `selectShot` — user clicks a shot (around line 1318-1324):**

Locate this block:
```javascript
  const viewerData = {
    shot_id: firstTake.shot_id,
    take_index: 0,
    file_path: firstTake.media_url,
    media_type: detectMediaType(firstTake.media_url),
  };
  await apiPost('/api/state/viewer', viewerData);
```

Replace the `viewerData` with:
```javascript
  const viewerData = {
    shot_id: firstTake.shot_id,
    take_index: 0,
    file_path: firstTake.media_url,
    media_type: detectMediaType(firstTake.media_url),
    compare_paths: [],  // selecting a shot exits compare mode
  };
  await apiPost('/api/state/viewer', viewerData);
```

**4c. `viewTake` — take nav within a shot (around line 1961-1969):**

Locate this block:
```javascript
  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);
```

Replace the `viewerData` with:
```javascript
  const viewerData = {
    shot_id: WS.shotDetail.shot_id,
    take_index: index,
    file_path: path,
    media_type: detectMediaType(path),
    compare_paths: [],  // take nav exits compare mode
  };

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

**4d. Shot node take nav (around line 2028-2036):**

Locate this block:
```javascript
  const viewerData = {
    shot_id: shotNode.shot_id || shotNode.name,
    take_index: index,
    file_path: mediaUrl,
    media_type: detectMediaType(mediaUrl),
  };

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

Replace the `viewerData` with:
```javascript
  const viewerData = {
    shot_id: shotNode.shot_id || shotNode.name,
    take_index: index,
    file_path: mediaUrl,
    media_type: detectMediaType(mediaUrl),
    compare_paths: [],  // shot take nav exits compare mode
  };

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

### Validation

```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/recoil && \
node --check workspace/static/workspace.js 2>/dev/null || node -e "new Function(require('fs').readFileSync('workspace/static/workspace.js', 'utf8'))" && \
grep -q '_lastCompareVersion' workspace/static/workspace.js && \
grep -q 'compare_version !== _lastCompareVersion' workspace/static/workspace.js && \
grep -q 'compare_paths.slice()' workspace/static/workspace.js && \
grep -q 'compare_paths: \[\],  //' workspace/static/workspace.js && \
[ $(grep -c 'compare_paths: \[\],  //' workspace/static/workspace.js) -eq 4 ] && \
grep -q 'compare_paths.length === 0' workspace/static/workspace.js && \
echo "Phase 2 OK — found 4 single-file clear sites, poll sync (bump + exit), and init capture"
```

**Live integration check** (run after Phase 2 passes validation; requires workspace server running on Studio via Tailscale at `http://100.105.59.118:8450`):

**Pre-step:** Restart the workspace MCP server (or reconnect the MCP client) so the newly registered `compare_in_viewer` tool becomes visible to Claude Code. In a Claude Code session, type `/mcp` and confirm the `workspace` server lists `compare_in_viewer` among its tools. If not listed, kill and re-spawn the MCP server process.

```bash
# 1. Verify health
curl -s http://100.105.59.118:8450/api/health | grep -q '"status":"ok"' && echo "server OK"

# 2. Write two test file paths directly to state via the API (simulates MCP tool)
# (Pick any two real files under projects/tartarus/ — script left as manual step)
# JT should then:
#   a) Open http://100.105.59.118:8450/workspace in a browser
#   b) Run compare_in_viewer via the workspace MCP from any Claude Code session
#   c) Confirm first image appears in viewer, footer shows "1/2"
#   d) Press → arrow key, confirm second image appears, footer shows "2/2"
#   e) Press ← arrow key, confirm first image appears again
#   f) Click any single file in the sidebar, confirm footer loses "N/M" indicator
#   g) Reload the page — compare mode should NOT restore (because step f cleared it)
#   h) Run compare_in_viewer again, then run show_in_viewer(single) from Claude — compare mode should exit, arrow keys should revert to take nav
```

---

## Testing Strategy

Phase 1: unit-style Python verification via inline script in validation block. Checks schema defaults, function signatures, and grep-based presence of required identifiers.

Phase 2: JS syntax check + grep for required identifiers + count of four clear sites. Live integration is a manual smoke test (documented above) — not automated because it requires browser + live server coordination.

The `/bugfix` pass after Phase 2 will exercise the full round-trip via actual tool invocation.

---

## Rollback

All changes are additive on the backend (new fields, new tool, new params with defaults). Frontend changes are surgical — reverting any one of the 6 JS edits independently is safe because each is a local augmentation.

Clean rollback command (if ever needed):
```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS && git checkout -- recoil/workspace/state.py recoil/workspace/server.py recoil/workspace/mcp_server.py recoil/workspace/static/workspace.js
```

Existing `state.json` files on disk gracefully absorb the new schema because `read_state()` performs a deep merge of saved data into `_fresh_default()` — missing `compare_paths`/`compare_version` fields get the defaults (empty list, 0).

---

## Out of scope (explicit deferrals)

- Triple-panel simultaneous view (showing A and B side-by-side rather than toggling) — future feature, not needed for the "JT toggles with arrows" use case
- Named labels per compare slot (e.g. `{"A": "path/a.png", "B": "path/b.png"}`) — order in `paths` array is sufficient
- Cross-project compare — deliberately rejected; MCP validates all paths are under active project
- Auto-clearing compare mode after N minutes of inactivity — not requested, adds complexity
- Visual difference overlay (XOR, blend modes) — separate feature; this spec is pure A/B toggle
- Duplicate-path dedup — `paths=[a,a]` is implementation-allowed (footer shows "1/2", "2/2" on the same image) but not actively tested; if behavior needs to change, do it in a follow-up
- Cross-process read-modify-write atomicity for `set_viewer_state` — the existing `fcntl.flock` protects individual writes but not R-M-W cycles between MCP and frontend. Window is small (polls are 2s apart) and pre-existing; address via an in-function flock spanning read→mutate→write if observed in practice
