# BUILD_SPEC — Console v2 Per-Project Mode

**Generated:** 2026-05-15
**Input synthesis:** `/Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/consultations/recoil/console-v2-per-project-2026-05-14/SYNTHESIS.md`
**Detail level:** max
**Visual design:** no — minor additions, follow existing patterns (tokens.css, lineage.css density, Blender-utilitarian aesthetic)
**Phase count:** 5
**Baseline HEAD:** `044b05c6`
**Rollback tag:** `rollback/pre-console-v2-per-project`

---

## Validation command

The harness runs this composite gate after every phase. Phase-local validation extends it with phase-specific greps and tests.

```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS
pytest recoil/api/tests/ -x \
  && pnpm --filter @recoil/contracts codegen:check \
  && pnpm --filter @recoil/desktop typecheck \
  && pnpm --filter @recoil/desktop test
```

Baseline counts at HEAD `044b05c6`:

- `pytest recoil/api/tests/` → **233 passing**
- `pnpm --filter @recoil/desktop test` → **110 passing**
- `pnpm --filter @recoil/contracts codegen:check` → clean
- `pnpm --filter @recoil/desktop typecheck` → clean

Every phase must end at or above those counts (new tests add; nothing regresses).

---

## Dependency Graph

```
Phase 1 (Foundation & ttyd): none
Phase 2 (Recents API): none (parallel with Phase 1)
Phase 3 (API Strictness): depends_on 1
Phase 4 (UI Shell): depends_on 1, 2, 3
Phase 5 (Audit Polish): depends_on 4
```

Each phase header below carries a `depends_on:` directive in its first five lines.

---

## Pre-flight Extraction: none — this build does not modify any config files.

No edits to `.claude/settings*.json`, `.mcp.json`, `CLAUDE.md`, `.env`, `keybindings.json`, or any harness/permissions config. This is pure code.

---

## Pre-build setup (Step 0 — harness runs before Phase 1)

```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS
git tag rollback/pre-console-v2-per-project
git push studio refs/tags/rollback/pre-console-v2-per-project
```

The push to Studio is mandatory per the `feedback-dispatch-push-tags` memory — without it, Studio's pre-flight halts when the harness asks for the tag.

---

## Phase 1: Foundation & ttyd Lifecycle
depends_on: none
engine: claude
estimated_minutes: 25
approximate_LOC: ~180
quality_gates: bugfix, simplify

### Goal

Per-project expanded-node state in the contracts schema, cold-start `focusedProjectId` from localStorage with abort-on-switch wiring, and ttyd kill-and-respawn with port-busy recovery and SIGTERM→SIGKILL escalation.

### Files (touched)

#### 1a. `recoil/console-v2/packages/contracts/src/manual.ts`

Bump `SCHEMA_VERSION` from `1` to `2` and add a new `expanded` field to `WorkspaceStateSchema` to carry per-project expanded node IDs.

Replace the existing `SCHEMA_VERSION` constant (line 14):

```typescript
export const SCHEMA_VERSION = 2 as const;
```

Replace the existing `WorkspaceStateSchema` definition (lines 127-138):

```typescript
// ── Workspace state (top-level envelope inside payload_json) ─────────────
export const WorkspaceStateSchema = z.object({
  schemaVersion: z.literal(SCHEMA_VERSION),
  columns: ColumnLayoutSchema,
  tabs: TabStateSchema,
  tweaks: TweaksSchema,
  viewports: z.record(z.string(), ViewportPositionSchema),
  parkedContexts: z.record(z.string(), ParkedContextSchema),
  // Optional — pre-existing workspace blobs from before 2026-05-12 won't have
  // it; defaulted by parseWorkspaceState below.
  inspectorSplit: InspectorSplitSchema.optional(),
  // Per-project expanded hierarchy node ids. Keyed by projectId; each value
  // is the set of expanded node ids (project / episode / scene / beat) for
  // that project. Defaults to {} so pre-existing payloads still parse.
  expanded: z.record(z.string(), z.array(z.string())).default({}),
});
export type WorkspaceState = z.infer<typeof WorkspaceStateSchema>;
```

Update `DEFAULT_WORKSPACE_STATE` (lines 146-159) to carry the new field and the new schema version:

```typescript
export const DEFAULT_WORKSPACE_STATE: WorkspaceState = {
  schemaVersion: SCHEMA_VERSION,
  columns: { schemaVersion: SCHEMA_VERSION, nav: 260, chat: 360 },
  tabs: {
    schemaVersion: SCHEMA_VERSION,
    parked: [],
    active: null,
    currentTemplate: "TakesBrowser",
  },
  tweaks: DEFAULT_TWEAKS,
  viewports: {},
  parkedContexts: {},
  inspectorSplit: DEFAULT_INSPECTOR_SPLIT,
  expanded: {},
};
```

Every nested schema that uses `z.literal(SCHEMA_VERSION)` continues to compose against the new constant value (`2`) automatically — there are no other call sites that hardcode `1` for these nested shapes. Existing persisted blobs (`schemaVersion: 1`) will fail `parseWorkspaceState` and route through `SessionRestoreModal` exactly as designed. This is intentional — JT was warned about the migration in the synthesis.

After editing, regenerate codegen:

```bash
pnpm --filter @recoil/contracts codegen
```

#### 1b. `recoil/console-v2/packages/desktop/src/App.tsx`

Three changes:

(i) Replace the `expanded` useState (line 86) with derived per-project access on top of the persisted `ws.expanded` record. Add an `AbortController` ref. Add the localStorage cold-start.

After the existing `const [focusedProjectId, setFocusedProjectId] = useState<string | null>(null);` line, **delete** the `const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());` line (line 86).

Add this block in its place:

```typescript
  // Abort controller for in-flight fetches. Aborted on every project change so
  // stale getTakes / getLineage / getRecent responses can't land into the new
  // project's UI. New AbortController is assigned per change so each generation
  // of requests has its own signal.
  const abortControllerRef = useRef<AbortController | null>(null);

  // Per-project expanded-node state. Persisted in workspace state so reload
  // remembers which beats were open inside each project. Returns a Set for
  // the current project; setter writes the Set back to the array in ws.expanded.
  const expanded: ReadonlySet<string> = useMemo(
    () => new Set(ws.expanded[focusedProjectId ?? ""] ?? []),
    [ws.expanded, focusedProjectId],
  );
  const setExpandedForCurrentProject = useCallback(
    (next: ReadonlySet<string>) => {
      const projectKey = focusedProjectId ?? "";
      if (!projectKey) return;
      setWs((s) => ({
        ...s,
        expanded: { ...s.expanded, [projectKey]: Array.from(next) },
      }));
    },
    [focusedProjectId],
  );
```

(ii) Replace the existing `onToggleExpand` callback (lines 377-384):

```typescript
  const onToggleExpand = useCallback((id: string) => {
    const cur = new Set(ws.expanded[focusedProjectId ?? ""] ?? []);
    if (cur.has(id)) cur.delete(id);
    else cur.add(id);
    setExpandedForCurrentProject(cur);
  }, [ws.expanded, focusedProjectId, setExpandedForCurrentProject]);
```

(iii) Add a cold-start effect that reads `recoil:lastProjectId` from localStorage and resolves it against the loaded `projects` list. Add immediately after the existing `adapter.getProjects().then(setProjects);` effect (lines 143-145):

```typescript
  // Cold-start: pick up the last-active project from localStorage and seed
  // `focusedProjectId`. We only do this once, after `projects` lands — the
  // resolution must match a current project (handles renames/deletes by
  // ignoring stale ids). Empty `projects` → stays null (empty-state).
  const coldStartRef = useRef(false);
  useEffect(() => {
    if (coldStartRef.current) return;
    if (projects.length === 0) return;
    coldStartRef.current = true;
    const stored = typeof window !== "undefined"
      ? window.localStorage.getItem("recoil:lastProjectId")
      : null;
    if (stored && projects.some((p) => (p.id as string) === stored)) {
      setFocusedProjectId(stored);
      setFocused(stored);
    }
  }, [projects]);
```

(iv) Add a project-change handler that aborts in-flight fetches, clears lineage cache, clears selection, writes localStorage, and kills+restarts ttyd. This is the canonical sequence the picker (Phase 4) will call. It also runs implicitly whenever the existing tree onSelect resolves a different `projectId` (the Phase 4 picker invokes it directly; the tree path passes through).

Add after the existing `setExpandedForCurrentProject` block:

```typescript
  // Project-change handler: abort, evict, clear, persist, respawn ttyd.
  // Phase 1 wires the side effects; Phase 4's <ProjectPicker> calls this
  // directly via the onChange prop. The existing tree-onSelect path also
  // calls this when projectId changes.
  const handleProjectChange = useCallback((newProjectId: string | null) => {
    abortControllerRef.current?.abort();
    abortControllerRef.current = new AbortController();
    setLineages({});
    setSelectedTakeId(null);
    setFocused(newProjectId);
    setFocusedProjectId(newProjectId);
    if (typeof window !== "undefined" && newProjectId) {
      window.localStorage.setItem("recoil:lastProjectId", newProjectId);
    }
    // ttyd kill-and-respawn: stop the old project's ttyd, start the new one.
    // Both calls fire-and-forget — failures land on the events stream.
    const oldProjectId = focusedProjectId;
    if (oldProjectId && oldProjectId !== newProjectId) {
      fetch(`/api/ttyd/stop`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ project_id: oldProjectId }),
      }).catch(() => { /* swallow — events stream surfaces failures */ });
    }
    if (newProjectId) {
      fetch(`/api/ttyd/start`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ project_id: newProjectId }),
      }).catch(() => { /* swallow */ });
    }
  }, [focusedProjectId]);
```

(v) Wire the existing `HierarchyNavigator onSelect` (lines 730-739) through `handleProjectChange` when the projectId changes. Replace the existing onSelect block:

```tsx
            onSelect={(id, projectId) => {
              if (projectId && projectId !== focusedProjectId) {
                // Project changed — full reset sequence (abort, evict cache,
                // clear take selection, write localStorage, respawn ttyd).
                handleProjectChange(projectId);
                setFocused(id);
                return;
              }
              if (projectId !== focusedProjectId) {
                setSelectedTakeId(null);
              }
              setFocused(id);
              setFocusedProjectId(projectId ?? null);
            }}
```

(vi) Update the props passed to `<HierarchyNavigator>` (line 740-741):

```tsx
            expanded={expanded}
            onToggleExpand={onToggleExpand}
```

(unchanged — the `expanded` symbol now points at the memoized Set, the toggle now writes through to `ws.expanded`).

(vii) Export `handleProjectChange` to be reachable from `<Titlebar>` in Phase 4 — pass it down via prop. For Phase 1, just construct it; Phase 4 wires the prop chain.

#### 1c. `recoil/api/ttyd_routes.py`

Add explicit port-busy probing before the bind attempt in `_allocate_port_locked`, and tighten `_kill_proc_record` with the synthesized timeout schedule.

Ensure `socket` is imported at the top of the module (spec-review MAJOR #2). Add the line if missing:

```python
import socket
```

Replace the existing `_allocate_port_locked` function (lines 120-135):

```python
def _allocate_port_locked() -> int:
    # Caller must hold _PROCS_LOCK. Reservation lives in _PORTS_RESERVED so
    # concurrent /start calls cannot race the same port. Each candidate port
    # is probed twice: a connect-test (is something *already* listening?)
    # then a bind-test (can we bind it?). The connect probe catches the
    # zombie-ttyd-on-7681 case where the previous process kept the port
    # but is no longer in _PROCS_RESERVED.
    for port in range(_PORT_MIN, _PORT_MAX + 1):
        if port in _PORTS_RESERVED:
            continue
        # Connect probe: 0 = something accepted the connection (busy from
        # our perspective even if we didn't spawn it); nonzero = free.
        probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        probe.settimeout(0.1)
        try:
            rc = probe.connect_ex(("127.0.0.1", port))
        except OSError:
            rc = 1
        finally:
            probe.close()
        if rc == 0:
            logger.warning(
                "ttyd port %d is busy (external listener); skipping", port
            )
            continue
        # Bind probe: confirm we can bind it ourselves before reserving.
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        try:
            sock.bind(("127.0.0.1", port))
        except OSError:
            sock.close()
            continue
        sock.close()
        _PORTS_RESERVED.add(port)
        return port
    logger.error(
        "no ttyd ports available in range %d-%d (all busy/reserved)",
        _PORT_MIN,
        _PORT_MAX,
    )
    raise HTTPException(status_code=503, detail="no ports available")
```

Replace the existing `_kill_proc_record` function (lines 215-237) with the synthesized SIGTERM → 500ms → SIGKILL escalation. The existing code uses `_STOP_GRACE_S = 3.0` (line 62) — the synthesis specifies 500ms wait before SIGKILL. Add a new constant and use it:

After the existing `_STOP_GRACE_S = 3.0` (line 62), add:

```python
_STOP_FAST_GRACE_S = 0.5  # picker-change ttyd swap — short grace, then SIGKILL
```

Replace `_kill_proc_record`:

```python
def _kill_proc_record(rec: _ProcRecord, *, grace_s: float = _STOP_FAST_GRACE_S) -> None:
    """SIGTERM → grace_s wait → SIGKILL escalation.

    Picker-change swaps want fast turnover (500ms). Shutdown atexit can
    afford the existing 3s grace — callers pass `grace_s=_STOP_GRACE_S`.
    """
    pid = rec.proc.pid
    try:
        os.killpg(pid, signal.SIGTERM)
    except ProcessLookupError:
        return
    except OSError as exc:
        logger.warning("killpg SIGTERM failed for pid=%s: %s", pid, exc)
    try:
        rec.proc.wait(timeout=grace_s)
        return
    except subprocess.TimeoutExpired:
        pass
    try:
        os.killpg(pid, signal.SIGKILL)
    except ProcessLookupError:
        return
    except OSError as exc:
        logger.warning("killpg SIGKILL failed for pid=%s: %s", pid, exc)
    try:
        rec.proc.wait(timeout=1.0)
    except subprocess.TimeoutExpired:
        logger.warning("ttyd pid=%s did not exit after SIGKILL", pid)
```

Update the atexit caller (`_kill_all_ttyds`, line 247) to keep its explicit `grace_s=1.0` — no change needed; it already passes the grace explicitly.

#### 1d. `recoil/console-v2/packages/desktop/src/chat/TerminalIframe.tsx`

Add a stable `key` so React unmounts and remounts the iframe on `port` change. This prevents the websocket from carrying over when ttyd respawns on a new port.

Replace the JSX return (lines 51-58):

```tsx
    return (
      <iframe
        key={`${resolvedHost}:${port}`}
        ref={iframeRef}
        className="chat-iframe"
        src={`http://${resolvedHost}:${port}`}
        sandbox="allow-scripts allow-same-origin"
        title="claude-terminal"
      />
    );
```

### Files (new)

None.

### What already exists (from prior phases)

This is the foundation phase. No prior contracts to honor besides the baseline `044b05c6` HEAD:

- `WorkspaceState` at `@recoil/contracts` carries `schemaVersion: 1` (this phase bumps to `2`).
- `App.tsx` keeps `expanded` as a local `useState<ReadonlySet<string>>` (this phase moves it to `ws.expanded`).
- `_kill_proc_record` waits `_STOP_GRACE_S=3.0`s before SIGKILL (this phase adds the fast-grace variant).

### Validation

```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS

# Syntax check
python3 -c "import ast; ast.parse(open('recoil/api/ttyd_routes.py').read())"

# Contract codegen + typecheck
pnpm --filter @recoil/contracts codegen
pnpm --filter @recoil/contracts codegen:check
pnpm --filter @recoil/desktop typecheck

# Structural greps — confirm the actual changes landed
grep -q "SCHEMA_VERSION = 2" recoil/console-v2/packages/contracts/src/manual.ts
grep -q "expanded: z.record(z.string(), z.array(z.string()))" recoil/console-v2/packages/contracts/src/manual.ts
grep -q "abortControllerRef" recoil/console-v2/packages/desktop/src/App.tsx
grep -q "recoil:lastProjectId" recoil/console-v2/packages/desktop/src/App.tsx
grep -q "handleProjectChange" recoil/console-v2/packages/desktop/src/App.tsx
grep -q "_STOP_FAST_GRACE_S" recoil/api/ttyd_routes.py
grep -q "connect_ex" recoil/api/ttyd_routes.py
grep -q 'key={`${resolvedHost}:${port}`}' recoil/console-v2/packages/desktop/src/chat/TerminalIframe.tsx

# Test suites — must stay green
pytest recoil/api/tests/ -x
pnpm --filter @recoil/desktop test
```

### Acceptance

- App boots from cold reload; picks up `recoil:lastProjectId` if the value matches a current project.
- Manual project switch in dev tools (or via the existing tree path) aborts in-flight fetches, clears lineage cache, clears `selectedTakeId`, writes localStorage, and triggers `POST /api/ttyd/stop` + `POST /api/ttyd/start`.
- Old ttyd process gone within ~500ms (SIGTERM → SIGKILL). New port allocation skips zombie ports via the connect-ex probe.
- `WorkspaceState` schema version is `2`; `expanded` keys live under `ws.expanded[projectId]`.
- Existing persisted state at v1 surfaces the SessionRestoreModal (expected — schema bump is a hard reset per A5).

### Scope boundary (do NOT)

- Do NOT add a `<ProjectPicker>` component. That's Phase 4.
- Do NOT modify any per-project API route signatures. That's Phase 3.
- Do NOT add `/api/recent/{project}` or any new backend route. That's Phase 2.
- Do NOT touch `lineage.py`, `_build_manifest_lineage`, or `LineageStrip.tsx`.
- Do NOT change `QueueInspector` or `/api/queue` — queue stays cross-project.
- Do NOT alter Events / Memory / Cost surfaces.
- Do NOT bump `schemaVersion` anywhere outside `manual.ts` (other nested shapes compose against the constant).

### Quality gates

`/bugfix` then `/simplify` per `feedback-never-skip-quality-passes`.

---

## Phase 2: Recents API Endpoint
depends_on: none
engine: claude
estimated_minutes: 30
approximate_LOC: ~280
quality_gates: bugfix, simplify

### Goal

Port `GET /api/recent/{project}` from `recoil/workspace/server.py:2341-2425` onto the engine API at `:8431`, augmented with explicit `beat_id`, `episode_id`, `take_id` derivation per row. Add a Zod schema in contracts and `getRecent` method on both adapters.

### Files (touched)

#### 2a. `recoil/api/main.py`

Register the new recent router. Add the import alongside the other adapter routers (after the existing `from recoil.api.media_routes import router as media_router`):

```python
from recoil.api.recent_routes import router as recent_router
```

After the existing `app.include_router(media_router, prefix="/api")` line (line 119), add:

```python
app.include_router(recent_router, prefix="/api")
```

The existing `/api/config` route at line 128 is already correctly registered — no change needed. The audit's H3 ("`/api/config` 404 on every page load") was a runtime/proxy issue at `044b05c6`. Verification step in this phase: hit the endpoint inside the validation block to confirm it returns 200.

#### 2b. `recoil/console-v2/packages/contracts/src/manual.ts`

Add the new schemas at the bottom of the file, before the `parseWorkspaceState` export. Append after the `DEFAULT_WORKSPACE_STATE` block (line 159):

```typescript
// ── Recents (Phase 2) ─────────────────────────────────────────────────────
// Backend endpoint: GET /api/recent/{project}?limit=&offset=
// Backend pre-derives beat_id / episode_id / take_id so the frontend never
// parses paths. Flat-layout projects (driver-beware) get episode_id=null.

export const RecentEntrySchema = z.object({
  schemaVersion: z.literal(SCHEMA_VERSION),
  name: z.string(),
  path: z.string(),
  media_url: z.string(),
  type: z.enum(["image", "video"]),
  mtime: z.number(),
  status: z.string(),
  status_color: z.string(),
  model: z.string().nullable(),
  cost: z.number().nullable(),
  beat_id: z.string().nullable(),
  episode_id: z.string().nullable(),
  take_id: z.string().nullable(),
});
export type RecentEntry = z.infer<typeof RecentEntrySchema>;

export const RecentResponseSchema = z.object({
  schemaVersion: z.literal(SCHEMA_VERSION),
  files: z.array(RecentEntrySchema),
  total: z.number().int(),
});
export type RecentResponse = z.infer<typeof RecentResponseSchema>;
```

Regenerate codegen:

```bash
pnpm --filter @recoil/contracts codegen
```

#### 2c. `recoil/console-v2/packages/contracts/src/adapter.ts`

Add `getRecent` to the `EngineDataAdapter` interface so both implementations (http-adapter and fixtures) and downstream consumers (App.tsx, Phase 4 RecentsPanel) typecheck against a single contract.

In `EngineDataAdapter`, after the existing `getSystemStatus` signature, add:

```typescript
  getRecent(
    projectId: string,
    limit?: number,
    offset?: number,
    signal?: AbortSignal,
  ): Promise<{ files: import("./manual").RecentEntry[]; total: number }>;
```

Without this interface entry Phase 4 will fail typecheck with `Property 'getRecent' does not exist on type 'EngineDataAdapter'`. Spec-review CRITICAL #1.

#### 2d. `recoil/console-v2/packages/http-adapter/src/adapter.ts`

Add the `getRecent` method after `getSystemStatus` (line 139). It accepts `signal?: AbortSignal` so callers can abort stale fetches across project switches.

After the existing `getSystemStatus` block:

```typescript
  /**
   * Phase 2 — recent media for a project. Backend derives beat_id /
   * episode_id / take_id explicitly. Frontend reads fields blindly.
   * Pagination via limit + offset; default cap is 50 (synthesis J4).
   */
  async getRecent(
    projectId: string,
    limit?: number,
    offset?: number,
    signal?: AbortSignal,
  ): Promise<{ files: import("@recoil/contracts").RecentEntry[]; total: number }> {
    const p = encodeURIComponent(projectId);
    const q = new URLSearchParams();
    if (limit !== undefined) q.set("limit", String(limit));
    if (offset !== undefined) q.set("offset", String(offset));
    const qs = q.toString();
    return getJson(`/api/recent/${p}${qs ? `?${qs}` : ""}`, { signal });
  },
```

Note: this assumes the existing `getJson` accepts an options bag with `signal`. If it does not, extend `getJson` minimally:

In `recoil/console-v2/packages/http-adapter/src/client.ts`, find the `getJson` definition. If its signature is `getJson<T>(path: string): Promise<T>`, extend to `getJson<T>(path: string, opts?: { signal?: AbortSignal }): Promise<T>` and pass `opts?.signal` to the underlying `fetch(..., { signal: opts?.signal })` call. This is an additive change — all existing call sites continue to work.

#### 2e. `recoil/console-v2/packages/fixtures/src/adapter.ts`

Add the mirror method. After the existing `getSystemStatus` block (lines 168-186):

```typescript
  /**
   * Phase 2 — fixture recents. Returns a small canned list mtime-sorted.
   * Signal honored — fixtures throw AbortError synchronously when aborted.
   */
  async getRecent(
    projectId: string,
    limit?: number,
    offset?: number,
    signal?: AbortSignal,
  ): Promise<{ files: import("@recoil/contracts").RecentEntry[]; total: number }> {
    if (signal?.aborted) {
      throw new DOMException("aborted", "AbortError");
    }
    const _unused = projectId; // multi-project fixtures land later
    const SAMPLES: import("@recoil/contracts").RecentEntry[] = [
      {
        schemaVersion: 2,
        name: "shot_001_take7.png",
        path: "output/previs/ep_001/shot_001_take7.png",
        media_url: "/media/fixture/output/previs/ep_001/shot_001_take7.png",
        type: "image",
        mtime: Date.now() / 1000 - 60,
        status: "primary",
        status_color: "green",
        model: "gemini-3.1-flash-image-preview",
        cost: 0.04,
        beat_id: "EP001_SH01",
        episode_id: "EP001",
        take_id: "EP001_SH01_T007",
      },
    ];
    const off = offset ?? 0;
    const cap = limit ?? 50;
    return {
      files: SAMPLES.slice(off, off + cap),
      total: SAMPLES.length,
    };
  },
```

### Files (new)

#### 2f. `recoil/api/adapters/recent.py`

Full file. Pure-Python recents reader with explicit ID derivation. Ports the workspace endpoint's filesystem-walk logic and adds the four derivation strategies (nested-episode, flat-layout, sidecar-overlay, fallback-null).

```python
"""Recents adapter — mtime-sorted media feed per project.

Ported from recoil/workspace/server.py:2341-2425 onto the engine API.
Adds explicit beat_id / episode_id / take_id derivation per row so the
frontend never parses paths.

Layout coverage (all 5 current projects):
  - Nested (afterimage, tartarus, ronin-drm):
      output/{previs,frames,video}/ep_NNN/shot_NNN_takeM.{png,mp4,...}
      episode_id = "EPNNN", beat_id = "EPNNN_SHNN", take_id from sidecar
      or filename.
  - Flat (driver-beware): output/<shot-folder>/take-N.{ext}
      episode_id = None, beat_id = <shot-folder>, take_id from sidecar.

When derivation cannot resolve an id, the field is null. The frontend
treats null beat_id as a disabled / greyed-out row. A
`recent_id_derivation_failed` fallback event is emitted for telemetry.
"""
from __future__ import annotations

import logging
import re
import time as _time
from pathlib import Path
from typing import Optional

from recoil.api.adapters._ids import validate_project_id
from recoil.api.fallback_bridge import emit_fallback
from recoil.core.paths import projects_root

logger = logging.getLogger(__name__)

# Mirror the workspace server's extension set.
MEDIA_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".mp4", ".mov", ".webm"}
VIDEO_EXTENSIONS = {".mp4", ".mov", ".webm"}
SKIP_DIRS = ("_archive", "_meta", "boundary_frames")

# In-process cache mirroring the workspace endpoint (5s TTL).
_RECENT_CACHE: dict[str, dict] = {}
_RECENT_CACHE_MTIME: dict[str, float] = {}
_RECENT_CACHE_TTL = 5.0

# Derivation patterns — kept narrow to avoid false-positive matches.
_EP_DIR_RE = re.compile(r"ep[_-]?(\d+)", re.IGNORECASE)
_SHOT_FILE_RE = re.compile(r"shot[_-]?(\d+)", re.IGNORECASE)
_TAKE_FILE_RE = re.compile(r"take[_-]?(\d+)", re.IGNORECASE)


def _build_metadata_index(project_id: str) -> dict[str, dict]:
    """Read sidecar JSONs from output/_meta/*.json (if present).

    Returns a {rel_path: metadata} map keyed by the rel_path on the parent
    media file. Failures are silent — recents continues without overlay.
    """
    project_dir = projects_root() / project_id
    meta_dir = project_dir / "output" / "_meta"
    if not meta_dir.is_dir():
        return {}
    import json
    index: dict[str, dict] = {}
    for sidecar in meta_dir.glob("*.json"):
        try:
            data = json.loads(sidecar.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            continue
        if not isinstance(data, dict):
            continue
        target = data.get("target_path") or data.get("path")
        if isinstance(target, str):
            index[target] = data
    return index


def _derive_ids(rel_path: str, meta: Optional[dict]) -> tuple[Optional[str], Optional[str], Optional[str]]:
    """Return (episode_id, beat_id, take_id) for a recent entry.

    Order of resolution per field:
      1. Sidecar metadata explicit (`episode_id`, `beat_id`/`shot_id`, `take_id`)
      2. Path-pattern parse:
         - episode_id from /ep_NNN/ segment in the relative path
         - beat_id from {episode}_SH{shot_num} when both are derivable, else
           the immediate parent folder name (flat layouts)
         - take_id from the filename's take_NNN segment
      3. None on failure (frontend disables the row, fallback event fires)
    """
    episode_id: Optional[str] = None
    beat_id: Optional[str] = None
    take_id: Optional[str] = None

    if meta:
        ep = meta.get("episode_id")
        if isinstance(ep, str) and ep.strip():
            episode_id = ep.strip()
        b = meta.get("beat_id") or meta.get("shot_id")
        if isinstance(b, str) and b.strip():
            beat_id = b.strip()
        t = meta.get("take_id")
        if isinstance(t, str) and t.strip():
            take_id = t.strip()

    parts = rel_path.split("/")
    name = parts[-1] if parts else ""

    if episode_id is None:
        for seg in parts:
            m = _EP_DIR_RE.fullmatch(seg) or _EP_DIR_RE.match(seg)
            if m:
                episode_id = f"EP{m.group(1).zfill(3)}"
                break

    if beat_id is None:
        m = _SHOT_FILE_RE.search(name)
        if m and episode_id:
            beat_id = f"{episode_id}_SH{m.group(1).zfill(2)}"
        else:
            # Flat layout fallback: parent folder name. Skip generic folders
            # like `output`, `frames`, `previs`, `video`.
            generic = {"output", "frames", "previs", "video", "stills"}
            for seg in reversed(parts[:-1]):
                if seg.lower() not in generic and not _EP_DIR_RE.fullmatch(seg):
                    beat_id = seg
                    break

    if take_id is None:
        m = _TAKE_FILE_RE.search(name)
        if m and beat_id:
            take_id = f"{beat_id}_T{m.group(1).zfill(3)}"

    return episode_id, beat_id, take_id


def list_recent(project_id: str, limit: int = 50, offset: int = 0) -> dict:
    """Return the recents response payload for a project.

    Shape:
      {
        "schemaVersion": 2,
        "files": [RecentEntry, ...],
        "total": int,
      }

    Raises ValueError on malformed project_id (route maps to 400).
    """
    validate_project_id(project_id)
    project_dir = projects_root() / project_id
    output_dir = project_dir / "output"
    if not output_dir.is_dir():
        return {"schemaVersion": 2, "files": [], "total": 0}

    cache_key = f"{project_id}:{limit}:{offset}"
    now = _time.monotonic()
    cached = _RECENT_CACHE.get(cache_key)
    if cached is not None:
        age = now - _RECENT_CACHE_MTIME.get(cache_key, 0.0)
        if age < _RECENT_CACHE_TTL:
            return cached

    meta_index = _build_metadata_index(project_id)

    entries: list[dict] = []
    for path in output_dir.rglob("*"):
        if not path.is_file():
            continue
        if path.suffix.lower() not in MEDIA_EXTENSIONS:
            continue
        if path.name.startswith("."):
            continue
        rel_parts = path.relative_to(output_dir).parts
        if any(
            p in SKIP_DIRS or p.startswith(".") or "backup" in p.lower()
            for p in rel_parts
        ):
            continue

        try:
            mtime = path.stat().st_mtime
        except OSError:
            continue

        rel_path = str(path.relative_to(project_dir))
        ext = path.suffix.lower()
        media_type = "video" if ext in VIDEO_EXTENSIONS else "image"
        meta = meta_index.get(rel_path)

        entry = {
            "schemaVersion": 2,
            "name": path.name,
            "path": rel_path,
            "media_url": f"/api/media/{project_id}/{rel_path}",
            "type": media_type,
            "mtime": mtime,
            "status": "untracked",
            "status_color": "gray",
            "model": None,
            "cost": None,
            "beat_id": None,
            "episode_id": None,
            "take_id": None,
        }
        if meta:
            entry["status"] = meta.get("status", entry["status"])
            entry["status_color"] = meta.get("status_color", entry["status_color"])
            entry["model"] = meta.get("model")
            entry["cost"] = meta.get("cost")

        episode_id, beat_id, take_id = _derive_ids(rel_path, meta)
        entry["episode_id"] = episode_id
        entry["beat_id"] = beat_id
        entry["take_id"] = take_id

        if beat_id is None:
            emit_fallback(
                "recent_id_derivation_failed",
                scope="api/adapters/recent",
                payload={"project_id": project_id, "path": rel_path},
            )

        entries.append(entry)

    entries.sort(key=lambda e: e["mtime"], reverse=True)
    total = len(entries)
    paginated = entries[offset : offset + limit]
    resp = {"schemaVersion": 2, "files": paginated, "total": total}
    _RECENT_CACHE[cache_key] = resp
    _RECENT_CACHE_MTIME[cache_key] = now
    return resp


__all__ = ["list_recent"]
```

#### 2g. `recoil/api/recent_routes.py`

```python
"""Recents route — GET /api/recent/{project}."""
from __future__ import annotations

from fastapi import APIRouter, HTTPException, Query, status

from recoil.api.adapters.recent import list_recent

router = APIRouter()


@router.get("/recent/{project_id}")
def get_recent(
    project_id: str,
    limit: int = Query(default=50, ge=1, le=500),
    offset: int = Query(default=0, ge=0),
) -> dict:
    """Mtime-sorted recent media for a project.

    Returns {schemaVersion, files, total}. Each row carries explicit
    beat_id / episode_id / take_id (null when derivation fails).
    """
    try:
        return list_recent(project_id, limit=limit, offset=offset)
    except ValueError as exc:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=str(exc),
        ) from exc


__all__ = ["router"]
```

#### 2h. `recoil/api/tests/test_recent.py`

```python
"""Tests for /api/recent/{project}."""
from __future__ import annotations

import json
import time
from pathlib import Path

import pytest
from fastapi.testclient import TestClient

import recoil.api.adapters.recent as recent_mod
from recoil.api.main import app


@pytest.fixture
def client() -> TestClient:
    return TestClient(app)


@pytest.fixture
def fake_projects(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    monkeypatch.setattr(
        "recoil.api.adapters.recent.projects_root", lambda: tmp_path
    )
    recent_mod._RECENT_CACHE.clear()
    recent_mod._RECENT_CACHE_MTIME.clear()
    return tmp_path


def _touch(p: Path, mtime: float | None = None) -> None:
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_bytes(b"\x00")
    if mtime is not None:
        import os
        os.utime(p, (mtime, mtime))


def test_nested_layout_derives_episode_beat_take(fake_projects: Path) -> None:
    out = fake_projects / "tartarus" / "output" / "previs" / "ep_001"
    _touch(out / "shot_001_take7.png", mtime=time.time())
    resp = recent_mod.list_recent("tartarus")
    assert resp["total"] == 1
    row = resp["files"][0]
    assert row["episode_id"] == "EP001"
    assert row["beat_id"] == "EP001_SH01"
    assert row["take_id"] == "EP001_SH01_T007"
    assert row["type"] == "image"


def test_flat_layout_yields_null_episode(fake_projects: Path) -> None:
    out = fake_projects / "driver-beware" / "output" / "CRASH_JUMP_02"
    _touch(out / "take_3.mp4", mtime=time.time())
    resp = recent_mod.list_recent("driver-beware")
    assert resp["total"] == 1
    row = resp["files"][0]
    assert row["episode_id"] is None
    assert row["beat_id"] == "CRASH_JUMP_02"
    assert row["take_id"] == "CRASH_JUMP_02_T003"
    assert row["type"] == "video"


def test_mtime_sort_descending(fake_projects: Path) -> None:
    out = fake_projects / "tartarus" / "output" / "previs" / "ep_001"
    _touch(out / "shot_001_take1.png", mtime=1000.0)
    _touch(out / "shot_002_take1.png", mtime=2000.0)
    resp = recent_mod.list_recent("tartarus")
    names = [f["name"] for f in resp["files"]]
    assert names == ["shot_002_take1.png", "shot_001_take1.png"]


def test_skip_dirs_filtered(fake_projects: Path) -> None:
    base = fake_projects / "tartarus" / "output"
    _touch(base / "_archive" / "old.png", mtime=time.time())
    _touch(base / "_meta" / "ignored.png", mtime=time.time())
    _touch(base / "boundary_frames" / "frame.png", mtime=time.time())
    _touch(base / "previs" / "ep_001" / "shot_001_take1.png", mtime=time.time())
    resp = recent_mod.list_recent("tartarus")
    assert resp["total"] == 1


def test_pagination(fake_projects: Path) -> None:
    out = fake_projects / "tartarus" / "output" / "previs" / "ep_001"
    for i in range(1, 6):
        _touch(out / f"shot_001_take{i}.png", mtime=float(i))
    resp = recent_mod.list_recent("tartarus", limit=2, offset=1)
    assert resp["total"] == 5
    assert len(resp["files"]) == 2


def test_sidecar_overlay(fake_projects: Path) -> None:
    out = fake_projects / "tartarus" / "output" / "previs" / "ep_001"
    _touch(out / "shot_001_take1.png", mtime=time.time())
    meta = fake_projects / "tartarus" / "output" / "_meta"
    meta.mkdir(parents=True, exist_ok=True)
    (meta / "shot_001_take1.json").write_text(
        json.dumps({
            "target_path": "output/previs/ep_001/shot_001_take1.png",
            "status": "primary",
            "status_color": "green",
            "model": "gemini-3.1-flash-image-preview",
            "cost": 0.04,
            "take_id": "EP001_SH01_T001",
        }),
        encoding="utf-8",
    )
    recent_mod._RECENT_CACHE.clear()
    resp = recent_mod.list_recent("tartarus")
    row = resp["files"][0]
    assert row["status"] == "primary"
    assert row["model"] == "gemini-3.1-flash-image-preview"
    assert row["take_id"] == "EP001_SH01_T001"


def test_route_returns_200(client: TestClient, fake_projects: Path) -> None:
    out = fake_projects / "tartarus" / "output" / "previs" / "ep_001"
    _touch(out / "shot_001_take1.png", mtime=time.time())
    r = client.get("/api/recent/tartarus")
    assert r.status_code == 200
    body = r.json()
    assert body["schemaVersion"] == 2
    assert body["total"] == 1


def test_config_route_returns_200(client: TestClient) -> None:
    # H3 verification — /api/config must exist and return the expected shape.
    r = client.get("/api/config")
    assert r.status_code == 200
    body = r.json()
    assert body["schemaVersion"] == 1
    assert "ttydHost" in body
```

### What already exists (from prior phases)

- `recoil/api/adapters/_ids.py:validate_project_id` raises `ValueError` on malformed project ids. Reuse for path-traversal safety.
- `recoil/api/fallback_bridge.emit_fallback` is the canonical fallback event emitter.
- `recoil/core/paths.projects_root()` returns the projects root path.
- `/api/config` exists at `recoil/api/main.py:128` returning `{schemaVersion: 1, ttydHost: <env>}`. The test in 2g verifies this.

### Validation

```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS

# Syntax checks
python3 -c "import ast; ast.parse(open('recoil/api/adapters/recent.py').read())"
python3 -c "import ast; ast.parse(open('recoil/api/recent_routes.py').read())"

# Codegen + typecheck
pnpm --filter @recoil/contracts codegen
pnpm --filter @recoil/contracts codegen:check
pnpm --filter @recoil/desktop typecheck

# Structural greps
grep -q "RecentEntrySchema" recoil/console-v2/packages/contracts/src/manual.ts
grep -q "RecentResponseSchema" recoil/console-v2/packages/contracts/src/manual.ts
grep -q "getRecent" recoil/console-v2/packages/http-adapter/src/adapter.ts
grep -q "getRecent" recoil/console-v2/packages/fixtures/src/adapter.ts
grep -q "recent_router" recoil/api/main.py
grep -q "def list_recent" recoil/api/adapters/recent.py
grep -q "/recent/{project_id}" recoil/api/recent_routes.py

# Test suites
pytest recoil/api/tests/ -x
pytest recoil/api/tests/test_recent.py -x -v
pnpm --filter @recoil/desktop test
```

### Acceptance

- `curl http://localhost:8431/api/recent/tartarus` returns mtime-sorted entries with populated `beat_id`/`take_id`/`episode_id`.
- `curl http://localhost:8431/api/recent/driver-beware` returns entries with `episode_id: null` and `beat_id` matching the shot folder name.
- `curl http://localhost:8431/api/config` returns 200 with `{schemaVersion: 1, ttydHost: ...}`.
- pytest gains 7 new test cases (233 + 7 = 240). vitest unchanged (110); the Phase 4 picker tests add to it.

### Scope boundary (do NOT)

- Do NOT add a `<RecentsPanel>` component or `NavTabStrip`. That's Phase 4.
- Do NOT modify `_resolve_shot` / `_build_manifest_lineage` in `lineage.py`.
- Do NOT add path parsing to the frontend — backend is authoritative.
- Do NOT add a `projectId` filter to `QueueInspector` or `/api/queue`.
- Do NOT change `getTakes` / `getLineage` signatures in this phase. That's Phase 3.

### Quality gates

`/bugfix` then `/simplify`.

---

## Phase 3: API Strictness + H1 Fallback Removal
depends_on: 1
engine: claude
estimated_minutes: 25
approximate_LOC: ~140
quality_gates: bugfix, simplify

### Goal

Require `projectId` on per-project routes. Drop the H1 `episode_id_derived_from_filename_prefix` fallback firehose by removing the cross-project candidate walk in `_find_shot_for_take`. Update the HTTP adapter, fixtures adapter, and `App.tsx` call sites.

### Files (touched)

#### 3a. `recoil/api/adapters/beats.py`

Tighten signatures so `project_id` is mandatory on every read/mutation that takes one. The H1 fallback (`_derive_episode_id`'s sanctioned filename-prefix path) stays — it's still called for edge-case shot files — but with a required `project_id` upstream we never fall through the cross-project walk that was firing it on every read.

Changes:

(i) `list_takes` (line 469-501): change signature from `project_id: Optional[str] = None` → `project_id: str`. Delete the cross-project candidate walk.

Replace the function:

```python
def list_takes(beat_id: str, project_id: str) -> list[Take]:
    """List takes for a beat — requires explicit project_id.

    Path-traversal guard (Debug R1): IDs validated before any filesystem access.
    """
    validate_hierarchy_id("beat_id", beat_id)
    validate_project_id(project_id)
    path = _shots_dir(project_id) / f"{beat_id}.json"
    if not path.exists():
        return []
    shot = _load_shot(path)
    if shot is None:
        return []
    raw_takes = shot.get("takes") or []
    out: list[Take] = []
    for i, raw in enumerate(raw_takes):
        if not isinstance(raw, dict):
            continue
        out.append(_build_take(beat_id, raw, i, project_id))
    return out
```

(ii) `get_beat` (line 504-523): make `project_id` required, delete candidate walk:

```python
def get_beat(beat_id: str, project_id: str) -> Optional[Beat]:
    """Single-beat lookup. Used by lineage adapter. Requires project_id."""
    validate_hierarchy_id("beat_id", beat_id)
    validate_project_id(project_id)
    path = _shots_dir(project_id) / f"{beat_id}.json"
    if not path.exists():
        return None
    shot = _load_shot(path)
    if shot is None:
        return None
    return _build_beat(shot)
```

(iii) `get_shot_dict` (line 526-541): make `project_id` required, delete candidate walk:

```python
def get_shot_dict(beat_id: str, project_id: str) -> Optional[dict]:
    """Raw shot dict — exposed for the lineage adapter. Requires project_id."""
    validate_hierarchy_id("beat_id", beat_id)
    validate_project_id(project_id)
    path = _shots_dir(project_id) / f"{beat_id}.json"
    if path.exists():
        return _load_shot(path)
    return None
```

(iv) `get_episode_id_for_beat` (line 544-567): make `project_id` required, delete candidate walk:

```python
def get_episode_id_for_beat(beat_id: str, project_id: str) -> str | None:
    """Resolve episode_id for a beat by loading its shot file. Requires project_id."""
    validate_hierarchy_id("beat_id", beat_id)
    validate_project_id(project_id)
    path = _shots_dir(project_id) / f"{beat_id}.json"
    if not path.exists():
        return None
    shot = _load_shot(path)
    if shot is None:
        return None
    return _derive_episode_id(shot, path, project_id)
```

(v) `_find_shot_for_take` (line 610-662): **leave unchanged.** Keep the existing `project_id: Optional[str] = None` signature and its candidate-walk fallback.

Rationale (spec-review CRITICAL #2): the H1 firehose is plugged by the *read-path* tightening above — `list_takes` and `get_lineage` now require `project_id` and account for the 167 events/session in the audit. Mutations are low-volume (a click here, a primary-mark there); leaving the candidate-walk intact on these helpers preserves backward compat with the workspace MCP server (which still calls them with `None`) without re-introducing the firehose.

(vi) Mutation helpers (`set_primary`, `toggle_circled`, `set_circled`, `reject_take`, `update_take_params`, `set_prompt_override`, `add_directive_to_beats`, `extract_cutaway`, `set_ref_overrides`, `pin_strategy`): **leave unchanged.**

Do NOT add `if project_id is None: raise ValueError(...)` guards. They would break the workspace MCP server's existing call sites that pass `project_id=None`. The H1 fix lives on the read path; tightening mutations is a separate follow-up that requires updating workspace MCP server first.

(vii) Keep `_derive_episode_id` unchanged (line 80-112). The sanctioned filename-prefix fallback at line 107-112 stays — it's the bottom of a deterministic ladder for shot files that genuinely don't carry `episode_id`. With required `project_id` upstream, the fallback fires only on the small set of legacy shot files that omit `episode_id`, not the 167-events-per-session firehose the audit captured.

#### 3b. `recoil/api/adapters/lineage.py`

Make `project_id` required on `get_lineage`. Inside the function, the existing `_resolve_shot` candidate-loop at lines 121-142 becomes a no-op (single-file stat) when `project_id` is provided — which it always now is.

Replace the `get_lineage` signature (line 669-673):

```python
def get_lineage(
    beat_id: str,
    project_id: str,
    take_id: Optional[str] = None,
) -> Optional[Lineage]:
```

Body unchanged. `_build_manifest_lineage`, `_build_beat_lineage`, `_resolve_shot`, `_resolve_take_by_id`, `_walk_parent_chain`, `_path_to_media`, `_coerce_eval_detail`, `_resolve_ref_url_from_label` — all unchanged. This phase is purely a signature tightening at the boundary.

#### 3c. `recoil/api/engine_routes.py`

Tighten the route handlers to require `projectId`. Replace `get_takes` (line 146-157):

```python
@router.get("/beats/{beat_id}/takes", response_model=list[Take])
def get_takes(
    beat_id: str,
    project_id: str = Query(..., alias="projectId"),
) -> list[Take]:
    try:
        return list_takes(beat_id, project_id=project_id)
    except ValueError as exc:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=str(exc),
        ) from exc
```

Replace `get_lineage` (line 160-184):

```python
@router.get("/beats/{beat_id}/lineage", response_model=Lineage)
def get_lineage(
    beat_id: str,
    project_id: str = Query(..., alias="projectId"),
    take_id: Optional[str] = Query(default=None, alias="takeId"),
) -> Lineage:
    """Beat-rooted lineage by default; take-rooted when `takeId` is provided.

    projectId is required — the prior cross-project fallback resolution was
    the source of the H1 fallback firehose. Callers must pass projectId.
    """
    try:
        L = _get_lineage(beat_id, project_id=project_id, take_id=take_id)
    except ValueError as exc:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=str(exc),
        ) from exc
    if L is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"no lineage for beat {beat_id!r}",
        )
    return L
```

`get_beats` and the mutation routes that already take projectId stay unchanged at the route level (Pydantic + FastAPI 422 handles missing required query params automatically).

#### 3d. `recoil/console-v2/packages/http-adapter/src/adapter.ts`

Update method signatures to require `projectId`. Each scoped method throws client-side when `projectId` is missing, so a bug-call in the UI fails fast rather than triggering a server 400 round-trip.

Replace `getTakes` (lines 78-82):

```typescript
  async getTakes(
    beatId: string,
    projectId: string,
    signal?: AbortSignal,
  ): Promise<Take[]> {
    if (!projectId) throw new Error("projectId required for scoped route");
    const b = encodeURIComponent(beatId);
    const qs = `?projectId=${encodeURIComponent(projectId)}`;
    return getJson<Take[]>(`/api/beats/${b}/takes${qs}`, { signal });
  },
```

Replace `getLineage` (lines 83-96):

```typescript
  async getLineage(
    beatId: string,
    projectId: string,
    takeId?: string,
    signal?: AbortSignal,
  ): Promise<Lineage | null> {
    if (!projectId) throw new Error("projectId required for scoped route");
    const b = encodeURIComponent(beatId);
    const q = new URLSearchParams();
    q.set("projectId", projectId);
    if (takeId) q.set("takeId", takeId);
    try {
      return await getJson<Lineage>(`/api/beats/${b}/lineage?${q.toString()}`, { signal });
    } catch (e) {
      if (e instanceof HttpError && e.status === 404) return null;
      throw e;
    }
  },
```

Replace `markPrimary` / `markCircled` / `rejectTake` (lines 152-166):

```typescript
  async markPrimary(takeId: string, projectId: string): Promise<{ ok: true }> {
    if (!projectId) throw new Error("projectId required for scoped route");
    const t = encodeURIComponent(takeId);
    const qs = `?projectId=${encodeURIComponent(projectId)}`;
    return postJson(`/api/takes/${t}/mark-primary${qs}`);
  },
  async markCircled(takeId: string, projectId: string): Promise<{ ok: true }> {
    if (!projectId) throw new Error("projectId required for scoped route");
    const t = encodeURIComponent(takeId);
    const qs = `?projectId=${encodeURIComponent(projectId)}`;
    return postJson(`/api/takes/${t}/mark-circled${qs}`);
  },
  async rejectTake(takeId: string, projectId: string): Promise<{ ok: true }> {
    if (!projectId) throw new Error("projectId required for scoped route");
    const t = encodeURIComponent(takeId);
    const qs = `?projectId=${encodeURIComponent(projectId)}`;
    return postJson(`/api/takes/${t}/reject${qs}`);
  },
```

#### 3e. `recoil/console-v2/packages/fixtures/src/adapter.ts`

Mirror the signature changes. Honor `signal.aborted` synchronously.

Replace `getTakes` (line 115-117):

```typescript
  async getTakes(beatId: string, projectId: string, signal?: AbortSignal): Promise<Take[]> {
    if (signal?.aborted) throw new DOMException("aborted", "AbortError");
    void projectId;
    return mutate.takesFor(beatId);
  },
```

Replace `getLineage` (lines 118-121):

```typescript
  async getLineage(
    beatId: string,
    projectId: string,
    _takeId?: string,
    signal?: AbortSignal,
  ): Promise<Lineage | null> {
    if (signal?.aborted) throw new DOMException("aborted", "AbortError");
    void projectId;
    if ((TARTARUS_LINEAGE.beatId as string) === beatId) return TARTARUS_LINEAGE;
    return null;
  },
```

Replace `markPrimary` / `markCircled` / `rejectTake` (lines 201-212):

```typescript
  async markPrimary(takeId: string, projectId: string): Promise<{ ok: true }> {
    void projectId;
    mutate.markPrimary(takeId);
    return { ok: true };
  },
  async markCircled(takeId: string, projectId: string): Promise<{ ok: true }> {
    void projectId;
    mutate.toggleCircled(takeId);
    return { ok: true };
  },
  async rejectTake(takeId: string, projectId: string): Promise<{ ok: true }> {
    void projectId;
    mutate.rejectTake(takeId);
    return { ok: true };
  },
```

#### 3f. `recoil/console-v2/packages/contracts/src/adapter.ts`

Update the `EngineDataAdapter` interface signatures (lines 58-59 + 84-86):

```typescript
  getTakes(beatId: string, projectId: string, signal?: AbortSignal): Promise<Take[]>;
  getLineage(
    beatId: string,
    projectId: string,
    takeId?: string,
    signal?: AbortSignal,
  ): Promise<Lineage | null>;
  // ...
  markPrimary(takeId: string, projectId: string): Promise<{ ok: true }>;
  markCircled(takeId: string, projectId: string): Promise<{ ok: true }>;
  rejectTake(takeId: string, projectId: string): Promise<{ ok: true }>;
```

#### 3g. `recoil/console-v2/packages/desktop/src/App.tsx`

At the existing call sites, assert non-null `focusedProjectId` before calling. Add explicit early-return when `focusedProjectId === null`.

Replace the `getTakes` effect (lines 267-284):

```typescript
  useEffect(() => {
    if (!focused || !focusedProjectId) {
      setTakes([]);
      return;
    }
    const signal = abortControllerRef.current?.signal;
    let cancelled = false;
    adapter
      .getTakes(focused, focusedProjectId, signal)
      .then((next) => {
        if (!cancelled) setTakes(next);
      })
      .catch((err) => {
        if (err?.name === "AbortError") return;
        if (!cancelled) setTakes([]);
      });
    return () => {
      cancelled = true;
    };
  }, [focused, focusedProjectId]);
```

Replace the `getLineage` effect (lines 293-321):

```typescript
  useEffect(() => {
    if (!focused || !focusedProjectId) return;
    if (takes.length === 0) return;
    const tId = lineageTakeId ?? "_primary";
    const cacheKey = `${focusedProjectId}::${focused}::${tId}`;
    if (cacheKey in lineages) return;
    const signal = abortControllerRef.current?.signal;
    let cancelled = false;
    adapter
      .getLineage(focused, focusedProjectId, lineageTakeId ?? undefined, signal)
      .then((lin) => {
        if (cancelled) return;
        setLineages((prev) =>
          cacheKey in prev ? prev : { ...prev, [cacheKey]: lin },
        );
      })
      .catch((err) => {
        if (err?.name === "AbortError") return;
        if (cancelled) return;
        setLineages((prev) =>
          cacheKey in prev ? prev : { ...prev, [cacheKey]: null },
        );
      });
    return () => {
      cancelled = true;
    };
  }, [focused, focusedProjectId, lineageTakeId, takes.length, lineages]);
```

Update mutation call sites in `markPrimaryFromQueue` (lines 236-252) — the Queue's `markPrimary` was already passing projectId; keep it but assert non-null:

```typescript
  const markPrimaryFromQueue = useCallback(
    (item: RecentItem) => {
      const tokens = item.target.split("/").map((s) => s.trim());
      const takeId = tokens[tokens.length - 1] ?? item.id;
      const projectId = tokens.length >= 2 ? tokens[0] : undefined;
      if (!projectId) {
        showBanner(`mark-primary failed — no projectId on ${takeId}`);
        return;
      }
      adapter
        .markPrimary(takeId, projectId)
        .then(reloadQueue)
        .catch((err) => {
          console.warn("[recoil] markPrimary failed", err);
          showBanner(`mark-primary failed — ${takeId}`);
        });
    },
    [reloadQueue, showBanner],
  );
```

### Files (new)

None.

### What already exists (from prior phases)

From Phase 1:

- `abortControllerRef: useRef<AbortController | null>` in `App.tsx`.
- `handleProjectChange(newProjectId)` — full reset sequence; aborts existing controller and assigns a fresh one.
- `focusedProjectId` state, asserted non-null at all per-project adapter call sites.

From Phase 2:

- `getRecent(projectId, limit?, offset?, signal?)` on both adapters (signal-aware). This phase models the same `signal` pattern for `getTakes` / `getLineage`.

### Validation

```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS

# Syntax checks
python3 -c "import ast; ast.parse(open('recoil/api/adapters/beats.py').read())"
python3 -c "import ast; ast.parse(open('recoil/api/adapters/lineage.py').read())"
python3 -c "import ast; ast.parse(open('recoil/api/engine_routes.py').read())"

# Typecheck + tests
pnpm --filter @recoil/contracts codegen:check
pnpm --filter @recoil/desktop typecheck
pnpm --filter @recoil/desktop test
pytest recoil/api/tests/ -x

# Structural greps — confirm strictness landed
grep -q "def list_takes(beat_id: str, project_id: str)" recoil/api/adapters/beats.py
grep -q "def get_beat(beat_id: str, project_id: str)" recoil/api/adapters/beats.py
grep -q "def get_shot_dict(beat_id: str, project_id: str)" recoil/api/adapters/beats.py
grep -q "def _find_shot_for_take" recoil/api/adapters/beats.py
grep -q "project_id: str," recoil/api/adapters/lineage.py
grep -q 'project_id: str = Query(..., alias="projectId")' recoil/api/engine_routes.py
grep -q "projectId required for scoped route" recoil/console-v2/packages/http-adapter/src/adapter.ts

# H1 fallback firehose is gone — no longer emitted on every take lookup
# (the emit_fallback call site stays in _derive_episode_id but only fires
# when the shot file genuinely lacks episode_id)
grep -c "emit_fallback" recoil/api/adapters/beats.py
# Expected: 6 occurrences (unchanged from baseline — only call paths changed)
```

### Acceptance

- `curl 'http://localhost:8431/api/beats/EP001_SH01/takes'` (no `?projectId=`) returns 422 with `field required`.
- `curl 'http://localhost:8431/api/beats/EP001_SH01/takes?projectId=tartarus'` returns 200.
- Manual browse of a tartarus episode produces zero `episode_id_derived_from_filename_prefix` events (the H1 firehose).
- `pnpm --filter @recoil/desktop typecheck` is clean (every call site passes `projectId`).
- pytest unchanged (240). vitest unchanged (110). The strictness is a signature tightening — existing tests already pass projectId since the optional kwarg was added in earlier phases.

### Scope boundary (do NOT)

- Do NOT modify `_build_manifest_lineage` body in `lineage.py`. Only `get_lineage`'s signature changes.
- Do NOT modify `_resolve_ref_url_from_label` in `lineage.py`.
- Do NOT touch `LineageStrip.tsx` or `LineageStrip.test.tsx`.
- Do NOT add a `projectId` filter to QueueInspector or `/api/queue`.
- Do NOT delete the `_derive_episode_id` fallback ladder — keep the sanctioned filename-prefix path; just stop hitting it on every read.
- Do NOT touch the workspace MCP server (`recoil/workspace/server.py`) — its calls to beats.py mutations with `project_id=None` now raise `ValueError`, which is the correct surface (caught by FastAPI's exception handler in the workspace path).

### Quality gates

`/bugfix` then `/simplify`.

---

## Phase 4: UI Shell (Picker + Tabs + Recents Renderer)
depends_on: 1, 2, 3
engine: claude
estimated_minutes: 30
approximate_LOC: ~420
quality_gates: bugfix, simplify

### Goal

Breadcrumb-segment project picker dropdown. `HIERARCHY | RECENT` tab strip above the tree. Recents list renderer with click-to-focus. Read-only project label in the left rail's `HIERARCHY` header. Scoped tree showing only the active project.

### Files (touched)

#### 4a. `recoil/console-v2/packages/desktop/src/lib/use_breadcrumb.ts`

Return the project segment as a separate object so the breadcrumb renderer can mount `<ProjectPicker>` for it and plain `<span>` for the others.

Replace the file in full:

```typescript
import { useMemo } from "react";

import type { Project } from "../data";
import type { FocusPath } from "./focus_walker";

export interface BreadcrumbProjectSegment {
  id: string;
  name: string;
}

export interface BreadcrumbResult {
  label: string;
  parts: string[];
  projectSegment: BreadcrumbProjectSegment | null;
  tailParts: string[];
}

export function useBreadcrumb(
  focused: string | null,
  path: FocusPath | null,
  selectedTakeId?: string | null,
  fallbackProject?: Project | null,
): BreadcrumbResult {
  return useMemo(() => {
    // No focus AND no fallback project = nothing to render. The picker
    // surface shows "no project selected" in this case.
    if (!focused && !fallbackProject) {
      return {
        label: "no project selected",
        parts: [],
        projectSegment: null,
        tailParts: [],
      };
    }
    if (!path) {
      const p = fallbackProject;
      const segment = p
        ? { id: p.id as string, name: p.name }
        : null;
      const tailParts = focused && focused !== (p?.id as string) ? [focused] : [];
      const label = [segment?.name ?? focused, ...tailParts]
        .filter(Boolean)
        .join(" · ");
      return {
        label,
        parts: [segment?.name ?? (focused ?? ""), ...tailParts],
        projectSegment: segment,
        tailParts,
      };
    }
    const { project, episode, scene, beat } = path;
    const segment: BreadcrumbProjectSegment = {
      id: project.id as string,
      name: project.name,
    };
    const tailParts: string[] = [];
    if (episode) tailParts.push(episode.name);
    if (scene) tailParts.push(scene.name);
    if (beat) tailParts.push(beat.name);
    else if (focused && (project.id as string) !== focused) tailParts.push(focused);
    if (selectedTakeId) {
      const suffix = selectedTakeId.includes("_T")
        ? selectedTakeId.slice(selectedTakeId.lastIndexOf("_T") + 1)
        : selectedTakeId;
      tailParts.push(suffix);
    }
    const parts = [segment.name, ...tailParts];
    return {
      label: parts.join(" · "),
      parts,
      projectSegment: segment,
      tailParts,
    };
  }, [focused, path, selectedTakeId, fallbackProject]);
}
```

#### 4b. `recoil/console-v2/packages/desktop/src/shell/ChromeTop.tsx`

The breadcrumb currently renders as a plain string. Switch to a slotted form that accepts a node for the project segment plus a tail string. The picker mounts as the project segment.

Replace the file (after reading its current contents):

```tsx
import type { ReactNode } from "react";
import type { StageTemplate } from "@recoil/contracts";

interface Props {
  breadcrumbProject: ReactNode;
  breadcrumbTail: string;
  tabs: { id: StageTemplate; label: string }[];
  activeTab: StageTemplate;
  onTabChange: (t: StageTemplate) => void;
  toolbarRight: ReactNode;
}

export function ChromeTop({
  breadcrumbProject,
  breadcrumbTail,
  tabs,
  activeTab,
  onTabChange,
  toolbarRight,
}: Props) {
  return (
    <div className="chrome-top">
      <div className="chrome-left">
        <div className="breadcrumb">
          <span className="bc-project">{breadcrumbProject}</span>
          {breadcrumbTail && <span className="bc-sep"> · </span>}
          {breadcrumbTail && <span className="path">{breadcrumbTail}</span>}
        </div>
      </div>
      <nav className="chrome-tabs">
        {tabs.map((t) => (
          <button
            key={t.id}
            className={`tab ${activeTab === t.id ? "active" : ""}`}
            onClick={() => onTabChange(t.id)}
          >
            {t.label}
          </button>
        ))}
      </nav>
      <div className="chrome-right">{toolbarRight}</div>
    </div>
  );
}
```

(If the existing ChromeTop has additional props beyond `breadcrumb`, preserve them — only the breadcrumb segment changes shape. Verify by reading the current ChromeTop.tsx before editing.)

#### 4c. `recoil/console-v2/packages/desktop/src/nav/HierarchyNavigator.tsx`

Two changes:

(i) Filter `projects` to just the active project (the picker handles cross-project switching now). The component still accepts the full `projects` array for backward compat with existing tests; internally it derives `activeProject = projects.find(p => p.id === focusedProjectId) ?? projects[0]`.

(ii) Add a read-only project label below the `HIERARCHY` header.

Replace the `HierarchyNavigator` export at the bottom (lines 601-682):

```tsx
export function HierarchyNavigator({
  projects,
  focused,
  focusedProjectId,
  onSelect,
  expanded,
  onToggleExpand,
  showScores,
  showSynthesized,
  adapter,
}: Props) {
  const navAdapter: NavAdapter = adapter ?? (defaultAdapter as unknown as NavAdapter);
  const panelBodyRef = useRef<HTMLDivElement>(null);

  // Scope the tree to the active project. Picker handles switching.
  const activeProject = (() => {
    if (focusedProjectId) {
      const found = projects.find((p) => (p.id as string) === focusedProjectId);
      if (found) return found;
    }
    // Spec-review CRITICAL #3: do NOT fall through to projects[0]. Cold start
    // with no focusedProjectId must hit the empty-state branch, not render
    // the alphabetically-first project.
    return null;
  })();

  const projectLabel = activeProject?.name ?? "—";

  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>): void => {
    if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
    e.preventDefault();
    const container = panelBodyRef.current;
    if (!container) return;
    const rows = Array.from(container.querySelectorAll<HTMLElement>("[data-navid]"));
    if (rows.length === 0) return;
    const currentIdx = rows.findIndex((r) => r.classList.contains("focused"));
    const nextIdx =
      e.key === "ArrowDown"
        ? Math.min(currentIdx + 1, rows.length - 1)
        : Math.max(currentIdx - 1, 0);
    if (nextIdx !== currentIdx) {
      const nextRow = rows[nextIdx];
      const qualifiedId = nextRow?.dataset.navid;
      if (!qualifiedId) return;
      const sepIdx = qualifiedId.indexOf("::");
      if (sepIdx !== -1) {
        const proj = qualifiedId.slice(0, sepIdx);
        const id = qualifiedId.slice(sepIdx + 2);
        onSelect(id, proj);
      } else {
        onSelect(qualifiedId);
      }
      nextRow.scrollIntoView({ block: "nearest" });
    }
  };

  return (
    <div className="panel">
      <div className="panel-header">
        <span className="title">HIERARCHY</span>
        <span className="h-actions">
          <button title="Filter">⌕</button>
        </span>
      </div>
      <div className="hierarchy-project-label" data-testid="hierarchy-project-label">
        {projectLabel}
      </div>
      <div
        ref={panelBodyRef}
        className="panel-body"
        tabIndex={0}
        onClickCapture={() => panelBodyRef.current?.focus()}
        onKeyDown={onKeyDown}
      >
        {activeProject ? (
          <ProjectNode
            key={activeProject.id as string}
            project={activeProject}
            expanded={expanded}
            focused={focused}
            focusedProjectId={focusedProjectId}
            onSelect={onSelect}
            onToggleExpand={onToggleExpand}
            showScores={showScores}
            showSynthesized={showSynthesized}
            adapter={navAdapter}
          />
        ) : (
          <div className="hn-empty" style={{ padding: "8px", color: "var(--muted)" }}>
            no project
          </div>
        )}
      </div>
    </div>
  );
}
```

Add a small CSS rule for the new label. Append to `recoil/console-v2/packages/desktop/src/nav/nav.css` (use Edit to add a new rule):

```css
.hierarchy-project-label {
  padding: 4px 8px 6px;
  font-family: var(--mono, monospace);
  font-size: 10.5px;
  letter-spacing: 0.05em;
  color: var(--fg-2);
  border-bottom: 1px solid var(--border-soft, rgba(255, 255, 255, 0.06));
}
```

#### 4d. `recoil/console-v2/packages/desktop/src/App.tsx`

Wire the picker, the tab strip, and the recents panel. Several changes:

(i) Add new state for the left-rail tab and recents list.

After the existing `const [errorBanner, setErrorBanner] = useState<string | null>(null);` block, add:

```typescript
  // Phase 4 — left-rail tab + recents list. Tab is ephemeral. Recents
  // refetched on focusedProjectId change.
  const [leftRailTab, setLeftRailTab] = useState<"hierarchy" | "recents">("hierarchy");
  const [recents, setRecents] = useState<import("@recoil/contracts").RecentEntry[]>([]);
```

(ii) Fetch recents on project change. After the existing `getLineage` effect:

```typescript
  useEffect(() => {
    if (!focusedProjectId) {
      setRecents([]);
      return;
    }
    const signal = abortControllerRef.current?.signal;
    let cancelled = false;
    adapter
      .getRecent(focusedProjectId, 50, 0, signal)
      .then((resp) => {
        if (!cancelled) setRecents(resp.files);
      })
      .catch((err) => {
        if (err?.name === "AbortError") return;
        if (!cancelled) setRecents([]);
      });
    return () => {
      cancelled = true;
    };
  }, [focusedProjectId]);
```

(iii) Compute the focused project for the breadcrumb + picker (already exists via `focusedProject`). Build the picker node and pass to ChromeTop.

Replace the existing `<ChromeTop breadcrumb={breadcrumb.label} ...>` invocation:

```tsx
      <ChromeTop
        breadcrumbProject={
          <ProjectPicker
            projects={projects}
            activeProjectId={focusedProjectId}
            onSelect={handleProjectChange}
          />
        }
        breadcrumbTail={breadcrumb.tailParts.join(" · ")}
        tabs={TEMPLATES}
        activeTab={ws.tabs.currentTemplate}
        onTabChange={setStageTemplate}
        toolbarRight={toolbarRight}
      />
```

Add the import at the top of `App.tsx`:

```typescript
import { ProjectPicker } from "./shell/ProjectPicker";
import { NavTabStrip } from "./nav/NavTabStrip";
import { RecentsPanel } from "./nav/RecentsPanel";
```

(iv) Update the `useBreadcrumb` call to pass `focusedProject` as fallback (so the breadcrumb still has a project segment before tree expansion):

Replace:

```typescript
  const breadcrumb = useBreadcrumb(focused, focusPath, selectedTakeId);
```

with:

```typescript
  const breadcrumb = useBreadcrumb(focused, focusPath, selectedTakeId, focusedProject);
```

(v) Replace the left rail `nav` slot — wrap `<HierarchyNavigator>` in the tab strip and conditionally render Recents.

Replace the existing `nav={...}` block on `<ResizableMain>`:

```tsx
        nav={
          <div className="left-rail">
            <NavTabStrip
              activeTab={leftRailTab}
              onChange={setLeftRailTab}
            />
            {leftRailTab === "hierarchy" ? (
              <HierarchyNavigator
                projects={projects}
                focused={focused}
                focusedProjectId={focusedProjectId}
                onSelect={(id, projectId) => {
                  if (projectId && projectId !== focusedProjectId) {
                    handleProjectChange(projectId);
                    setFocused(id);
                    return;
                  }
                  if (projectId !== focusedProjectId) {
                    setSelectedTakeId(null);
                  }
                  setFocused(id);
                  setFocusedProjectId(projectId ?? null);
                }}
                expanded={expanded}
                onToggleExpand={onToggleExpand}
                showScores={ws.tweaks.showScores}
                showSynthesized={ws.tweaks.showSynthesized}
              />
            ) : (
              <RecentsPanel
                recents={recents}
                onSelect={(entry) => {
                  if (!entry.beat_id) return;
                  setFocused(entry.beat_id);
                  // focusedProjectId stays current — recents is per-project
                  if (entry.take_id) setSelectedTakeId(entry.take_id);
                }}
              />
            )}
          </div>
        }
```

Add a CSS rule for `.left-rail` in `recoil/console-v2/packages/desktop/src/styles/shell.css` (append):

```css
.left-rail {
  display: flex;
  flex-direction: column;
  min-height: 0;
  height: 100%;
}

.left-rail > .panel {
  flex: 1 1 auto;
  min-height: 0;
  overflow: auto;
}
```

### Files (new)

#### 4e. `recoil/console-v2/packages/desktop/src/shell/ProjectPicker.tsx`

```tsx
/**
 * ProjectPicker — leading breadcrumb segment.
 *
 * Click opens a popover listing all projects from `useProjects()`. Selecting
 * a row fires `onSelect(projectId)` — App.tsx's handleProjectChange runs the
 * full reset sequence (abort, evict cache, clear selection, write
 * localStorage, respawn ttyd).
 *
 * Stays visible when the left rail collapses (`cmd-\`) — picker lives in
 * the breadcrumb, not the rail.
 */
import { useEffect, useRef, useState } from "react";

import type { Project } from "../data";
import "./project-picker.css";

interface Props {
  projects: Project[];
  activeProjectId: string | null;
  onSelect: (projectId: string) => void;
}

export function ProjectPicker({ projects, activeProjectId, onSelect }: Props) {
  const [open, setOpen] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);

  const active = projects.find((p) => (p.id as string) === activeProjectId);
  const label = active?.name ?? "no project selected";

  useEffect(() => {
    if (!open) return;
    const onDoc = (e: MouseEvent) => {
      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
        setOpen(false);
      }
    };
    document.addEventListener("mousedown", onDoc);
    return () => document.removeEventListener("mousedown", onDoc);
  }, [open]);

  return (
    <div ref={containerRef} className="project-picker" data-testid="project-picker">
      <button
        className="pp-trigger"
        onClick={() => setOpen((o) => !o)}
        aria-haspopup="listbox"
        aria-expanded={open}
        data-testid="project-picker-trigger"
      >
        <span className="pp-label">{label}</span>
        <span className="pp-caret">▾</span>
      </button>
      {open && (
        <ul className="pp-popover" role="listbox" data-testid="project-picker-popover">
          {projects.length === 0 ? (
            <li className="pp-empty">no projects</li>
          ) : (
            projects.map((p) => {
              const pid = p.id as string;
              const isActive = pid === activeProjectId;
              return (
                <li key={pid}>
                  <button
                    className={`pp-row ${isActive ? "active" : ""}`}
                    onClick={() => {
                      setOpen(false);
                      if (pid !== activeProjectId) onSelect(pid);
                    }}
                    data-testid={`project-picker-row-${pid}`}
                  >
                    <span className="pp-row-name">{p.name}</span>
                    {p.name !== pid && <span className="pp-row-slug">{pid}</span>}
                  </button>
                </li>
              );
            })
          )}
        </ul>
      )}
    </div>
  );
}
```

#### 4f. `recoil/console-v2/packages/desktop/src/shell/project-picker.css`

```css
/* ProjectPicker — leading breadcrumb segment. Matches lineage.css density. */

.project-picker {
  position: relative;
  display: inline-flex;
  align-items: center;
}

.pp-trigger {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 2px 8px;
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--r-sm, 3px);
  font-family: var(--mono, monospace);
  font-size: 11px;
  color: var(--fg-1);
  cursor: pointer;
}

.pp-trigger:hover {
  border-color: var(--border-strong, rgba(255, 255, 255, 0.12));
  background: var(--bg-2, rgba(255, 255, 255, 0.03));
}

.pp-label {
  font-weight: 500;
}

.pp-caret {
  font-size: 9px;
  color: var(--fg-3);
}

.pp-popover {
  position: absolute;
  top: calc(100% + 2px);
  left: 0;
  z-index: 50;
  min-width: 200px;
  max-height: 360px;
  overflow: auto;
  margin: 0;
  padding: 4px 0;
  list-style: none;
  background: var(--bg-1, #1a1a1a);
  border: 1px solid var(--border-strong, rgba(255, 255, 255, 0.12));
  border-radius: var(--r, 5px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}

.pp-row {
  display: flex;
  align-items: baseline;
  gap: 8px;
  width: 100%;
  padding: 4px 10px;
  background: transparent;
  border: none;
  font-family: var(--mono, monospace);
  font-size: 11px;
  color: var(--fg-1);
  text-align: left;
  cursor: pointer;
}

.pp-row:hover {
  background: var(--bg-2, rgba(255, 255, 255, 0.06));
}

.pp-row.active {
  color: var(--accent, #6c8cff);
}

.pp-row-name {
  font-weight: 500;
}

.pp-row-slug {
  font-size: 10px;
  color: var(--fg-3);
}

.pp-empty {
  padding: 6px 10px;
  font-family: var(--mono, monospace);
  font-size: 11px;
  color: var(--muted);
}
```

#### 4g. `recoil/console-v2/packages/desktop/src/nav/NavTabStrip.tsx`

```tsx
/**
 * NavTabStrip — `[HIERARCHY | RECENT]` toggle above the left rail body.
 *
 * Two tabs only. Controlled — App owns the active state.
 */
import "./nav.css";

interface Props {
  activeTab: "hierarchy" | "recents";
  onChange: (tab: "hierarchy" | "recents") => void;
}

export function NavTabStrip({ activeTab, onChange }: Props) {
  return (
    <div className="nav-tab-strip" data-testid="nav-tab-strip">
      <button
        className={`nts-tab ${activeTab === "hierarchy" ? "active" : ""}`}
        onClick={() => onChange("hierarchy")}
        data-testid="nav-tab-hierarchy"
      >
        HIERARCHY
      </button>
      <button
        className={`nts-tab ${activeTab === "recents" ? "active" : ""}`}
        onClick={() => onChange("recents")}
        data-testid="nav-tab-recents"
      >
        RECENT
      </button>
    </div>
  );
}
```

Add the corresponding CSS to `nav.css` (append):

```css
.nav-tab-strip {
  display: flex;
  border-bottom: 1px solid var(--border-soft, rgba(255, 255, 255, 0.06));
}

.nts-tab {
  flex: 1 1 0;
  padding: 6px 8px;
  background: transparent;
  border: none;
  border-bottom: 2px solid transparent;
  font-family: var(--mono, monospace);
  font-size: 10.5px;
  letter-spacing: 0.06em;
  color: var(--fg-3);
  cursor: pointer;
}

.nts-tab:hover {
  color: var(--fg-1);
}

.nts-tab.active {
  color: var(--fg-1);
  border-bottom-color: var(--accent, #6c8cff);
}
```

#### 4h. `recoil/console-v2/packages/desktop/src/nav/RecentsPanel.tsx`

```tsx
/**
 * RecentsPanel — mtime-sorted thumbnails for the active project.
 *
 * Each row: 48px thumbnail (default per synthesis J1), label, model + relative
 * time. onClick: setFocused(beat_id) + setSelectedTakeId(take_id). Stay on
 * Recents tab (no auto-flip).
 *
 * When `beat_id` is null (derivation failed at the backend), the row is grey
 * and non-interactive. Backend emits `recent_id_derivation_failed` for
 * telemetry on these rows.
 */
import type { RecentEntry } from "@recoil/contracts";
import "./recents.css";

interface Props {
  recents: RecentEntry[];
  onSelect: (entry: RecentEntry) => void;
}

function relativeTime(mtime: number): string {
  const now = Date.now() / 1000;
  const dt = now - mtime;
  if (dt < 60) return `${Math.round(dt)}s ago`;
  if (dt < 3600) return `${Math.round(dt / 60)}m ago`;
  if (dt < 86400) return `${Math.round(dt / 3600)}h ago`;
  return `${Math.round(dt / 86400)}d ago`;
}

export function RecentsPanel({ recents, onSelect }: Props) {
  return (
    <div className="panel recents-panel" data-testid="recents-panel">
      <div className="panel-header">
        <span className="title">RECENT</span>
        <span className="h-actions mono" style={{ color: "var(--fg-3)", fontSize: "10px" }}>
          {recents.length}
        </span>
      </div>
      <div className="panel-body">
        {recents.length === 0 ? (
          <div className="recents-empty">no recent media</div>
        ) : (
          recents.map((entry) => {
            const disabled = !entry.beat_id;
            return (
              <button
                key={entry.path}
                className={`recents-row ${disabled ? "disabled" : ""}`}
                disabled={disabled}
                onClick={() => onSelect(entry)}
                data-testid={`recents-row-${entry.path}`}
                title={disabled ? "id derivation failed — backend telemetry fired" : entry.path}
              >
                <span className="recents-thumb">
                  {entry.type === "image" ? (
                    <img src={entry.media_url} alt="" loading="lazy" />
                  ) : (
                    <span className="recents-thumb-video">▶</span>
                  )}
                </span>
                <span className="recents-meta">
                  <span className="recents-label">{entry.name}</span>
                  <span className="recents-sub">
                    <span className="recents-model">{entry.model ?? "—"}</span>
                    <span className="recents-time">{relativeTime(entry.mtime)}</span>
                  </span>
                </span>
              </button>
            );
          })
        )}
      </div>
    </div>
  );
}
```

#### 4i. `recoil/console-v2/packages/desktop/src/nav/recents.css`

```css
.recents-panel .panel-body {
  display: flex;
  flex-direction: column;
  padding: 0;
}

.recents-row {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 4px 8px;
  background: transparent;
  border: none;
  border-bottom: 1px solid var(--border-soft, rgba(255, 255, 255, 0.04));
  text-align: left;
  cursor: pointer;
  color: var(--fg-1);
}

.recents-row:hover:not(.disabled) {
  background: var(--bg-2, rgba(255, 255, 255, 0.04));
}

.recents-row.disabled {
  cursor: default;
  opacity: 0.4;
}

.recents-thumb {
  flex: 0 0 48px;
  width: 48px;
  height: 48px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg-3, rgba(255, 255, 255, 0.04));
  border-radius: var(--r-sm, 3px);
  overflow: hidden;
}

.recents-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.recents-thumb-video {
  font-size: 14px;
  color: var(--fg-3);
}

.recents-meta {
  display: flex;
  flex-direction: column;
  min-width: 0;
  gap: 2px;
  font-family: var(--mono, monospace);
}

.recents-label {
  font-size: 11px;
  color: var(--fg-1);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.recents-sub {
  display: flex;
  gap: 8px;
  font-size: 10px;
  color: var(--fg-3);
}

.recents-empty {
  padding: 12px 10px;
  font-family: var(--mono, monospace);
  font-size: 11px;
  color: var(--muted);
}
```

#### 4j. `recoil/console-v2/packages/desktop/src/shell/ProjectPicker.test.tsx`

```tsx
import { render, fireEvent, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";

import { ProjectPicker } from "./ProjectPicker";
import type { Project } from "../data";

const PROJECTS = [
  { id: "tartarus" as any, name: "Tartarus" } as Project,
  { id: "afterimage" as any, name: "Afterimage" } as Project,
];

describe("ProjectPicker", () => {
  it("renders active project name", () => {
    render(<ProjectPicker projects={PROJECTS} activeProjectId="tartarus" onSelect={() => {}} />);
    expect(screen.getByTestId("project-picker-trigger")).toHaveTextContent("Tartarus");
  });

  it("opens the popover and lists projects on click", () => {
    render(<ProjectPicker projects={PROJECTS} activeProjectId="tartarus" onSelect={() => {}} />);
    fireEvent.click(screen.getByTestId("project-picker-trigger"));
    expect(screen.getByTestId("project-picker-popover")).toBeInTheDocument();
    expect(screen.getByTestId("project-picker-row-afterimage")).toBeInTheDocument();
  });

  it("fires onSelect(projectId) and closes the popover", () => {
    const onSelect = vi.fn();
    render(<ProjectPicker projects={PROJECTS} activeProjectId="tartarus" onSelect={onSelect} />);
    fireEvent.click(screen.getByTestId("project-picker-trigger"));
    fireEvent.click(screen.getByTestId("project-picker-row-afterimage"));
    expect(onSelect).toHaveBeenCalledWith("afterimage");
    expect(screen.queryByTestId("project-picker-popover")).not.toBeInTheDocument();
  });

  it("does NOT fire onSelect when clicking the active row", () => {
    const onSelect = vi.fn();
    render(<ProjectPicker projects={PROJECTS} activeProjectId="tartarus" onSelect={onSelect} />);
    fireEvent.click(screen.getByTestId("project-picker-trigger"));
    fireEvent.click(screen.getByTestId("project-picker-row-tartarus"));
    expect(onSelect).not.toHaveBeenCalled();
  });
});
```

#### 4k. `recoil/console-v2/packages/desktop/src/nav/RecentsPanel.test.tsx`

```tsx
import { render, fireEvent, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";

import { RecentsPanel } from "./RecentsPanel";
import type { RecentEntry } from "@recoil/contracts";

const SAMPLE: RecentEntry[] = [
  {
    schemaVersion: 2,
    name: "shot_001_take7.png",
    path: "output/previs/ep_001/shot_001_take7.png",
    media_url: "/media/x.png",
    type: "image",
    mtime: Date.now() / 1000 - 60,
    status: "primary",
    status_color: "green",
    model: "gemini",
    cost: 0.04,
    beat_id: "EP001_SH01",
    episode_id: "EP001",
    take_id: "EP001_SH01_T007",
  },
  {
    schemaVersion: 2,
    name: "weird.png",
    path: "output/weird.png",
    media_url: "/media/weird.png",
    type: "image",
    mtime: Date.now() / 1000 - 30,
    status: "untracked",
    status_color: "gray",
    model: null,
    cost: null,
    beat_id: null,
    episode_id: null,
    take_id: null,
  },
];

describe("RecentsPanel", () => {
  it("renders entries", () => {
    render(<RecentsPanel recents={SAMPLE} onSelect={() => {}} />);
    expect(screen.getByText("shot_001_take7.png")).toBeInTheDocument();
  });

  it("fires onSelect when row is clicked", () => {
    const onSelect = vi.fn();
    render(<RecentsPanel recents={SAMPLE} onSelect={onSelect} />);
    fireEvent.click(screen.getByTestId("recents-row-output/previs/ep_001/shot_001_take7.png"));
    expect(onSelect).toHaveBeenCalledWith(SAMPLE[0]);
  });

  it("disables rows with null beat_id and does not fire onSelect", () => {
    const onSelect = vi.fn();
    render(<RecentsPanel recents={SAMPLE} onSelect={onSelect} />);
    const row = screen.getByTestId("recents-row-output/weird.png");
    expect(row).toBeDisabled();
    fireEvent.click(row);
    expect(onSelect).not.toHaveBeenCalled();
  });

  it("renders empty state when no recents", () => {
    render(<RecentsPanel recents={[]} onSelect={() => {}} />);
    expect(screen.getByText("no recent media")).toBeInTheDocument();
  });
});
```

#### 4l. `recoil/console-v2/packages/desktop/src/nav/NavTabStrip.test.tsx`

```tsx
import { render, fireEvent, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";

import { NavTabStrip } from "./NavTabStrip";

describe("NavTabStrip", () => {
  it("renders both tabs and marks the active one", () => {
    render(<NavTabStrip activeTab="hierarchy" onChange={() => {}} />);
    expect(screen.getByTestId("nav-tab-hierarchy")).toHaveClass("active");
    expect(screen.getByTestId("nav-tab-recents")).not.toHaveClass("active");
  });

  it("fires onChange when clicking a tab", () => {
    const onChange = vi.fn();
    render(<NavTabStrip activeTab="hierarchy" onChange={onChange} />);
    fireEvent.click(screen.getByTestId("nav-tab-recents"));
    expect(onChange).toHaveBeenCalledWith("recents");
  });
});
```

### What already exists (from prior phases)

From Phase 1:

- `handleProjectChange(newProjectId: string | null)` in `App.tsx` — full reset sequence.
- `abortControllerRef: useRef<AbortController | null>`.
- `WorkspaceState.expanded: Record<string, string[]>` — per-project expanded ids.
- `focusedProjectId` state initialized from `localStorage.getItem('recoil:lastProjectId')`.

From Phase 2:

- `adapter.getRecent(projectId, limit?, offset?, signal?): Promise<{files, total}>` on both adapters.
- `RecentEntry` Zod schema exported from `@recoil/contracts`.

From Phase 3:

- `adapter.getTakes(beatId, projectId, signal?)` and `adapter.getLineage(beatId, projectId, takeId?, signal?)` — required projectId, signal-aware.
- All scoped routes 400 on missing `projectId`.

### Validation

```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS

# Typecheck + tests
pnpm --filter @recoil/contracts codegen:check
pnpm --filter @recoil/desktop typecheck
pnpm --filter @recoil/desktop test

# Structural greps
grep -q "export function ProjectPicker" recoil/console-v2/packages/desktop/src/shell/ProjectPicker.tsx
grep -q "export function NavTabStrip" recoil/console-v2/packages/desktop/src/nav/NavTabStrip.tsx
grep -q "export function RecentsPanel" recoil/console-v2/packages/desktop/src/nav/RecentsPanel.tsx
grep -q "hierarchy-project-label" recoil/console-v2/packages/desktop/src/nav/HierarchyNavigator.tsx
grep -q "import { ProjectPicker }" recoil/console-v2/packages/desktop/src/App.tsx
grep -q "import { NavTabStrip }" recoil/console-v2/packages/desktop/src/App.tsx
grep -q "import { RecentsPanel }" recoil/console-v2/packages/desktop/src/App.tsx
grep -q "leftRailTab" recoil/console-v2/packages/desktop/src/App.tsx
grep -q "projectSegment" recoil/console-v2/packages/desktop/src/lib/use_breadcrumb.ts

# pytest still green
pytest recoil/api/tests/ -x

# vitest count grew by ~10 tests (ProjectPicker:4 + RecentsPanel:4 + NavTabStrip:2 = 10)
pnpm --filter @recoil/desktop test -- --reporter=verbose | tail -10
```

### Acceptance

- Cold reload picks up last project from localStorage and renders its name in the breadcrumb's leading segment.
- Click the picker → dropdown lists all projects from `useProjects()`. Click another → tree swaps to new project, ttyd kills + respawns, lineage cache evicts, selection clears.
- Left rail's `HIERARCHY` header has a project label row below it showing the active project name.
- Tree shows only the active project (other projects no longer at depth-0).
- Click `RECENT` tab → mtime-sorted entries render with 48px thumbs. Click a row → `setFocused(beat_id)` + `setSelectedTakeId(take_id)`. RECENT tab stays active.
- Rows with `beat_id === null` are grey + non-interactive (telemetry-only).
- `cmd-\` collapses the rail; breadcrumb picker stays visible.
- pytest 240 (unchanged from Phase 2 + 3). vitest +10 = ~120 passing.

### Scope boundary (do NOT)

- Do NOT modify `LineageStrip.tsx` or any file under `recoil/console-v2/packages/desktop/src/lineage/`.
- Do NOT modify `_build_manifest_lineage` in `lineage.py`.
- Do NOT add a `projectId` filter to `QueueInspector` or `/api/queue`.
- Do NOT change the Events SSE stream — Phase 4 does not touch `event_stream.ts`.
- Do NOT alter `Memory` tab scoping — Memory stays cross-project (A7).
- Do NOT introduce a "show all projects" toggle on the Hierarchy panel — single-project scope is the new behavior.
- Do NOT auto-flip the active tab from Recents → Hierarchy on click (A9 — stay on Recents).
- Do NOT touch `TerminalIframe.tsx` (Phase 1 already wired the key-based remount).

### Quality gates

`/bugfix` then `/simplify`.

---

## Phase 5: Audit Polish
depends_on: 4
engine: claude
estimated_minutes: 20
approximate_LOC: ~90
quality_gates: bugfix, simplify

### Goal

Land the four audit polish items: H5 events filter math (chip click filters actually take effect), M1 empty-takes state, M3 hierarchy truncation tooltip, X1 events timestamp `HH:MM:SS` with ISO tooltip. Add the "show all projects" toggle to the Events tab (defaults to OFF — active-project filter on).

### Files (touched)

#### 5a. `recoil/console-v2/packages/desktop/src/stage/TakesBrowser.tsx`

Add an empty-state when there are no takes. Insert after the `visible` filter (around line 41), before the `return` block:

After `const primaryId = primaryTake ? (primaryTake.id as string) : "—";`, add:

```tsx
  // Empty state — matches Lineage tab's `ls-empty` style. M1.
  if (takes.length === 0) {
    return (
      <div className="empty-state takes-empty" data-testid="takes-empty">
        no takes for this beat — pick a beat with generated takes
      </div>
    );
  }
```

Append CSS to whichever stylesheet TakesBrowser already imports (typically a stage.css or co-located file — if no such import exists, add it inline via styled `<div>`). For consistency, add to `recoil/console-v2/packages/desktop/src/styles/shell.css`:

```css
.takes-empty,
.events-empty {
  padding: 24px 18px;
  font-family: var(--mono, monospace);
  font-size: 11px;
  color: var(--muted);
}
```

#### 5b. `recoil/console-v2/packages/desktop/src/stage/EventsInspector.tsx`

Three changes:

(i) Fix the chip filter math so the rendered set always matches the active severities (H5). The current code already filters correctly (line 36-38) — the audit captured a UI bug where the rendered counts in the header didn't match the rendered rows. The fix is to derive the header from `filtered.length` not from `events.length`. Audit reads `261/401` → after the fix, clicking FAILURE should show only failure rows with header `85/401`.

(ii) Add the active-project filter (A7) with a `show all projects` toggle.

(iii) Reformat the timestamp inside `EventsList` — `HH:MM:SS` (X1) with `title` attribute holding the full ISO.

For EventsInspector.tsx (replace the file):

```tsx
/**
 * Events inspector — Phase 9, polished Phase 5 (audit fold-in).
 *
 * H5: filtered.length is the only header source.
 * A7: active-project filter (default ON) with "show all projects" toggle.
 */
import * as React from "react";
import type { EngineEvent, EventSeverity } from "@recoil/contracts";
import { EventsList } from "./EventsList";

const ALL: (EventSeverity | "all")[] = [
  "all",
  "failure",
  "warning",
  "fallback",
  "success",
  "info",
];

interface Props {
  events: EngineEvent[];
  focusedProjectId: string | null;
}

function eventBelongsToProject(ev: EngineEvent, projectId: string): boolean {
  // Event payload conventions vary; try project_id, projectId, and the
  // string match within `scope`. Backend should standardize on project_id
  // in payload long-term.
  const payload = (ev as unknown as { payload?: Record<string, unknown> }).payload;
  if (payload && typeof payload === "object") {
    const pid = (payload.project_id as string | undefined) ?? (payload.projectId as string | undefined);
    if (typeof pid === "string" && pid === projectId) return true;
  }
  return false;
}

export function EventsInspector({ events, focusedProjectId }: Props) {
  const [sev, setSev] = React.useState<Set<string>>(
    new Set(["failure", "warning", "fallback"]),
  );
  const [showAllProjects, setShowAllProjects] = React.useState(false);

  // 1. Project filter (active-project ON by default per A7).
  const projectFiltered = React.useMemo(() => {
    if (showAllProjects) return events;
    if (!focusedProjectId) return events;
    return events.filter((ev) => eventBelongsToProject(ev, focusedProjectId));
  }, [events, focusedProjectId, showAllProjects]);

  // 2. Severity filter.
  const filtered = sev.has("all")
    ? projectFiltered
    : projectFiltered.filter((e) => sev.has(e.severity));

  const counts = ALL.reduce(
    (acc, s) => {
      acc[s] =
        s === "all"
          ? projectFiltered.length
          : projectFiltered.filter((e) => e.severity === s).length;
      return acc;
    },
    {} as Record<string, number>,
  );

  const toggle = (s: string) =>
    setSev((prev) => {
      if (s === "all") return new Set(["all"]);
      const n = new Set(prev);
      n.delete("all");
      if (n.has(s)) n.delete(s);
      else n.add(s);
      return n.size === 0 ? new Set(["all"]) : n;
    });

  return (
    <>
      <div className="stage-section-head">
        <span>
          Events · {filtered.length}/{projectFiltered.length}
        </span>
        <span className="sub">
          all engine activity — completions, retries, fallbacks, eval, escalations
        </span>
        <label
          className="events-show-all"
          style={{ marginLeft: "auto", fontSize: "10px", color: "var(--fg-3)" }}
        >
          <input
            type="checkbox"
            checked={showAllProjects}
            onChange={(e) => setShowAllProjects(e.target.checked)}
            data-testid="events-show-all-projects"
          />
          {" "}show all projects
        </label>
      </div>
      <div className="events-filter-row">
        {ALL.map((s) => (
          <button
            key={s}
            className={`sev-chip sev-${s} ${sev.has(s) ? "on" : ""}`}
            onClick={() => toggle(s)}
          >
            <span>{s}</span>
            <span className="sev-chip-count tabular">{counts[s]}</span>
          </button>
        ))}
      </div>
      {filtered.length === 0 ? (
        <div className="empty-state events-empty" data-testid="events-empty">
          no events match the active filters
        </div>
      ) : (
        <EventsList events={filtered} />
      )}
    </>
  );
}
```

Update the `ArtifactStage.tsx` invocation of `<EventsInspector>` (line 228) to pass `focusedProjectId`:

In `recoil/console-v2/packages/desktop/src/stage/ArtifactStage.tsx`, find the `<EventsInspector events={events} />` line and add the prop:

```tsx
    body = <EventsInspector events={events} focusedProjectId={focusedProjectId} />;
```

Also add `focusedProjectId: string | null;` to the `Props` interface of `ArtifactStage` and pass it through from `App.tsx` (the App.tsx side already has `focusedProjectId` in scope — just add it to the prop list passed to `<ArtifactStage ...>`).

In `App.tsx`, on the `<ArtifactStage ...>` invocation, add:

```tsx
          focusedProjectId={focusedProjectId}
```

#### 5c. `recoil/console-v2/packages/desktop/src/stage/EventsList.tsx`

Update the timestamp rendering — `HH:MM:SS` with `title={iso}` (X1).

Find where the timestamp column is rendered. The current rendering likely uses something like `new Date(ev.ts).toISOString()`. Replace with:

```tsx
{(() => {
  const d = new Date(ev.ts);
  const short = d.toLocaleTimeString("en-US", { hour12: false });
  const iso = typeof ev.ts === "string" ? ev.ts : d.toISOString();
  return <span className="ev-ts" title={iso}>{short}</span>;
})()}
```

If the existing code uses a helper, edit the helper to return `{ short, iso }` and rendering wraps in a `<span title={iso}>{short}</span>`. The exact line numbers depend on the EventsList.tsx contents at `044b05c6`; the harness sub-agent reads the file and applies the equivalent edit.

#### 5d. `recoil/console-v2/packages/desktop/src/nav/HierarchyNavigator.tsx`

Add `title={shotName}` on the long shot-name span (M3) and ensure the existing `label` element has CSS truncation. Search for the `NavRow` rendering of beat rows (depth-3 `<NavRow ... label={b.name} />`).

The component already passes `label={b.name}` to `<NavRow>`. The fix needs to land on `NavRow.tsx` — the label needs to render with `title={label}` and truncate via CSS.

Open `recoil/console-v2/packages/desktop/src/nav/NavRow.tsx` and find the `<span className="label">{label}</span>` (or equivalent). Replace with:

```tsx
<span className="label" title={typeof label === "string" ? label : undefined}>
  {label}
</span>
```

Ensure the corresponding CSS has truncation. In `nav.css`, find the `.nav-row .label` rule and confirm it has:

```css
.nav-row .label {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
```

If the rule is missing or incomplete, add it.

### Files (new)

None.

### What already exists (from prior phases)

From Phase 4:

- `App.tsx` carries `focusedProjectId` and passes it to `<ArtifactStage>` (Phase 5 adds the prop) and `<HierarchyNavigator>`.
- `RecentsPanel`, `NavTabStrip`, `ProjectPicker` all mounted and tested.

### Validation

```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS

# Typecheck + tests
pnpm --filter @recoil/desktop typecheck
pnpm --filter @recoil/desktop test

# Structural greps
grep -q "takes-empty" recoil/console-v2/packages/desktop/src/stage/TakesBrowser.tsx
grep -q "showAllProjects" recoil/console-v2/packages/desktop/src/stage/EventsInspector.tsx
grep -q "events-show-all-projects" recoil/console-v2/packages/desktop/src/stage/EventsInspector.tsx
grep -q 'toLocaleTimeString("en-US"' recoil/console-v2/packages/desktop/src/stage/EventsList.tsx
grep -q "focusedProjectId" recoil/console-v2/packages/desktop/src/stage/EventsInspector.tsx
grep -q 'title={typeof label === "string"' recoil/console-v2/packages/desktop/src/nav/NavRow.tsx

# pytest still green
pytest recoil/api/tests/ -x

# vitest still green (~120)
pnpm --filter @recoil/desktop test
```

### Acceptance

- Focus a project with no takes for the active beat → empty state renders `no takes for this beat — pick a beat with generated takes`.
- Open Events tab → click `FAILURE` chip → only failure rows render. Header reads `<failure-count>/<project-filtered-total>`.
- Events tab default state: `show all projects` checkbox is OFF; events are project-scoped to `focusedProjectId`.
- Toggle `show all projects` ON → all events render (back to baseline behavior).
- Event timestamps display `HH:MM:SS` (no microseconds, no date). Hover any row → tooltip shows the full ISO.
- Hover a truncated `DEER_SMOKE_OR_FIRE_SEEDA…` shot row in the Hierarchy → tooltip shows the full name (M3).

### Scope boundary (do NOT)

- Do NOT modify `LineageStrip.tsx`, `_build_manifest_lineage`, or `_resolve_ref_url_from_label`.
- Do NOT add a `projectId` filter to `QueueInspector` — Queue stays cross-project.
- Do NOT scope the SSE stream to a single project — only the Events tab's rendering filters (A14 — stream stays cross-project).
- Do NOT scope Memory or Cost surfaces.
- Do NOT add a project filter to the `/api/events` endpoint — the filter is client-side.
- Do NOT modify the EventBus or any sanctioned-fallbacks registry.

### Quality gates

`/bugfix` then `/simplify`.

---

## Rollback procedure

Step 0 (harness runs once, before Phase 1):

```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS
git tag rollback/pre-console-v2-per-project
git push studio refs/tags/rollback/pre-console-v2-per-project
```

If post-build validation fails after the configured debug retries:

```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS
git reset --hard rollback/pre-console-v2-per-project
git push studio +HEAD:main
```

Per-phase diagnostic tags (`rollback/console-v2-per-project-phase-N`) are placed after each phase passes validation. These are forensic only — JT can `git diff phase-N..phase-N+1` to inspect per-phase changes. They are NOT for partial shipping; the build either fully lands or fully rolls back.

---

## Quality gates after each phase

After validation passes for a phase, the harness runs:

1. `/bugfix` quality pass — verify the fix targets the live code path (per `feedback-never-skip-quality-passes` memory).
2. `/simplify` quality pass — reduce LOC, remove redundancy, fold helpers where natural.

Both are mandatory. Skipping either is a contract violation per JT's memory.

---

## Post-build manual smoke (JT)

After the harness writes `BUILD COMPLETE`:

- Cold reload Console v2 (`http://localhost:5173/`) → picker shows last project from localStorage.
- Click picker → dropdown lists all 5 projects → click another → tree swaps, ttyd respawns, breadcrumb updates.
- `cmd-\` collapses left rail → breadcrumb picker still visible → switch project still works.
- Click `RECENT` tab → mtime-sorted entries load → click a row → main stage updates, RECENT tab stays active.
- Pick a beat with no takes → empty state renders.
- Open Events tab → click `FAILURE` chip → only failures render; header math matches; toggle `show all projects` ON → events cross-project again.
- Hover a truncated `DEER_*` shot in Hierarchy → tooltip shows full name.
- Manual browse of a tartarus episode → zero `episode_id_derived_from_filename_prefix` events.
- Right rail terminal: no leaked websockets after 3 picker switches; no `session id capture timeout` cascade.

---

**END OF BUILD_SPEC — ready for /harness consumption.**
