Phase 2: Bridge + Data Layer

# Bridge + Data Layer — GitLab → Beads Integration

**Context:** This phase implements the heart of Mission Control — the bridge that converts GitLab events into beads tasks, manages the mapping file, handles crash recovery, and maintains data integrity.

**Why this matters:**
The bridge ensures no GitLab activity is lost and no duplicate tasks are created. It's the reliability layer that lets users trust MC with their work.

**Duration estimate:** 2-3 days

**Core components:**
1. **State Machine** — Lifecycle: (new) → active → suspect_orphan → closed
2. **Mapping File** — Persistent {gitlab_key} → {bead_id} mapping
3. **Two-Strike Rule** — Items must be missing 2 consecutive reconciliations to auto-close
4. **Crash Recovery** — Write-ahead pattern with pending flag
5. **Reconciliation** — Periodic full sync to heal missed events
6. **Decision Logging** — Append-only log of all user decisions

**Invariants (must ALWAYS hold):**
- INV-1: No duplicate beads (each key maps to exactly one bead)
- INV-2: No orphan beads (every bead_id exists in beads)
- INV-3: No false closes (two-strike rule enforced)
- INV-4: Cursor monotonicity (never moves backward)

**Dependencies:**
- Requires Phase 1 (Foundation) complete
- Blocks all view phases (Focus, Queue, etc.)

**Acceptance criteria:**
- Bridge creates beads from GitLab events
- Two-strike auto-close works correctly
- Crash recovery handles all failure scenarios
- Reconciliation heals missed events
- Single-instance lock prevents race conditions
- Decision log captures all actions


Display raw lore data in UI

# Basic UI Showing Lore Data (Visual Verification)

**Parent epic:** Phase 1: Foundation

**Why this task:**
Before building real views, we need to prove the full data pipeline works:
Frontend → Tauri IPC → Rust backend → lore CLI → parsed data → back to UI

This is visual verification (no automated tests) — we're proving plumbing works.

**Implementation:**

1. **Tauri command to fetch lore data:**
   ```rust
   // src-tauri/src/commands/work_items.rs
   #[tauri::command]
   pub async fn get_lore_data(
       lore: State<'_, Box<dyn LoreCli>>,
   ) -> Result<LoreMeResponse, String> {
       lore.me().map_err(|e| e.to_string())
   }
   ```

2. **Frontend hook for data fetching:**
   ```typescript
   // src/hooks/useLoreData.ts
   import { invoke } from '@tauri-apps/api/core';
   import { useQuery } from '@tanstack/react-query';

   export function useLoreData() {
       return useQuery({
           queryKey: ['lore-data'],
           queryFn: () => invoke<LoreMeResponse>('get_lore_data'),
           refetchInterval: false, // Manual refetch on lore-db-changed
       });
   }
   ```

3. **Listen for file watcher events:**
   ```typescript
   // src/hooks/useTauriEvents.ts
   import { listen } from '@tauri-apps/api/event';

   export function useLoreRefresh(queryClient: QueryClient) {
       useEffect(() => {
           const unlisten = listen('lore-db-changed', () => {
               queryClient.invalidateQueries({ queryKey: ['lore-data'] });
           });

           return () => { unlisten.then(fn => fn()); };
       }, [queryClient]);
   }
   ```

4. **Basic debug UI component:**
   ```tsx
   // src/components/DebugView.tsx
   export function DebugView() {
       const { data, isLoading, error } = useLoreData();

       if (isLoading) return <div>Loading...</div>;
       if (error) return <div>Error: {error.message}</div>;

       return (
           <div className="p-4 font-mono text-sm">
               <h2>Reviews ({data.mrs.reviewing.length})</h2>
               <pre>{JSON.stringify(data.mrs.reviewing, null, 2)}</pre>

               <h2>Issues ({data.issues.length})</h2>
               <pre>{JSON.stringify(data.issues, null, 2)}</pre>

               <h2>Since Last Check ({data.since_last_check.length})</h2>
               <pre>{JSON.stringify(data.since_last_check, null, 2)}</pre>
           </div>
       );
   }
   ```

**Verification checklist:**
- [ ] App starts without errors
- [ ] Data appears in UI after ~1 second
- [ ] Running `lore sync` causes UI to refresh
- [ ] Error states show meaningful messages
- [ ] Console has no TypeScript errors

**Acceptance criteria:**
- Full data pipeline works end-to-end
- UI shows raw JSON data from lore
- File watcher triggers refresh
- Ready to build real views on top


Implement Zustand store with persistence

# Zustand Store with Persistence

**Parent epic:** Phase 1: Foundation

**Purpose:** Central state management for MC. Zustand provides simple, scalable state with persistence support.

**Store slices:**

```typescript
interface MCStore {
  // Focus state
  focusId: string | null;
  setFocus: (id: string) => void;
  clearFocus: () => void;

  // Items (cached from lore + manual)
  items: WorkItem[];
  setItems: (items: WorkItem[]) => void;
  updateItem: (id: string, updates: Partial<WorkItem>) => void;

  // Queue order (manual priority)
  queueOrder: string[];
  reorderQueue: (fromIndex: number, toIndex: number) => void;

  // UI state
  activeView: 'focus' | 'queue' | 'inbox' | 'settings';
  setActiveView: (view: string) => void;

  // Sync state
  syncStatus: SyncState;
  lastSync: Date | null;
  syncError: string | null;
  setSyncStatus: (status: SyncState, error?: string) => void;
}
```

**TDD: Store tests (RED first):**

```typescript
// tests/unit/store.test.ts
describe('MCStore', () => {
  beforeEach(() => {
    useStore.setState(initialState);
  });

  describe('focus', () => {
    it('sets focus id', () => {
      const { setFocus } = useStore.getState();
      setFocus('br-123');

      expect(useStore.getState().focusId).toBe('br-123');
    });

    it('clears focus', () => {
      useStore.setState({ focusId: 'br-123' });
      const { clearFocus } = useStore.getState();

      clearFocus();

      expect(useStore.getState().focusId).toBeNull();
    });
  });

  describe('queue order', () => {
    it('reorders items', () => {
      useStore.setState({ queueOrder: ['a', 'b', 'c', 'd'] });
      const { reorderQueue } = useStore.getState();

      reorderQueue(2, 0); // Move 'c' to front

      expect(useStore.getState().queueOrder).toEqual(['c', 'a', 'b', 'd']);
    });

    it('persists order to state.json', async () => {
      const { reorderQueue } = useStore.getState();
      reorderQueue(0, 1);

      // Wait for persistence
      await new Promise(r => setTimeout(r, 100));

      const saved = await invoke('read_state');
      expect(saved.queueOrder).toEqual(useStore.getState().queueOrder);
    });
  });

  describe('items', () => {
    it('updates single item', () => {
      useStore.setState({ items: [{ id: '1', title: 'Test' }] });
      const { updateItem } = useStore.getState();

      updateItem('1', { snoozedUntil: new Date() });

      expect(useStore.getState().items[0].snoozedUntil).toBeDefined();
    });
  });

  describe('persistence', () => {
    it('loads state from state.json on init', async () => {
      const savedState = { focusId: 'br-saved', queueOrder: ['a', 'b'] };
      vi.mocked(invoke).mockResolvedValue(savedState);

      await initStore();

      expect(useStore.getState().focusId).toBe('br-saved');
    });
  });
});
```

**Implementation:**

```typescript
// src/store/index.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';

// Custom storage that uses Tauri backend
const tauriStorage = createJSONStorage(() => ({
  getItem: async (name) => {
    const state = await invoke('read_state');
    return JSON.stringify(state);
  },
  setItem: async (name, value) => {
    await invoke('write_state', { state: JSON.parse(value) });
  },
  removeItem: async (name) => {
    await invoke('clear_state');
  },
}));

export const useStore = create<MCStore>()(
  persist(
    (set, get) => ({
      // Focus
      focusId: null,
      setFocus: (id) => set({ focusId: id }),
      clearFocus: () => set({ focusId: null }),

      // Items
      items: [],
      setItems: (items) => set({ items }),
      updateItem: (id, updates) => set(state => ({
        items: state.items.map(i => i.id === id ? { ...i, ...updates } : i)
      })),

      // Queue order
      queueOrder: [],
      reorderQueue: (from, to) => set(state => {
        const newOrder = [...state.queueOrder];
        const [removed] = newOrder.splice(from, 1);
        newOrder.splice(to, 0, removed);
        return { queueOrder: newOrder };
      }),

      // UI
      activeView: 'focus',
      setActiveView: (view) => set({ activeView: view }),

      // Sync
      syncStatus: 'synced',
      lastSync: null,
      syncError: null,
      setSyncStatus: (status, error) => set({
        syncStatus: status,
        syncError: error,
        lastSync: status === 'synced' ? new Date() : get().lastSync
      }),
    }),
    {
      name: 'mc-state',
      storage: tauriStorage,
      partialize: (state) => ({
        focusId: state.focusId,
        queueOrder: state.queueOrder,
        activeView: state.activeView,
      }),
    }
  )
);
```

**Acceptance criteria:**
- All store actions work correctly
- State persists to ~/.local/share/mc/state.json
- Loads persisted state on startup
- Queue order persists across sessions


Implement decision log infrastructure

# Decision Logging Infrastructure (RED → GREEN)

**Parent epic:** Phase 2: Bridge + Data Layer

**Philosophy:** Manual-first, learn from data. We don't know the right prioritization algorithm yet. Instead:
1. User manually sets THE ONE THING
2. MC logs every decision with context and reasoning
3. Post-process logs to extract patterns (future)
4. Eventually codify patterns into suggestions (future)

**Log file:** `~/.local/share/mc/decision_log.jsonl`

**Actions to log:**
| Action | What to Capture |
|--------|-----------------|
| `set_focus` | Which bead, why, what else was available |
| `reorder` | Old order, new order, why |
| `defer` | Which bead, duration, why |
| `snooze` | Which bead, until when, why |
| `skip` | Which bead, why (explicitly chose not to do it) |
| `complete` | Which bead, duration if tracked, notes |
| `create_manual` | New bead from quick capture |
| `change_priority` | Old priority, new priority, why |

**TDD: Decision log tests (RED first):**

```rust
#[test]
fn log_entry_appended() {
    let temp = tempfile::tempdir().unwrap();
    let log_path = temp.path().join("decisions.jsonl");
    let logger = DecisionLogger::new(&log_path)?;

    logger.log(DecisionEntry {
        action: Action::SetFocus,
        bead_id: "br-x7f".into(),
        reason: Some("Sarah is blocked".into()),
        tags: vec!["blocking".into(), "urgent".into()],
        context: context_snapshot(),
    })?;

    let content = std::fs::read_to_string(&log_path)?;
    let entries: Vec<DecisionEntry> = content.lines()
        .map(|l| serde_json::from_str(l).unwrap())
        .collect();

    assert_eq!(entries.len(), 1);
    assert_eq!(entries[0].action, Action::SetFocus);
}

#[test]
fn multiple_logs_append_not_overwrite() {
    let temp = tempfile::tempdir().unwrap();
    let log_path = temp.path().join("decisions.jsonl");
    let logger = DecisionLogger::new(&log_path)?;

    logger.log(entry1)?;
    logger.log(entry2)?;
    logger.log(entry3)?;

    let content = std::fs::read_to_string(&log_path)?;
    assert_eq!(content.lines().count(), 3);
}

#[test]
fn context_snapshot_captured() {
    let temp = tempfile::tempdir().unwrap();
    let log_path = temp.path().join("decisions.jsonl");
    let logger = DecisionLogger::new(&log_path)?;

    let context = DecisionContext {
        queue_snapshot: vec![item1, item2, item3],
        time_of_day: TimeOfDay::Morning,
        day_of_week: Weekday::Tuesday,
        items_completed_today: 3,
        focus_session_duration: Some(Duration::from_secs(2700)), // 45 min
    };

    logger.log(DecisionEntry {
        context,
        ..Default::default()
    })?;

    let content = std::fs::read_to_string(&log_path)?;
    let entry: DecisionEntry = serde_json::from_str(content.lines().next().unwrap())?;

    assert_eq!(entry.context.items_completed_today, 3);
}
```

**Implementation (GREEN):**

```rust
#[derive(Serialize, Deserialize, Debug)]
pub struct DecisionEntry {
    pub timestamp: DateTime<Utc>,
    pub action: Action,
    pub bead_id: String,
    pub reason: Option<String>,
    pub tags: Vec<String>,
    pub context: DecisionContext,
}

#[derive(Serialize, Deserialize, Debug)]
pub struct DecisionContext {
    pub queue_snapshot: Vec<QueueItem>,
    pub time_of_day: TimeOfDay,
    pub day_of_week: Weekday,
    pub focus_session_duration: Option<Duration>,
    pub items_completed_today: u32,
    pub previous_focus: Option<String>,
}

pub struct DecisionLogger {
    file: std::fs::File,
}

impl DecisionLogger {
    pub fn new(path: &Path) -> Result<Self> {
        let file = OpenOptions::new()
            .create(true)
            .append(true)
            .open(path)?;
        Ok(Self { file })
    }

    pub fn log(&self, entry: DecisionEntry) -> Result<()> {
        let mut entry = entry;
        entry.timestamp = Utc::now();

        let json = serde_json::to_string(&entry)?;
        writeln!(&self.file, "{}", json)?;

        Ok(())
    }
}
```

**Example log entry:**
```json
{
  "timestamp": "2026-02-25T10:30:00Z",
  "action": "set_focus",
  "bead_id": "br-x7f",
  "reason": "Sarah pinged me on Slack, she's blocked",
  "tags": ["blocking", "urgent"],
  "context": {
    "previous_focus": "br-a3b",
    "queue_size": 12,
    "time_of_day": "morning",
    "day_of_week": "Tuesday",
    "items_completed_today": 3
  }
}
```

**Log retention (v1):**
- Accept unbounded growth (18 MB/year at 100 decisions/day)
- Future: rotate at 50 MB, time-based pruning

**Acceptance criteria:**
- Append-only JSONL format
- All action types logged with context
- No data loss on crash (flush after each write)
- Queryable for pattern extraction (future)


Implement QueueView container

# QueueView Container

**Parent epic:** Phase 4: Queue + Inbox Views

**Purpose:** Container component that assembles QueueList with filtering, batch trigger, and navigation.

**TDD: QueueView tests (RED first):**

```typescript
// tests/views/QueueView.test.tsx
describe('QueueView', () => {
  it('renders QueueList with items grouped by type', () => {
    const store = createStore({ items: mockItems });
    render(<QueueView />, { wrapper: StoreProvider(store) });

    expect(screen.getByText('REVIEWS (2)')).toBeInTheDocument();
    expect(screen.getByText('ASSIGNED ISSUES (1)')).toBeInTheDocument();
  });

  it('filters items when filter applied', async () => {
    const store = createStore({ items: mockItems });
    render(<QueueView />, { wrapper: StoreProvider(store) });

    await userEvent.keyboard('{Meta>}k{/Meta}');
    await userEvent.type(screen.getByRole('textbox'), 'type:review{Enter}');

    expect(screen.getByText('REVIEWS (2)')).toBeInTheDocument();
    expect(screen.queryByText('ASSIGNED ISSUES')).not.toBeInTheDocument();
  });

  it('hides snoozed items by default', () => {
    const items = [
      ...mockItems,
      { id: '99', snoozedUntil: new Date(Date.now() + 3600000) }
    ];
    const store = createStore({ items });
    render(<QueueView />, { wrapper: StoreProvider(store) });

    expect(screen.queryByText(items[2].title)).not.toBeInTheDocument();
  });

  it('shows empty state when no items', () => {
    const store = createStore({ items: [] });
    render(<QueueView />, { wrapper: StoreProvider(store) });

    expect(screen.getByText(/Queue is empty/)).toBeInTheDocument();
  });

  it('batch button enters batch mode', async () => {
    const setActiveView = vi.fn();
    const store = createStore({ items: mockReviewItems, setActiveView });
    render(<QueueView />, { wrapper: StoreProvider(store) });

    await userEvent.click(screen.getByRole('button', { name: /batch all/i }));

    // Should show batch mode (or navigate to it)
    expect(screen.getByText('BATCH:')).toBeInTheDocument();
  });
});
```

**Implementation:**

```tsx
// src/views/QueueView.tsx
export function QueueView() {
  const { items, queueOrder, setFocus, setActiveView } = useStore();
  const [filter, setFilter] = useState<Filter | null>(null);
  const [showCommandPalette, setShowCommandPalette] = useState(false);
  const [batchType, setBatchType] = useState<ItemType | null>(null);

  useKeyboardShortcuts({
    'mod+k': () => setShowCommandPalette(true),
  });

  // Filter and sort items
  const visibleItems = useMemo(() => {
    let filtered = items
      .filter(i => !i.snoozedUntil || new Date(i.snoozedUntil) < new Date())
      .filter(i => !i.skippedToday);

    if (filter?.type) {
      filtered = filtered.filter(i => i.type === filter.type);
    }

    // Sort by queue order, then by age
    return filtered.sort((a, b) => {
      const aIdx = queueOrder.indexOf(a.id);
      const bIdx = queueOrder.indexOf(b.id);
      if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx;
      if (aIdx !== -1) return -1;
      if (bIdx !== -1) return 1;
      return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
    });
  }, [items, queueOrder, filter]);

  const handleSetFocus = (id: string) => {
    setFocus(id);
    setActiveView('focus');
  };

  const handleBatchStart = (type: ItemType) => {
    setBatchType(type);
  };

  if (batchType) {
    const batchItems = visibleItems.filter(i => i.type === batchType);
    return (
      <BatchMode
        items={batchItems}
        type={batchType}
        onComplete={handleComplete}
        onSkip={handleSkip}
        onExit={() => setBatchType(null)}
      />
    );
  }

  if (visibleItems.length === 0) {
    return (
      <div className="flex flex-col items-center justify-center h-full">
        <Inbox className="w-16 h-16 text-muted-foreground mb-4" />
        <h2 className="text-xl font-semibold">Queue is empty</h2>
        <p className="text-muted-foreground">All caught up!</p>
      </div>
    );
  }

  return (
    <div className="p-4" data-testid="queue-view">
      <div className="flex justify-between items-center mb-4">
        <h1 className="text-xl font-bold">Queue</h1>
        <Button variant="outline" size="sm" onClick={() => setShowCommandPalette(true)}>
          Filter <kbd className="ml-2">⌘K</kbd>
        </Button>
      </div>

      {filter && (
        <div className="mb-4 flex items-center gap-2">
          <Badge>{formatFilter(filter)}</Badge>
          <Button variant="ghost" size="sm" onClick={() => setFilter(null)}>
            Clear
          </Button>
        </div>
      )}

      <QueueList
        items={visibleItems}
        onSetFocus={handleSetFocus}
        onBatchStart={handleBatchStart}
        onReorder={handleReorder}
      />

      {showCommandPalette && (
        <CommandPalette
          items={visibleItems}
          onFilter={setFilter}
          onSelect={handleSetFocus}
          onClose={() => setShowCommandPalette(false)}
        />
      )}
    </div>
  );
}
```

**Acceptance criteria:**
- Items display grouped by type
- Filter works via command palette
- Snoozed items hidden
- Empty state shows
- Batch mode triggers correctly
- Click sets focus and navigates


Implement two-strike auto-close for GitLab state changes

# Two-Strike Auto-Close Rule (RED → GREEN)

**Parent epic:** Phase 2: Bridge + Data Layer

**Problem being solved:**
What if GitLab's API hiccups and temporarily says "you have no reviews"? Without protection, we'd delete all your tasks.

**Solution:**
Items must be missing for TWO consecutive reconciliations before auto-close.

| Check #1 | Check #2 | Result |
|----------|----------|--------|
| Missing | Missing | Close the task (confirmed gone) |
| Missing | Found | Keep it (was just a glitch) |
| Found | — | Keep it (still active) |

**TDD: Two-strike tests (RED first):**

```rust
#[test]
fn first_miss_sets_suspect_orphan() {
    let mut bridge = setup_with_mapping("mr:gitlab:123:847", "br-x");
    let lore_items = vec![]; // Item missing

    bridge.reconcile(&lore_items)?;

    let entry = bridge.mapping.get("mr:gitlab:123:847").unwrap();
    assert!(entry.suspect_orphan, "First miss should set suspect_orphan");
    assert!(bridge.beads.exists("br-x")?, "Bead should NOT be closed yet");
}

#[test]
fn second_miss_closes_bead() {
    let mut bridge = setup_with_suspect_orphan("mr:gitlab:123:847", "br-x");
    let lore_items = vec![]; // Still missing

    bridge.reconcile(&lore_items)?;

    assert!(!bridge.mapping.contains_key("mr:gitlab:123:847"), "Entry should be removed");
    assert!(!bridge.beads.exists("br-x")?, "Bead should be closed");
}

#[test]
fn reappearance_clears_suspect() {
    let mut bridge = setup_with_suspect_orphan("mr:gitlab:123:847", "br-x");
    let lore_items = vec![mr_item("mr:gitlab:123:847")]; // Item reappears

    bridge.reconcile(&lore_items)?;

    let entry = bridge.mapping.get("mr:gitlab:123:847").unwrap();
    assert!(!entry.suspect_orphan, "Reappearance should clear suspect flag");
}

#[test]
fn auto_close_includes_reason() {
    let mut beads = MockBeadsCli::new();
    let mut bridge = setup_with_suspect_orphan_and(&mut beads, "mr:gitlab:123:847", "br-x");

    bridge.reconcile(&[])?;

    let close_call = beads.calls().iter().find(|c| matches!(c, BeadsCall::Close { .. }));
    assert!(close_call.is_some());
    if let Some(BeadsCall::Close { reason, .. }) = close_call {
        assert!(reason.contains("MR") || reason.contains("GitLab"));
    }
}

#[test]
fn invariant_inv3_no_false_closes() {
    // Fuzz test: random sequences of present/missing should never violate INV-3
    let mut bridge = setup_with_mapping("mr:gitlab:123:847", "br-x");

    // Single miss should never close
    bridge.reconcile(&[])?;
    assert!(bridge.beads.exists("br-x")?);

    // Reappearance after single miss should preserve bead
    bridge.reconcile(&[mr_item("mr:gitlab:123:847")])?;
    assert!(bridge.beads.exists("br-x")?);

    // Only double-miss should close
    bridge.reconcile(&[])?; // Miss 1
    bridge.reconcile(&[])?; // Miss 2 → close
    assert!(!bridge.beads.exists("br-x")?);
}
```

**Implementation (GREEN):**

```rust
impl<L: LoreCli, B: BeadsCli> BridgeState<L, B> {
    pub fn reconcile(&mut self, current_items: &[LoreItem]) -> Result<()> {
        let expected_keys: HashSet<_> = current_items
            .iter()
            .map(|i| i.to_mapping_key())
            .collect();

        let mut to_close = vec![];

        for (key, entry) in self.mapping.iter_mut() {
            if expected_keys.contains(key) {
                // Item still exists — clear any suspect flag
                entry.suspect_orphan = false;
            } else if entry.suspect_orphan {
                // Second miss — schedule for closure
                to_close.push((key.clone(), entry.bead_id.clone()));
            } else {
                // First miss — set suspect flag
                entry.suspect_orphan = true;
            }
        }

        // Close confirmed orphans
        for (key, bead_id) in to_close {
            if let Some(id) = bead_id {
                self.beads.close(&id, "Item no longer in GitLab")?;
            }
            self.mapping.remove(&key);
        }

        // Add new items not in mapping
        for item in current_items {
            let key = item.to_mapping_key();
            if !self.mapping.contains_key(&key) {
                // ... create bead
            }
        }

        Ok(())
    }
}
```

**Acceptance criteria:**
- First miss only sets flag, never closes
- Second miss closes bead with descriptive reason
- Reappearance clears suspect flag
- Invariant INV-3 never violated


Phase 5: Batch Mode

# Batch Mode — Flow State for Similar Tasks

**Context:** Batch Mode enables rapid completion of similar items (e.g., all code reviews) by presenting them one at a time with streamlined actions.

**UX principle:** "You have 4 code reviews. Want to batch them? (~20 min)"

**Duration estimate:** 1-2 days

**Visual design:**
```
┌─────────────────────────────────────────────────────────────┐
│                     BATCH: CODE REVIEWS                      │
│                        1 of 4 · 25 min                       │
│                     ━━━━━━━━━━░░░░░░░░░░                     │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│     Fix authentication token refresh logic                  │
│     !847 in platform/core                                   │
│                                                             │
│     47 lines changed across 3 files                         │
│                                                             │
│     ┌───────────────┐  ┌───────────────┐  ┌───────────┐    │
│     │  Open in GL   │  │     Done      │  │   Skip    │    │
│     │      ⌘O       │  │      ⌘D       │  │    ⌘S     │    │
│     └───────────────┘  └───────────────┘  └───────────┘    │
│                                                             │
│                        ESC to exit batch                     │
└─────────────────────────────────────────────────────────────┘
```

**Scope includes:**
1. Full-screen batch interface
2. Progress bar tracking
3. Rapid completion flow (Done → next automatically)
4. Completion celebration (confetti, sound?)

**Behavior:**
- "Open in GL" opens review in browser
- "Done" marks complete, advances to next
- "Skip" advances without completing
- ESC exits batch at any point
- Completion shows celebration

**Dependencies:**
- Requires Phase 4 (Queue) for batch trigger
- Reuses FocusCard-like component

**Acceptance criteria:**
- Batch mode shows items one at a time
- Progress bar updates correctly
- Done/Skip advance flow
- ESC exits cleanly
- Completion celebration works


Implement drag-to-reorder with priority logging

# Drag-to-Reorder with Decision Logging (RED → GREEN)

**Parent epic:** Phase 4: Queue + Inbox Views

**Purpose:** Manual priority via drag and drop. Every reorder is logged with context for pattern learning.

**TDD: Drag reorder tests (RED first):**

```typescript
// tests/components/QueueList.test.tsx
describe('QueueList drag reorder', () => {
  it('reorders items on drag', async () => {
    const onReorder = vi.fn();
    render(<QueueList items={mockItems} onReorder={onReorder} />);

    const items = screen.getAllByTestId('queue-item');

    // Simulate drag item[2] to position 0
    await userEvent.pointer([
      { keys: '[MouseLeft>]', target: items[2] },
      { target: items[0] },
      { keys: '[/MouseLeft]' },
    ]);

    expect(onReorder).toHaveBeenCalledWith({
      itemId: '3',
      oldIndex: 2,
      newIndex: 0,
      oldOrder: ['1', '2', '3', '4'],
      newOrder: ['3', '1', '2', '4'],
    });
  });

  it('persists order after reorder', async () => {
    const mockPersist = vi.fn();
    render(<QueueList items={mockItems} onReorder={mockPersist} />);

    // Perform drag
    // ...

    // Verify persistence called
    expect(mockPersist).toHaveBeenCalled();

    // Re-render with new order - should match
    rerender(<QueueList items={reorderedItems} />);
    const items = screen.getAllByTestId('queue-item');
    expect(items[0]).toHaveTextContent('Issue #312');
  });

  it('logs reorder decision', async () => {
    const mockLogDecision = vi.fn();
    vi.mock('../store', () => ({ useStore: () => ({ logDecision: mockLogDecision }) }));

    render(<QueueList items={mockItems} />);

    // Perform drag
    // ...

    expect(mockLogDecision).toHaveBeenCalledWith(expect.objectContaining({
      action: 'reorder',
      old_order: ['1', '2', '3', '4'],
      new_order: ['3', '1', '2', '4'],
    }));
  });

  it('prompts for reason after reorder', async () => {
    render(<QueueList items={mockItems} />);

    // Perform drag
    // ...

    // ReasonPrompt should appear
    expect(screen.getByText(/Why did you move/)).toBeInTheDocument();
  });
});
```

**Implementation (GREEN):**

```tsx
// src/components/QueueList.tsx
import { DndContext, closestCenter, DragEndEvent } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';

export function QueueList({ items, onItemClick, onReorder }: QueueListProps) {
  const [showReasonPrompt, setShowReasonPrompt] = useState(false);
  const [pendingReorder, setPendingReorder] = useState<ReorderData | null>(null);

  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    if (!over || active.id === over.id) return;

    const oldIndex = items.findIndex(i => i.id === active.id);
    const newIndex = items.findIndex(i => i.id === over.id);

    const reorderData: ReorderData = {
      itemId: active.id as string,
      oldIndex,
      newIndex,
      oldOrder: items.map(i => i.id),
      newOrder: arrayMove(items, oldIndex, newIndex).map(i => i.id),
    };

    // Apply reorder immediately for responsiveness
    onReorder(reorderData);

    // Prompt for reason
    setPendingReorder(reorderData);
    setShowReasonPrompt(true);
  };

  const handleReasonSubmit = ({ reason, tags }) => {
    if (pendingReorder) {
      invoke('log_decision', {
        entry: {
          action: 'reorder',
          ...pendingReorder,
          reason,
          tags,
          context: captureContext(),
        }
      });
    }
    setShowReasonPrompt(false);
    setPendingReorder(null);
  };

  return (
    <DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
      <SortableContext items={items.map(i => i.id)} strategy={verticalListSortingStrategy}>
        {items.map(item => (
          <SortableQueueItem key={item.id} item={item} onClick={() => onItemClick(item.id)} />
        ))}
      </SortableContext>

      {showReasonPrompt && (
        <ReasonPrompt
          action="reorder"
          itemTitle={items.find(i => i.id === pendingReorder?.itemId)?.title ?? ''}
          onSubmit={handleReasonSubmit}
          onCancel={() => setShowReasonPrompt(false)}
        />
      )}
    </DndContext>
  );
}
```

**Acceptance criteria:**
- Drag and drop works smoothly
- Order persists across refreshes
- Reorder decision logged with context
- Reason prompt appears after reorder


Configure Vitest for frontend testing

# Vitest Configuration for React Frontend

**Parent epic:** Phase 0: Test Infrastructure

**Why Vitest:**
- Fast, native ESM support
- Jest-compatible API
- Great React Testing Library integration
- Works seamlessly with Vite

**What to configure:**
1. Install dependencies: vitest, @testing-library/react, @testing-library/user-event, jsdom
2. Create vitest.config.ts with:
   - jsdom environment for DOM testing
   - Coverage configuration (v8 provider)
   - Test file patterns (*.test.ts, *.test.tsx)
   - Setup file for global test utilities
3. Create test setup file (tests/setup.ts):
   - Import @testing-library/jest-dom matchers
   - Configure cleanup after each test
   - Set up mock for Tauri IPC
4. Add test scripts to package.json:
   - "test": "vitest run"
   - "test:watch": "vitest"
   - "test:coverage": "vitest run --coverage"

**Coverage targets:**
- Frontend hooks: 85%
- Frontend components: 70%

**Acceptance criteria:**
- Running `npm run test` executes Vitest
- Test files in tests/unit/ and tests/components/ are discovered
- Coverage report generates to coverage/ directory
- @testing-library matchers work (toBeInTheDocument, etc.)


Scaffold Tauri + Vite + React project

# Project Scaffolding — Tauri 2.0 + React 19 + Vite

**Parent epic:** Phase 1: Foundation

**Why this stack:**
- Tauri 2.0: Rust backend, tiny bundles (~15MB), native APIs, system tray, global hotkeys
- React 19: Latest concurrent features, huge ecosystem, AI-friendly for iteration
- Vite: Fast dev server, instant HMR, native ESM

**Scaffolding steps:**

1. **Initialize Tauri + Vite project:**
   ```bash
   npm create tauri-app@latest mission-control -- --template react-ts
   ```

2. **Install core dependencies:**
   ```bash
   npm install @tauri-apps/api @tauri-apps/plugin-shell
   npm install -D @tauri-apps/cli
   ```

3. **Install frontend dependencies:**
   ```bash
   npm install react@19 react-dom@19
   npm install @tanstack/react-query zustand
   npm install tailwindcss postcss autoprefixer
   npm install framer-motion
   ```

4. **Install shadcn/ui:**
   ```bash
   npx shadcn-ui@latest init
   ```

5. **Configure Tailwind:**
   - tailwind.config.js with content paths
   - Dark mode: 'class' strategy
   - postcss.config.js

6. **Configure Tauri (tauri.conf.json):**
   - Bundle identifier: com.mission-control.app
   - Window: hiddenTitle=true, decorations=false (custom titlebar)
   - System tray: enabled
   - Permissions for shell plugin, fs plugin

**File structure after scaffold:**
```
mission-control/
├── src-tauri/
│   ├── src/main.rs
│   ├── Cargo.toml
│   └── tauri.conf.json
├── src/
│   ├── App.tsx
│   └── main.tsx
├── package.json
└── vite.config.ts
```

**Acceptance criteria:**
- `npm run tauri dev` launches working app
- React renders in Tauri webview
- Tailwind classes work
- shadcn components available
- Basic folder structure matches PLAN.md specification


Implement app initialization and startup sequence

# App Initialization and Startup Sequence

**Parent epic:** Phase 1: Foundation

**Purpose:** Proper initialization order when MC launches, including state loading, CLI verification, and recovery.

**Startup sequence:**

```
1. Acquire single-instance lock
   └─ If locked → show "already running" dialog → exit

2. Create data directories
   └─ ~/.local/share/mc/

3. Load persisted state
   └─ state.json, settings.json, gitlab_bead_map.json
   └─ Handle missing/corrupt files gracefully

4. Verify CLI dependencies
   └─ Check lore --version
   └─ Check br --version
   └─ Show warning if missing

5. Run crash recovery
   └─ Check for pending mapping entries
   └─ Retry incomplete bead creations

6. Run full reconciliation (startup sync)
   └─ Fetch all open items from lore
   └─ Heal any missed events

7. Start file watcher
   └─ Watch lore.db for changes

8. Initialize Tauri app
   └─ Register global shortcuts
   └─ Set up system tray
   └─ Show main window
```

**TDD: Startup tests (RED first):**

```rust
// src-tauri/tests/startup_test.rs

#[tokio::test]
async fn startup_acquires_lock_first() {
    let temp = tempfile::tempdir().unwrap();
    let config = Config::with_data_dir(temp.path());

    let app1 = App::init(&config).await;
    assert!(app1.is_ok());

    let app2 = App::init(&config).await;
    assert!(matches!(app2, Err(InitError::AlreadyRunning)));
}

#[tokio::test]
async fn startup_creates_data_directories() {
    let temp = tempfile::tempdir().unwrap();
    let data_dir = temp.path().join("mc");
    let config = Config::with_data_dir(&data_dir);

    let _app = App::init(&config).await.unwrap();

    assert!(data_dir.exists());
    assert!(data_dir.join("gitlab_bead_map.json").exists()
            || data_dir.join(".gitkeep").exists());
}

#[tokio::test]
async fn startup_handles_missing_state_file() {
    let temp = tempfile::tempdir().unwrap();
    // Don't create state.json

    let app = App::init(&Config::with_data_dir(temp.path())).await;

    assert!(app.is_ok());
    assert_eq!(app.unwrap().state().focus_id, None);
}

#[tokio::test]
async fn startup_handles_corrupt_state_file() {
    let temp = tempfile::tempdir().unwrap();
    std::fs::write(temp.path().join("state.json"), "not json").unwrap();

    let app = App::init(&Config::with_data_dir(temp.path())).await;

    // Should recover gracefully, not crash
    assert!(app.is_ok());
}

#[tokio::test]
async fn startup_warns_on_missing_lore() {
    let app = App::init_with_cli_check(|cmd| {
        if cmd == "lore" { Err("not found".into()) }
        else { Ok(()) }
    }).await;

    assert!(app.is_ok());
    assert!(app.unwrap().warnings().contains(&Warning::LoreMissing));
}

#[tokio::test]
async fn startup_runs_crash_recovery() {
    let temp = tempfile::tempdir().unwrap();

    // Create mapping with pending entry
    let mapping = Mapping {
        mappings: hashmap! {
            "mr_review:...".into() => MappingEntry {
                bead_id: None,
                pending: true,
                ..Default::default()
            }
        },
        ..Default::default()
    };
    save_mapping(temp.path(), &mapping).unwrap();

    let beads = MockBeadsCli::new();
    let app = App::init_with_beads(beads.clone()).await.unwrap();

    // Should have retried the pending bead creation
    assert!(beads.was_called("create"));
}
```

**Implementation:**

```rust
// src-tauri/src/app.rs
pub struct App {
    lock: InstanceLock,
    state: AppState,
    mapping: Arc<Mutex<Mapping>>,
    orchestrator: Arc<Mutex<SyncOrchestrator>>,
    watcher: LoreDbWatcher,
    warnings: Vec<Warning>,
}

impl App {
    pub async fn init(config: &Config) -> Result<Self, InitError> {
        // 1. Acquire lock
        let lock = InstanceLock::acquire(&config.lock_path())
            .map_err(|_| InitError::AlreadyRunning)?;

        // 2. Create directories
        std::fs::create_dir_all(&config.data_dir())?;

        // 3. Load state
        let state = AppState::load(&config.state_path())
            .unwrap_or_default();
        let mapping = Mapping::load(&config.mapping_path())
            .unwrap_or_default();

        // 4. Verify CLIs
        let mut warnings = vec![];
        if !verify_cli("lore") {
            warnings.push(Warning::LoreMissing);
        }
        if !verify_cli("br") {
            warnings.push(Warning::BrMissing);
        }

        // 5. Crash recovery
        let mapping = Arc::new(Mutex::new(mapping));
        recover_pending_entries(&mapping, &beads).await?;

        // 6. Full reconciliation
        let orchestrator = SyncOrchestrator::new(lore, beads, mapping.clone());
        orchestrator.run_full_reconciliation().await?;

        // 7. Start watcher
        let watcher = LoreDbWatcher::new(&config.lore_db_path(), || {
            // Trigger sync
        })?;

        Ok(Self {
            lock,
            state,
            mapping,
            orchestrator: Arc::new(Mutex::new(orchestrator)),
            watcher,
            warnings,
        })
    }
}
```

**Acceptance criteria:**
- Single-instance lock works
- Directories created
- Corrupt state handled gracefully
- CLI warnings shown
- Crash recovery runs
- Reconciliation runs on startup


Write E2E tests for critical user flows

# E2E Tests for Critical Flows

**Parent epic:** Phase 7: Polish + E2E Tests

**Test files to create:**

### focus-flow.spec.ts
```typescript
import { test, expect } from '@playwright/test';

test.describe('Focus Flow', () => {
  test.beforeEach(async ({ page }) => {
    // Start app with mocked data
    process.env.MC_TEST_MODE = 'true';
    await page.goto('http://localhost:1420');
  });

  test('shows focus card on launch', async ({ page }) => {
    await expect(page.getByTestId('focus-card')).toBeVisible();
    await expect(page.getByRole('heading')).toContainText('Fix authentication');
  });

  test('Start opens GitLab URL', async ({ page }) => {
    const [newPage] = await Promise.all([
      page.waitForEvent('popup'),
      page.click('button:has-text("Start")'),
    ]);

    expect(newPage.url()).toContain('gitlab.com');
  });

  test('keyboard shortcut Enter triggers Start', async ({ page }) => {
    const [newPage] = await Promise.all([
      page.waitForEvent('popup'),
      page.keyboard.press('Enter'),
    ]);

    expect(newPage.url()).toContain('gitlab.com');
  });
});
```

### batch-mode.spec.ts
```typescript
test.describe('Batch Mode', () => {
  test('completing all items shows celebration', async ({ page }) => {
    // Navigate to queue
    await page.click('[data-testid="nav-queue"]');

    // Start batch
    await page.click('button:has-text("Batch All")');

    // Verify batch mode entered
    await expect(page.getByText('BATCH: CODE REVIEWS')).toBeVisible();

    // Complete all 4 items
    for (let i = 0; i < 4; i++) {
      await page.keyboard.press('Meta+d'); // Done
    }

    // Verify celebration
    await expect(page.getByText('All done!')).toBeVisible();
    await expect(page.getByTestId('confetti')).toBeVisible();
  });

  test('Escape exits batch mode', async ({ page }) => {
    await page.click('[data-testid="nav-queue"]');
    await page.click('button:has-text("Batch All")');

    await page.keyboard.press('Escape');

    await expect(page.getByText('BATCH:')).not.toBeVisible();
    await expect(page.getByTestId('queue-list')).toBeVisible();
  });
});
```

### quick-capture.spec.ts
```typescript
test.describe('Quick Capture', () => {
  test('creates bead from capture', async ({ page }) => {
    // Open capture overlay
    await page.keyboard.press('Meta+Shift+c');

    await expect(page.getByTestId('quick-capture-overlay')).toBeVisible();

    // Type and save
    await page.fill('textarea', 'Check webhook retry logic');
    await page.keyboard.press('Enter');

    // Verify overlay closes
    await expect(page.getByTestId('quick-capture-overlay')).not.toBeVisible();

    // Verify bead created (check inbox)
    await page.click('[data-testid="nav-inbox"]');
    await expect(page.getByText('Check webhook retry logic')).toBeVisible();
  });

  test('Escape cancels without creating', async ({ page }) => {
    await page.keyboard.press('Meta+Shift+c');
    await page.fill('textarea', 'partial text');
    await page.keyboard.press('Escape');

    await expect(page.getByTestId('quick-capture-overlay')).not.toBeVisible();

    // Verify no bead created
    await page.click('[data-testid="nav-inbox"]');
    await expect(page.getByText('partial text')).not.toBeVisible();
  });
});
```

### sync-status.spec.ts
```typescript
test.describe('Sync Status', () => {
  test('shows error on lore failure', async ({ page }) => {
    // Configure mock to fail
    process.env.MC_MOCK_LORE_FAIL = 'true';
    await page.goto('http://localhost:1420');

    await expect(page.getByText(/Sync failed/)).toBeVisible();
    await expect(page.getByText(/lore/)).toBeVisible();
  });

  test('recovers after failure', async ({ page }) => {
    process.env.MC_MOCK_LORE_FAIL = 'true';
    await page.goto('http://localhost:1420');

    // Clear failure mode
    process.env.MC_MOCK_LORE_FAIL = 'false';

    // Trigger retry
    await page.click('button:has-text("Retry")');

    await expect(page.getByText(/Sync failed/)).not.toBeVisible();
    await expect(page.getByTestId('focus-card')).toBeVisible();
  });
});
```

**Acceptance criteria:**
- All E2E tests pass in CI
- Tests cover critical user flows
- Mock configuration allows testing error states
- Tests run in < 2 minutes total


Implement focus actions with decision logging

# Focus Actions with Decision Logging (RED → GREEN)

**Parent epic:** Phase 3: Focus View

**Actions:**
| Action | What happens |
|--------|--------------|
| Start | Opens GitLab URL in browser, logs decision |
| Defer 1h | Sets snooze until now+1h, logs with reason |
| Defer Tomorrow | Sets snooze until 9am tomorrow, logs with reason |
| Skip | Removes from queue for today, logs with reason |

**TDD: Action tests (RED first):**

```typescript
// tests/hooks/useActions.test.ts
describe('useActions', () => {
  it('start opens URL in browser', async () => {
    const mockOpen = vi.fn();
    vi.mock('@tauri-apps/plugin-shell', () => ({ open: mockOpen }));

    const { result } = renderHook(() => useActions());

    await result.current.start({
      id: 'br-x7f',
      url: 'https://gitlab.com/platform/core/-/merge_requests/847'
    });

    expect(mockOpen).toHaveBeenCalledWith('https://gitlab.com/platform/core/-/merge_requests/847');
  });

  it('start logs decision', async () => {
    const mockLogDecision = vi.fn();
    vi.mock('../store', () => ({ useStore: () => ({ logDecision: mockLogDecision }) }));

    const { result } = renderHook(() => useActions());

    await result.current.start({ id: 'br-x7f' });

    expect(mockLogDecision).toHaveBeenCalledWith(expect.objectContaining({
      action: 'start',
      bead_id: 'br-x7f',
    }));
  });

  it('defer calculates correct snooze time', async () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-02-25T10:00:00Z'));

    const mockUpdateBead = vi.fn();
    const { result } = renderHook(() => useActions());

    await result.current.defer({ id: 'br-x7f' }, '1h');

    expect(mockUpdateBead).toHaveBeenCalledWith('br-x7f', {
      snoozedUntil: new Date('2026-02-25T11:00:00Z')
    });

    vi.useRealTimers();
  });

  it('defer tomorrow uses 9am', async () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-02-25T22:00:00Z')); // 10pm

    const mockUpdateBead = vi.fn();
    const { result } = renderHook(() => useActions());

    await result.current.defer({ id: 'br-x7f' }, 'tomorrow');

    expect(mockUpdateBead).toHaveBeenCalledWith('br-x7f', {
      snoozedUntil: new Date('2026-02-26T09:00:00') // 9am next day
    });

    vi.useRealTimers();
  });

  it('skip marks item as skipped for today', async () => {
    const mockUpdateBead = vi.fn();
    const { result } = renderHook(() => useActions());

    await result.current.skip({ id: 'br-x7f' }, 'Not urgent');

    expect(mockUpdateBead).toHaveBeenCalledWith('br-x7f', {
      skippedToday: true
    });
  });
});
```

**Implementation (GREEN):**

```typescript
// src/hooks/useActions.ts
import { open } from '@tauri-apps/plugin-shell';
import { invoke } from '@tauri-apps/api/core';

export function useActions() {
  const { logDecision, updateItem } = useStore();

  const start = async (item: WorkItem) => {
    // Open in browser
    if (item.url) {
      await open(item.url);
    }

    // Log decision
    await invoke('log_decision', {
      entry: {
        action: 'start',
        bead_id: item.id,
        context: captureContext(),
      }
    });
  };

  const defer = async (item: WorkItem, duration: '1h' | 'tomorrow' | string) => {
    const snoozedUntil = calculateSnoozeTime(duration);

    await invoke('update_item', {
      id: item.id,
      updates: { snoozedUntil }
    });

    // Prompt for reason then log
    const reason = await promptForReason('defer');
    await invoke('log_decision', {
      entry: {
        action: 'defer',
        bead_id: item.id,
        reason,
        context: captureContext(),
      }
    });
  };

  const skip = async (item: WorkItem) => {
    await invoke('update_item', {
      id: item.id,
      updates: { skippedToday: true }
    });

    const reason = await promptForReason('skip');
    await invoke('log_decision', {
      entry: {
        action: 'skip',
        bead_id: item.id,
        reason,
        context: captureContext(),
      }
    });
  };

  return { start, defer, skip };
}
```

**Acceptance criteria:**
- Start opens correct URL in default browser
- Defer calculates correct snooze times
- Skip hides item for rest of day
- All actions logged with context
- Reason prompt appears and captures input


Implement event-to-bead title and key formatting

# Event-to-Bead Title and Key Formatting

**Parent epic:** Phase 2: Bridge + Data Layer

**Purpose:** Convert lore events into standardized bead titles and stable mapping keys.

**Mapping rules from plan:**

| GitLab Event | Bead Title | Mapping Key Format |
|--------------|------------|-------------------|
| MR review requested | `Review MR !{iid}: {title}` | `mr_review:{host}:{project_id}:{iid}` |
| Issue assigned | `Issue #{iid}: {title}` | `issue:{host}:{project_id}:{iid}` |
| MR you authored | `Your MR !{iid}: {title}` | `mr_authored:{host}:{project_id}:{iid}` |
| Mention in discussion | `Mentioned in {type} #{iid}: {snippet}` | `mention:{host}:{project_id}:{iid}:{note_id}` |
| Comment on your MR | `Respond to @{actor} on MR !{iid}` | `feedback:{host}:{project_id}:{iid}:{note_id}` |

**Key format rationale:** We use numeric `project_id` instead of project path because paths can change (renames, transfers). Numeric IDs are immutable.

**TDD: Formatting tests (RED first):**

```rust
// src-tauri/tests/event_formatting_test.rs

#[test]
fn mr_review_title_format() {
    let event = LoreEvent::MrReview {
        iid: 847,
        title: "Fix authentication token refresh logic".into(),
        project: "platform/core".into(),
        author: "sarah".into(),
    };

    let title = format_bead_title(&event);

    assert_eq!(title, "Review MR !847: Fix authentication token refresh logic");
}

#[test]
fn mr_review_key_format() {
    let event = LoreEvent::MrReview {
        host: "gitlab.com".into(),
        project_id: 12345,
        iid: 847,
        ..
    };

    let key = format_mapping_key(&event);

    assert_eq!(key, "mr_review:gitlab.com:12345:847");
}

#[test]
fn issue_assigned_title_format() {
    let event = LoreEvent::IssueAssigned {
        iid: 312,
        title: "Implement user profile page".into(),
    };

    let title = format_bead_title(&event);

    assert_eq!(title, "Issue #312: Implement user profile page");
}

#[test]
fn mention_title_truncates_snippet() {
    let event = LoreEvent::Mention {
        entity_type: "issue".into(),
        iid: 312,
        snippet: "Hey @taylor can you take a look at this really long comment that goes on and on".into(),
    };

    let title = format_bead_title(&event);

    assert!(title.len() <= 80);
    assert!(title.ends_with("..."));
}

#[test]
fn feedback_title_format() {
    let event = LoreEvent::Feedback {
        iid: 847,
        actor: "mike".into(),
    };

    let title = format_bead_title(&event);

    assert_eq!(title, "Respond to @mike on MR !847");
}

#[test]
fn key_uses_project_id_not_path() {
    let event = LoreEvent::MrReview {
        project: "old-name/repo".into(),  // Path can change
        project_id: 12345,                 // ID is stable
        ..
    };

    let key = format_mapping_key(&event);

    assert!(key.contains("12345"));
    assert!(!key.contains("old-name"));
}

#[test]
fn key_handles_special_characters() {
    let event = LoreEvent::Mention {
        host: "gitlab.example.com".into(),
        project_id: 123,
        iid: 45,
        note_id: 67890,
    };

    let key = format_mapping_key(&event);

    // Should be safe for JSON keys and filenames
    assert!(!key.contains(' '));
    assert!(!key.contains('/'));
}
```

**Implementation:**

```rust
// src-tauri/src/bridge/formatting.rs

pub fn format_bead_title(event: &LoreEvent) -> String {
    match event {
        LoreEvent::MrReview { iid, title, .. } => {
            format!("Review MR !{}: {}", iid, truncate(title, 60))
        }

        LoreEvent::IssueAssigned { iid, title, .. } => {
            format!("Issue #{}: {}", iid, truncate(title, 60))
        }

        LoreEvent::MrAuthored { iid, title, .. } => {
            format!("Your MR !{}: {}", iid, truncate(title, 60))
        }

        LoreEvent::Mention { entity_type, iid, snippet, .. } => {
            format!("Mentioned in {} #{}: {}", entity_type, iid, truncate(snippet, 40))
        }

        LoreEvent::Feedback { iid, actor, .. } => {
            format!("Respond to @{} on MR !{}", actor, iid)
        }
    }
}

pub fn format_mapping_key(event: &LoreEvent) -> String {
    match event {
        LoreEvent::MrReview { host, project_id, iid, .. } => {
            format!("mr_review:{}:{}:{}", host, project_id, iid)
        }

        LoreEvent::IssueAssigned { host, project_id, iid, .. } => {
            format!("issue:{}:{}:{}", host, project_id, iid)
        }

        LoreEvent::MrAuthored { host, project_id, iid, .. } => {
            format!("mr_authored:{}:{}:{}", host, project_id, iid)
        }

        LoreEvent::Mention { host, project_id, iid, note_id, .. } => {
            format!("mention:{}:{}:{}:{}", host, project_id, iid, note_id)
        }

        LoreEvent::Feedback { host, project_id, iid, note_id, .. } => {
            format!("feedback:{}:{}:{}:{}", host, project_id, iid, note_id)
        }
    }
}

fn truncate(s: &str, max_len: usize) -> String {
    if s.len() <= max_len {
        s.to_string()
    } else {
        format!("{}...", &s[..max_len - 3])
    }
}
```

**Acceptance criteria:**
- All event types produce correct titles
- Keys use project_id (stable) not path
- Long titles truncated with ellipsis
- Keys are safe for JSON/filesystem
- Roundtrip: key → parse → same components


Implement error boundary and error handling UI

# Error Boundary and Error Handling UI

**Parent epic:** Phase 7: Polish + E2E Tests

**Purpose:** Graceful error handling throughout the app. Crashes should be caught and show recovery options.

**Error types:**

| Error Type | Handling | User Message |
|------------|----------|--------------|
| CLI not found | Show setup guide | "lore not found. Install with..." |
| CLI failure | Retry option | "Sync failed. [Retry]" |
| Network error | Retry + offline mode | "GitLab unreachable. Working offline." |
| State corruption | Auto-recover + notify | "Recovered from error. Some data may need refresh." |
| React crash | Error boundary | "Something went wrong. [Reload]" |

**TDD: Error handling tests (RED first):**

```typescript
// tests/components/ErrorBoundary.test.tsx
describe('ErrorBoundary', () => {
  it('catches errors and shows fallback', () => {
    const ThrowingComponent = () => {
      throw new Error('Test error');
    };

    render(
      <ErrorBoundary>
        <ThrowingComponent />
      </ErrorBoundary>
    );

    expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /reload/i })).toBeInTheDocument();
  });

  it('logs error details', () => {
    const consoleSpy = vi.spyOn(console, 'error');
    const ThrowingComponent = () => { throw new Error('Test'); };

    render(
      <ErrorBoundary>
        <ThrowingComponent />
      </ErrorBoundary>
    );

    expect(consoleSpy).toHaveBeenCalled();
  });

  it('shows recovery actions', () => {
    const onRecover = vi.fn();

    render(
      <ErrorBoundary onRecover={onRecover}>
        <ThrowingComponent />
      </ErrorBoundary>
    );

    userEvent.click(screen.getByRole('button', { name: /try again/i }));

    expect(onRecover).toHaveBeenCalled();
  });
});

// tests/components/ErrorDisplay.test.tsx
describe('ErrorDisplay', () => {
  it('shows CLI not found error with setup guide', () => {
    render(<ErrorDisplay error={{ type: 'cli_not_found', cli: 'lore' }} />);

    expect(screen.getByText(/lore not found/)).toBeInTheDocument();
    expect(screen.getByText(/Install with/)).toBeInTheDocument();
  });

  it('shows retry button for transient errors', () => {
    const onRetry = vi.fn();
    render(<ErrorDisplay error={{ type: 'sync_failed' }} onRetry={onRetry} />);

    expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
  });
});
```

**Implementation:**

```tsx
// src/components/ErrorBoundary.tsx
interface ErrorBoundaryState {
  hasError: boolean;
  error: Error | null;
}

export class ErrorBoundary extends React.Component<
  { children: React.ReactNode; onRecover?: () => void },
  ErrorBoundaryState
> {
  state: ErrorBoundaryState = { hasError: false, error: null };

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error('React error boundary caught:', error, errorInfo);

    // Could send to error tracking service
  }

  handleReload = () => {
    window.location.reload();
  };

  handleRecover = () => {
    this.setState({ hasError: false, error: null });
    this.props.onRecover?.();
  };

  render() {
    if (this.state.hasError) {
      return (
        <div className="flex flex-col items-center justify-center h-screen p-8">
          <AlertTriangle className="w-16 h-16 text-destructive mb-4" />
          <h1 className="text-2xl font-bold mb-2">Something went wrong</h1>
          <p className="text-muted-foreground mb-4 text-center max-w-md">
            Mission Control encountered an unexpected error.
          </p>

          {process.env.NODE_ENV === 'development' && (
            <pre className="bg-muted p-4 rounded mb-4 text-xs max-w-lg overflow-auto">
              {this.state.error?.stack}
            </pre>
          )}

          <div className="flex gap-4">
            <Button onClick={this.handleRecover}>Try Again</Button>
            <Button variant="outline" onClick={this.handleReload}>Reload App</Button>
          </div>
        </div>
      );
    }

    return this.props.children;
  }
}

// src/components/ErrorDisplay.tsx
interface ErrorDisplayProps {
  error: AppError;
  onRetry?: () => void;
  onDismiss?: () => void;
}

export function ErrorDisplay({ error, onRetry, onDismiss }: ErrorDisplayProps) {
  const messages: Record<string, { title: string; desc: string; action?: string }> = {
    cli_not_found: {
      title: `${error.cli} not found`,
      desc: `Install with: cargo install ${error.cli}`,
    },
    sync_failed: {
      title: 'Sync failed',
      desc: 'Could not fetch latest data from GitLab.',
      action: 'retry',
    },
    network_error: {
      title: 'Network error',
      desc: 'GitLab is unreachable. Working offline.',
    },
  };

  const msg = messages[error.type] || { title: 'Error', desc: error.message };

  return (
    <Alert variant="destructive">
      <AlertTitle>{msg.title}</AlertTitle>
      <AlertDescription>{msg.desc}</AlertDescription>

      {msg.action === 'retry' && onRetry && (
        <Button size="sm" className="mt-2" onClick={onRetry}>Retry</Button>
      )}
    </Alert>
  );
}
```

**Acceptance criteria:**
- React errors caught by boundary
- Helpful error messages shown
- Recovery/retry actions available
- No raw error stacks in production
- Errors logged for debugging


Implement tmp file cleanup on startup

Implement startup cleanup of orphaned .tmp files from interrupted atomic writes.

BACKGROUND:
PLAN.md states: 'Crash Recovery: On startup, check for .json.tmp files — if found, previous write was interrupted. Delete tmp and use existing .json (last known good state).'

WHY THIS MATTERS:
- Atomic write pattern: write to .tmp, then rename to target
- If crash occurs between write and rename, .tmp file lingers
- Lingering .tmp files indicate incomplete operation
- Must clean up before proceeding to avoid confusion

IMPLEMENTATION:
- On app startup, scan ~/.local/share/mc/ for *.tmp files
- For each .tmp file found:
  1. Log warning: 'Found orphaned tmp file, previous write interrupted'
  2. Delete the .tmp file
  3. Continue with existing .json file (if any)
- Run this BEFORE loading state files
- Run this AFTER acquiring single-instance lock (bd-2at)

TESTING (TDD):
- Create .tmp file, start app, verify .tmp deleted
- Verify main .json file unchanged
- Verify app continues normally after cleanup
- Test with multiple .tmp files
- Test with no .tmp files (normal case)

FILE LOCATION:
Part of app initialization (bd-3jh) or data/state.rs

ORDERING:
1. Acquire lock (bd-2at)
2. Clean up tmp files (THIS BEAD)
3. Run pending recovery (bd-z4n)
4. Load state files


Add test commands to package.json and Cargo.toml

# Test Command Configuration

**Parent epic:** Phase 0: Test Infrastructure

**package.json scripts to add:**

```json
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "test:e2e": "playwright test",
    "test:e2e:headed": "playwright test --headed",
    "test:e2e:debug": "playwright test --debug",
    "test:all": "npm run test && npm run test:e2e",
    "test:ci": "npm run test:coverage && npm run test:e2e"
  }
}
```

**Cargo.toml test configuration:**

```toml
[dev-dependencies]
tempfile = "3"        # For temp directory fixtures
tokio-test = "0.4"    # Async test utilities
rstest = "0.18"       # Parameterized tests

[[test]]
name = "bridge"
path = "tests/bridge_test.rs"

[[test]]
name = "crash_recovery"
path = "tests/crash_recovery_test.rs"

[[test]]
name = "mapping"
path = "tests/mapping_test.rs"
```

**CI workflow integration (.github/workflows/test.yml):**

```yaml
jobs:
  rust-tests:
    runs-on: ubuntu-latest
    steps:
      - run: cargo test --all-features

  frontend-tests:
    runs-on: ubuntu-latest
    steps:
      - run: npm run test:coverage

  e2e-tests:
    runs-on: macos-latest
    steps:
      - run: npm run test:e2e
```

**Acceptance criteria:**
- All test commands work from project root
- cargo test runs all Rust tests
- npm run test:all runs frontend + E2E tests
- CI config ready for GitHub Actions


Implement filter/search with command palette

# Filter/Search with Command Palette (RED → GREEN)

**Parent epic:** Phase 4: Queue + Inbox Views

**Purpose:** ⌘K opens a command palette for quick filtering and searching across all items.

**TDD: Filter tests (RED first):**

```typescript
// tests/components/CommandPalette.test.tsx
describe('CommandPalette', () => {
  it('opens on ⌘K', async () => {
    render(<App />);

    await userEvent.keyboard('{Meta>}k{/Meta}');

    expect(screen.getByRole('dialog')).toBeInTheDocument();
    expect(screen.getByPlaceholderText(/Search or filter/)).toBeInTheDocument();
  });

  it('closes on Escape', async () => {
    render(<App />);
    await userEvent.keyboard('{Meta>}k{/Meta}');

    await userEvent.keyboard('{Escape}');

    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
  });

  it('filters items by text', async () => {
    render(<QueueView items={mockItems} />);
    await userEvent.keyboard('{Meta>}k{/Meta}');

    await userEvent.type(screen.getByRole('textbox'), 'auth');

    expect(screen.getByText('Fix auth token refresh')).toBeInTheDocument();
    expect(screen.queryByText('Update README')).not.toBeInTheDocument();
  });

  it('shows filter commands', async () => {
    render(<App />);
    await userEvent.keyboard('{Meta>}k{/Meta}');

    await userEvent.type(screen.getByRole('textbox'), 'type:');

    expect(screen.getByText('type:review')).toBeInTheDocument();
    expect(screen.getByText('type:issue')).toBeInTheDocument();
    expect(screen.getByText('type:manual')).toBeInTheDocument();
  });

  it('filters by type when type: command used', async () => {
    const onFilter = vi.fn();
    render(<CommandPalette onFilter={onFilter} />);

    await userEvent.type(screen.getByRole('textbox'), 'type:review');
    await userEvent.keyboard('{Enter}');

    expect(onFilter).toHaveBeenCalledWith({ type: 'review' });
  });

  it('filters by staleness', async () => {
    const onFilter = vi.fn();
    render(<CommandPalette onFilter={onFilter} />);

    await userEvent.type(screen.getByRole('textbox'), 'stale:7d');
    await userEvent.keyboard('{Enter}');

    expect(onFilter).toHaveBeenCalledWith({ minAge: 7 });
  });
});
```

**Implementation (GREEN):**

```tsx
// src/components/CommandPalette.tsx
import { Command, CommandInput, CommandList, CommandItem, CommandGroup } from '@/components/ui/command';

const FILTER_COMMANDS = [
  { prefix: 'type:', options: ['review', 'issue', 'manual', 'mention'] },
  { prefix: 'from:', options: ['gitlab', 'manual'] },
  { prefix: 'stale:', options: ['1d', '3d', '7d'] },
  { prefix: 'status:', options: ['active', 'snoozed', 'all'] },
];

export function CommandPalette({ items, onFilter, onSelect, onClose }: CommandPaletteProps) {
  const [search, setSearch] = useState('');

  const { filteredItems, activeCommand } = useMemo(() => {
    // Check if search matches a command prefix
    for (const cmd of FILTER_COMMANDS) {
      if (search.startsWith(cmd.prefix)) {
        return {
          filteredItems: [],
          activeCommand: cmd,
        };
      }
    }

    // Text search
    return {
      filteredItems: items.filter(item =>
        item.title.toLowerCase().includes(search.toLowerCase())
      ),
      activeCommand: null,
    };
  }, [search, items]);

  useKeyboardShortcuts({
    'mod+k': () => {}, // Already open
    'escape': onClose,
  });

  return (
    <Command className="rounded-lg border shadow-md">
      <CommandInput
        placeholder="Search or filter... (try type:review)"
        value={search}
        onValueChange={setSearch}
      />

      <CommandList>
        {activeCommand ? (
          <CommandGroup heading={activeCommand.prefix}>
            {activeCommand.options.map(opt => (
              <CommandItem
                key={opt}
                onSelect={() => {
                  onFilter(parseFilter(activeCommand.prefix + opt));
                  onClose();
                }}
              >
                {activeCommand.prefix}{opt}
              </CommandItem>
            ))}
          </CommandGroup>
        ) : (
          <>
            {filteredItems.length > 0 && (
              <CommandGroup heading="Items">
                {filteredItems.slice(0, 10).map(item => (
                  <CommandItem
                    key={item.id}
                    onSelect={() => {
                      onSelect(item.id);
                      onClose();
                    }}
                  >
                    <TypeIcon type={item.type} className="mr-2" />
                    {item.title}
                  </CommandItem>
                ))}
              </CommandGroup>
            )}

            <CommandGroup heading="Commands">
              {FILTER_COMMANDS.map(cmd => (
                <CommandItem key={cmd.prefix} onSelect={() => setSearch(cmd.prefix)}>
                  {cmd.prefix}...
                </CommandItem>
              ))}
            </CommandGroup>
          </>
        )}
      </CommandList>
    </Command>
  );
}
```

**Acceptance criteria:**
- ⌘K opens palette
- Text search filters items
- Command prefixes work (type:, from:, stale:)
- Selection navigates to item
- Filter applied to queue view


Implement bv integration for triage recommendations

# BV Integration for Triage Recommendations

**Parent epic:** Phase 4: Queue + Inbox Views

**Purpose:** Show bv's triage recommendations as hints to help prioritize work. This is OPTIONAL advisory data, not mandatory.

**From plan:** "bv recommendations — Show as separate 'suggestions' section or inline hints?"

**Design decision:** Show as subtle hints, not separate section. User is always in control.

**TDD: BV integration tests (RED first):**

```rust
// src-tauri/tests/bv_integration_test.rs

#[test]
fn parse_bv_triage_output() {
    let fixture = include_str!("fixtures/bv/triage.json");
    let result: BvTriageResponse = serde_json::from_str(fixture).unwrap();

    assert!(result.recommendations.is_some());
}

#[tokio::test]
async fn get_recommendations_returns_top_picks() {
    let app = setup_test_app();

    let result: Vec<Recommendation> = app.invoke("get_recommendations", ()).await;

    assert!(result.len() <= 5); // Top 5 max
}

#[tokio::test]
async fn recommendations_gracefully_handle_bv_failure() {
    let bv = MockBvCli::that_fails();
    let app = setup_test_app_with(bv);

    let result: Vec<Recommendation> = app.invoke("get_recommendations", ()).await;

    // Should return empty, not error
    assert!(result.is_empty());
}
```

**Frontend tests:**

```typescript
// tests/hooks/useRecommendations.test.ts
describe('useRecommendations', () => {
  it('fetches recommendations from bv', async () => {
    vi.mocked(invoke).mockResolvedValue([
      { id: 'br-123', score: 0.95, reason: 'High PageRank' }
    ]);

    const { result } = renderHook(() => useRecommendations());

    await waitFor(() => {
      expect(result.current.data).toHaveLength(1);
    });
  });

  it('shows loading state', () => {
    const { result } = renderHook(() => useRecommendations());

    expect(result.current.isLoading).toBe(true);
  });

  it('returns empty on error (no crash)', async () => {
    vi.mocked(invoke).mockRejectedValue(new Error('bv failed'));

    const { result } = renderHook(() => useRecommendations());

    await waitFor(() => {
      expect(result.current.data).toEqual([]);
      expect(result.current.error).toBeNull(); // Swallowed
    });
  });
});
```

**Implementation:**

```rust
// src-tauri/src/data/bv.rs
pub trait BvCli: Send + Sync {
    fn triage(&self) -> Result<BvTriageResponse>;
}

pub struct RealBvCli;

impl BvCli for RealBvCli {
    fn triage(&self) -> Result<BvTriageResponse> {
        let output = Command::new("bv")
            .args(["--robot-triage"])
            .output()?;

        if !output.status.success() {
            return Ok(BvTriageResponse::default()); // Graceful degradation
        }

        serde_json::from_slice(&output.stdout)
            .context("Failed to parse bv output")
    }
}

// src-tauri/src/commands/recommendations.rs
#[tauri::command]
pub async fn get_recommendations(
    bv: State<'_, Arc<dyn BvCli>>,
) -> Vec<Recommendation> {
    bv.triage()
        .map(|r| r.recommendations.unwrap_or_default())
        .map(|recs| recs.into_iter().take(5).collect())
        .unwrap_or_default()
}
```

**Frontend hook:**

```typescript
// src/hooks/useRecommendations.ts
export function useRecommendations() {
  return useQuery({
    queryKey: ['recommendations'],
    queryFn: async () => {
      try {
        return await invoke<Recommendation[]>('get_recommendations');
      } catch {
        return []; // Graceful degradation
      }
    },
    staleTime: 5 * 60 * 1000, // Cache 5 minutes
  });
}
```

**UI integration:**

```tsx
// In QueueList, show hint for recommended items
function QueueItem({ item, recommendation }) {
  return (
    <div className="...">
      {/* ... item content ... */}

      {recommendation && (
        <Tooltip>
          <TooltipTrigger>
            <Sparkles className="w-3 h-3 text-amber-500" />
          </TooltipTrigger>
          <TooltipContent>
            Suggested: {recommendation.reason}
          </TooltipContent>
        </Tooltip>
      )}
    </div>
  );
}
```

**Acceptance criteria:**
- BV output parses correctly
- Recommendations show as subtle hints
- Graceful degradation on bv failure
- User never forced to follow recommendations


Implement complete action for finished work

# Complete Action for Finished Work

**Parent epic:** Phase 3: Focus View

**Purpose:** When user finishes a task, mark it complete. This closes the bead and logs the completion.

**Flow:**
1. User clicks "Done" or presses hotkey
2. Prompt for optional reason/notes
3. Close bead via `br close`
4. Log completion decision
5. Auto-advance to next item

**TDD: Complete action tests (RED first):**

```typescript
// tests/hooks/useActions.test.ts
describe('complete action', () => {
  it('closes bead via br CLI', async () => {
    const mockInvoke = vi.fn().mockResolvedValue({});
    vi.mocked(invoke).mockImplementation(mockInvoke);

    const { result } = renderHook(() => useActions());

    await result.current.complete({ id: 'br-123', title: 'Test' });

    expect(mockInvoke).toHaveBeenCalledWith('complete_item', {
      id: 'br-123',
      reason: undefined
    });
  });

  it('includes reason when provided', async () => {
    const mockInvoke = vi.fn().mockResolvedValue({});
    vi.mocked(invoke).mockImplementation(mockInvoke);

    const { result } = renderHook(() => useActions());

    await result.current.complete(
      { id: 'br-123' },
      { reason: 'Approved the MR', tags: ['done'] }
    );

    expect(mockInvoke).toHaveBeenCalledWith('complete_item', {
      id: 'br-123',
      reason: 'Approved the MR'
    });
  });

  it('logs completion decision', async () => {
    const mockLogDecision = vi.fn();

    const { result } = renderHook(() => useActions({ logDecision: mockLogDecision }));

    await result.current.complete({ id: 'br-123' });

    expect(mockLogDecision).toHaveBeenCalledWith(expect.objectContaining({
      action: 'complete',
      bead_id: 'br-123',
    }));
  });

  it('removes item from local state', async () => {
    const updateItem = vi.fn();
    const store = createStore({ updateItem });

    const { result } = renderHook(() => useActions(), {
      wrapper: StoreProvider(store)
    });

    await result.current.complete({ id: 'br-123' });

    expect(updateItem).toHaveBeenCalledWith('br-123', { completed: true });
  });
});
```

**Rust command:**

```rust
// src-tauri/src/commands/actions.rs
#[tauri::command]
pub async fn complete_item(
    id: String,
    reason: Option<String>,
    beads: State<'_, Arc<dyn BeadsCli>>,
    mapping: State<'_, Arc<Mutex<Mapping>>>,
    decision_log: State<'_, DecisionLogger>,
) -> Result<(), String> {
    // Close the bead
    let close_reason = reason.clone().unwrap_or_else(|| "Completed".into());
    beads.close(&id, &close_reason)
        .map_err(|e| e.to_string())?;

    // Remove from mapping if it's a gitlab item
    {
        let mut map = mapping.lock().await;
        map.remove_by_bead_id(&id);
        map.save()?;
    }

    // Log decision
    decision_log.log(DecisionEntry {
        action: Action::Complete,
        bead_id: id,
        reason,
        context: DecisionContext::capture(),
        ..Default::default()
    }).ok();

    Ok(())
}
```

**Frontend hook:**

```typescript
// src/hooks/useActions.ts
export function useActions() {
  const { updateItem, items, focusId, setFocus } = useStore();
  const queryClient = useQueryClient();

  const complete = async (
    item: WorkItem,
    options?: { reason?: string; tags?: string[] }
  ) => {
    // Close bead via backend
    await invoke('complete_item', {
      id: item.id,
      reason: options?.reason,
    });

    // Update local state
    updateItem(item.id, { completed: true });

    // Invalidate queries
    queryClient.invalidateQueries({ queryKey: ['work-items'] });

    // Auto-advance to next item if this was focused
    if (focusId === item.id) {
      const nextItem = items.find(i => i.id !== item.id && !i.completed);
      setFocus(nextItem?.id ?? null);
    }
  };

  return { complete, ... };
}
```

**Acceptance criteria:**
- Bead closed via br CLI
- Mapping updated (removes gitlab items)
- Decision logged with context
- Local state updated
- Auto-advances focus to next item


Phase 4: Queue + Inbox Views

# Queue + Inbox Views

**Context:** Queue View shows all pending work organized by type. Inbox View shows new items requiring triage. Both support the "achievable inbox zero" principle.

**Duration estimate:** 2-3 days

**Queue View visual:**
```
┌─────────────────────────────────────────────────────────────┐
│  Queue                                          ⌘K filter   │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  REVIEWS (4)                           [Batch All · 25min]  │
│  ┌─────────────────────────────────────────────────────┐    │
│  │ 🔴 !847  Fix auth token refresh          2d  @sarah │    │
│  │ 🟡 !902  Add rate limiting middleware    1d  @mike  │    │
│  │ 🟢 !915  Update README badges            4h  @alex  │    │
│  │ 🟢 !918  Typo fix in error messages      2h  @bot   │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  ASSIGNED ISSUES (2)                                        │
│  BEADS (3)                                                  │
│  MANUAL TASKS (1)                                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘
```

**Scope includes:**
1. QueueList component with sections by type
2. Staleness color coding (fresh=green, aging=amber, stale=red)
3. Drag to reorder (manual priority)
4. Click to set as focus
5. Inbox with triage actions
6. Filter/search (⌘K)

**Behavior:**
- Items colored by staleness
- Click to make it THE ONE THING
- Drag to reorder (manual priority, logged)
- "Batch All" enters batch mode

**Dependencies:**
- Requires Phase 3 (Focus View) for focus-setting integration
- Blocks Phase 5 (Batch Mode) which is triggered from here

**Acceptance criteria:**
- Queue shows all items grouped by type
- Staleness colors visible
- Drag reorder persists and logs
- Click sets focus
- Inbox has triage actions
- Filter/search works


Implement InboxView container

# InboxView Container

**Parent epic:** Phase 4: Queue + Inbox Views

**Purpose:** Container for Inbox component with triage flow and keyboard navigation.

**TDD: InboxView tests (RED first):**

```typescript
// tests/views/InboxView.test.tsx
describe('InboxView', () => {
  const mockNewItems = [
    { id: '1', triaged: false, title: 'Mention in #312' },
    { id: '2', triaged: false, title: 'Comment on MR !847' },
  ];

  it('shows only untriaged items', () => {
    const items = [...mockNewItems, { id: '3', triaged: true, title: 'Already done' }];
    const store = createStore({ items });
    render(<InboxView />, { wrapper: StoreProvider(store) });

    expect(screen.getAllByTestId('inbox-item')).toHaveLength(2);
    expect(screen.queryByText('Already done')).not.toBeInTheDocument();
  });

  it('shows inbox zero celebration when empty', () => {
    const store = createStore({ items: [] });
    render(<InboxView />, { wrapper: StoreProvider(store) });

    expect(screen.getByText('Inbox Zero')).toBeInTheDocument();
    expect(screen.getByText(/All caught up/)).toBeInTheDocument();
  });

  it('keyboard navigation between items', async () => {
    const store = createStore({ items: mockNewItems });
    render(<InboxView />, { wrapper: StoreProvider(store) });

    await userEvent.keyboard('{ArrowDown}');

    expect(screen.getAllByTestId('inbox-item')[1]).toHaveFocus();
  });

  it('accept triages item', async () => {
    const updateItem = vi.fn();
    const store = createStore({ items: mockNewItems, updateItem });
    render(<InboxView />, { wrapper: StoreProvider(store) });

    await userEvent.click(screen.getAllByRole('button', { name: /accept/i })[0]);

    expect(updateItem).toHaveBeenCalledWith('1', { triaged: true });
  });

  it('logs triage decision', async () => {
    const logDecision = vi.fn();
    const store = createStore({ items: mockNewItems, logDecision });
    render(<InboxView />, { wrapper: StoreProvider(store) });

    await userEvent.click(screen.getAllByRole('button', { name: /accept/i })[0]);

    expect(logDecision).toHaveBeenCalledWith(expect.objectContaining({
      action: 'triage',
      bead_id: '1',
    }));
  });

  it('updates count in real-time after triage', async () => {
    const store = createStore({ items: mockNewItems });
    render(<InboxView />, { wrapper: StoreProvider(store) });

    expect(screen.getByText('Inbox (2)')).toBeInTheDocument();

    await userEvent.click(screen.getAllByRole('button', { name: /accept/i })[0]);

    expect(screen.getByText('Inbox (1)')).toBeInTheDocument();
  });
});
```

**Implementation:**

```tsx
// src/views/InboxView.tsx
export function InboxView() {
  const { items, updateItem } = useStore();
  const { logDecision } = useDecisionLog();
  const [focusIndex, setFocusIndex] = useState(0);

  const untriagedItems = useMemo(() =>
    items.filter(i => !i.triaged),
    [items]
  );

  useKeyboardShortcuts({
    'arrowdown': () => setFocusIndex(i => Math.min(i + 1, untriagedItems.length - 1)),
    'arrowup': () => setFocusIndex(i => Math.max(i - 1, 0)),
    'a': () => handleTriage(untriagedItems[focusIndex]?.id, 'accept'),
    'd': () => handleTriage(untriagedItems[focusIndex]?.id, 'defer'),
    'x': () => handleTriage(untriagedItems[focusIndex]?.id, 'archive'),
  }, { enabled: untriagedItems.length > 0 });

  const handleTriage = async (id: string, action: 'accept' | 'defer' | 'archive', duration?: string) => {
    if (!id) return;

    // Update item state
    if (action === 'accept') {
      updateItem(id, { triaged: true });
    } else if (action === 'defer') {
      updateItem(id, { snoozedUntil: calculateSnoozeTime(duration || '1h') });
    } else if (action === 'archive') {
      updateItem(id, { triaged: true, archived: true });
    }

    // Log decision
    logDecision({
      action: 'triage',
      bead_id: id,
      context: {
        triage_action: action,
        inbox_size: untriagedItems.length,
      }
    });
  };

  if (untriagedItems.length === 0) {
    return (
      <div className="flex flex-col items-center justify-center h-full" data-testid="inbox-view">
        <motion.div
          initial={{ scale: 0 }}
          animate={{ scale: 1 }}
          className="text-center"
        >
          <CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
          <h2 className="text-2xl font-bold">Inbox Zero</h2>
          <p className="text-muted-foreground">All caught up!</p>
        </motion.div>
      </div>
    );
  }

  return (
    <div className="p-4" data-testid="inbox-view">
      <h1 className="text-xl font-bold mb-4">Inbox ({untriagedItems.length})</h1>

      <Inbox
        items={untriagedItems}
        focusIndex={focusIndex}
        onTriage={handleTriage}
      />

      <p className="text-sm text-muted-foreground mt-4">
        Keyboard: ↑↓ navigate · A accept · D defer · X archive
      </p>
    </div>
  );
}
```

**Acceptance criteria:**
- Only untriaged items shown
- Inbox zero state with animation
- Keyboard navigation works
- Triage actions update state
- Decisions logged
- Count updates in real-time


Implement useTauriEvents React hook for event communication

Create the useTauriEvents React hook that handles Tauri→React event communication. This hook is critical for the reactive UI - it receives events from the Rust backend (file watcher triggers, sync status changes, error notifications) and propagates them to React state.

BACKGROUND:
- Tauri supports bidirectional IPC: invoke (React→Rust) AND events (Rust→React)
- File watcher in Rust emits events when lore.db changes
- Sync orchestrator emits status updates
- These events need to trigger React re-renders

IMPLEMENTATION:
- Use @tauri-apps/api/event for listening
- Subscribe on mount, cleanup on unmount
- Type-safe event payloads matching Rust structs
- Events: 'lore-db-changed', 'sync-started', 'sync-completed', 'sync-error'

TESTING (TDD):
- Mock @tauri-apps/api/event
- Test subscription on mount
- Test cleanup on unmount
- Test event handler invocation
- Test payload parsing

FILE LOCATION:
src/hooks/useTauriEvents.ts

DEPENDENCIES:
- Needs TypeScript type definitions (bd-20b)
- Used by TanStack Query layer (bd-1fy) for cache invalidation
- Used by sync status indicator (bd-2or)


Implement BvCli trait for triage recommendation mocking

Add BvCli trait to the Rust trait-based mocking infrastructure for bv (beads graph triage) CLI operations.

BACKGROUND:
- bd-28q sets up LoreCli and BeadsCli traits for mocking
- BV integration (bd-4s6) needs BvCli trait for testability
- bv --robot-* commands return triage recommendations, insights, etc.

IMPLEMENTATION:
- Define BvCli trait with methods matching bv robot commands
- RealBvCli: shells out to actual bv CLI
- MockBvCli: returns fixture data for testing

TRAIT METHODS:
- fn robot_triage(&self) -> Result<BvTriageResponse>
- fn robot_next(&self) -> Result<BvNextResponse>
- fn robot_insights(&self) -> Result<BvInsightsResponse>

TESTING:
- Test MockBvCli returns fixture data
- Test RealBvCli parses actual bv output
- Test error handling when bv unavailable

FILE LOCATION:
src-tauri/src/data/bv.rs

NOTE: This trait completes the CLI wrapper trio (LoreCli, BeadsCli, BvCli) enabling full backend testability.


Implement app navigation and routing

# App Navigation and Routing

**Parent epic:** Phase 1: Foundation

**Purpose:** Navigate between views (Focus, Queue, Inbox, Settings) with keyboard shortcuts and visual nav.

**Navigation structure:**
```
┌─────────────────────────────────────────────────────────┐
│  [Focus ⌘1]  [Queue ⌘2]  [Inbox ⌘3]  [···]  [⚙ ⌘,]    │
├─────────────────────────────────────────────────────────┤
│                                                         │
│                    < Active View >                      │
│                                                         │
└─────────────────────────────────────────────────────────┘
```

**Keyboard shortcuts:**
| Key | Action |
|-----|--------|
| ⌘1 | Focus View |
| ⌘2 | Queue View |
| ⌘3 | Inbox View |
| ⌘, | Settings |

**TDD: Navigation tests (RED first):**

```typescript
// tests/components/Navigation.test.tsx
describe('Navigation', () => {
  it('renders nav items', () => {
    render(<Navigation />);

    expect(screen.getByRole('button', { name: /focus/i })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /queue/i })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /inbox/i })).toBeInTheDocument();
  });

  it('highlights active view', () => {
    const store = createStore({ activeView: 'queue' });
    render(<Navigation />, { wrapper: StoreProvider(store) });

    expect(screen.getByRole('button', { name: /queue/i })).toHaveAttribute('data-active', 'true');
  });

  it('shows inbox badge count', () => {
    const store = createStore({ items: mockItems.filter(i => !i.triaged) });
    render(<Navigation />, { wrapper: StoreProvider(store) });

    expect(screen.getByTestId('inbox-badge')).toHaveTextContent('3');
  });

  it('navigates on click', async () => {
    const setActiveView = vi.fn();
    const store = createStore({ setActiveView });
    render(<Navigation />, { wrapper: StoreProvider(store) });

    await userEvent.click(screen.getByRole('button', { name: /queue/i }));

    expect(setActiveView).toHaveBeenCalledWith('queue');
  });

  it('navigates on keyboard shortcut', async () => {
    const setActiveView = vi.fn();
    const store = createStore({ setActiveView });
    render(<App />, { wrapper: StoreProvider(store) });

    await userEvent.keyboard('{Meta>}2{/Meta}');

    expect(setActiveView).toHaveBeenCalledWith('queue');
  });
});

// tests/App.test.tsx
describe('App routing', () => {
  it('renders FocusView when activeView is focus', () => {
    const store = createStore({ activeView: 'focus' });
    render(<App />, { wrapper: StoreProvider(store) });

    expect(screen.getByTestId('focus-view')).toBeInTheDocument();
  });

  it('renders QueueView when activeView is queue', () => {
    const store = createStore({ activeView: 'queue' });
    render(<App />, { wrapper: StoreProvider(store) });

    expect(screen.getByTestId('queue-view')).toBeInTheDocument();
  });
});
```

**Implementation:**

```tsx
// src/components/Navigation.tsx
const NAV_ITEMS = [
  { id: 'focus', label: 'Focus', shortcut: '⌘1' },
  { id: 'queue', label: 'Queue', shortcut: '⌘2' },
  { id: 'inbox', label: 'Inbox', shortcut: '⌘3', badge: true },
];

export function Navigation() {
  const { activeView, setActiveView, items } = useStore();
  const inboxCount = items.filter(i => !i.triaged).length;

  useKeyboardShortcuts({
    'mod+1': () => setActiveView('focus'),
    'mod+2': () => setActiveView('queue'),
    'mod+3': () => setActiveView('inbox'),
    'mod+,': () => setActiveView('settings'),
  });

  return (
    <nav className="flex items-center gap-1 px-4 py-2 border-b">
      {NAV_ITEMS.map(item => (
        <Button
          key={item.id}
          variant={activeView === item.id ? 'secondary' : 'ghost'}
          size="sm"
          data-active={activeView === item.id}
          onClick={() => setActiveView(item.id)}
        >
          {item.label}
          <kbd className="ml-2 text-xs text-muted-foreground">{item.shortcut}</kbd>
          {item.badge && inboxCount > 0 && (
            <Badge data-testid="inbox-badge" className="ml-2" variant="destructive">
              {inboxCount}
            </Badge>
          )}
        </Button>
      ))}

      <div className="flex-1" />

      <Button variant="ghost" size="icon" onClick={() => setActiveView('settings')}>
        <Settings className="w-4 h-4" />
      </Button>
    </nav>
  );
}

// src/App.tsx
export function App() {
  const { activeView } = useStore();

  const views = {
    focus: <FocusView />,
    queue: <QueueView />,
    inbox: <InboxView />,
    settings: <SettingsView />,
  };

  return (
    <div className="flex flex-col h-screen">
      <Navigation />
      <main className="flex-1 overflow-auto" data-testid={`${activeView}-view`}>
        {views[activeView]}
      </main>
    </div>
  );
}
```

**Acceptance criteria:**
- All nav items render
- Active view highlighted
- Inbox badge shows count
- Click navigates
- Keyboard shortcuts work
- Correct view renders for each state


Phase 1: Foundation

# Foundation — Tauri + React + Lore Integration

**Context:** This phase creates the basic shell of Mission Control — a working Tauri app with system tray, global hotkey, and the ability to read data from lore CLI.

**Why this matters:**
- Foundation must be solid before building features on top
- System tray + hotkey is core to the "ambient awareness" principle
- Lore integration proves the CLI-shelling architecture works

**Duration estimate:** 2-3 days

**Scope includes:**
1. Scaffold Tauri 2.0 + Vite + React project
2. Basic window with system tray icon
3. Global hotkey to toggle window (⌘⇧M)
4. Lore CLI wrapper with JSON parsing (RED → GREEN)
5. File watcher on lore.db for change detection (RED → GREEN)
6. Display raw lore data in UI (visual verification)

**Architecture decisions embedded:**
- Tauri 2.0 for Rust backend + tiny bundle (~15MB vs Electron 150MB)
- React 19 + Vite for fast iteration
- CLI shelling (not library imports) for clean API boundaries

**Dependencies:**
- Requires Phase 0 (Test Infrastructure) complete
- Blocks Phase 2 (Bridge) and all view phases

**Acceptance criteria:**
- Tauri app launches and shows in system tray
- ⌘⇧M toggles window visibility
- App can call `lore --robot me` and parse response
- lore.db changes trigger refresh callback
- Basic UI shows parsed lore data


Implement BatchMode component

# BatchMode Component (RED → GREEN)

**Parent epic:** Phase 5: Batch Mode

**TDD: BatchMode tests (RED first):**

```typescript
// tests/components/BatchMode.test.tsx
describe('BatchMode', () => {
  const mockItems = [
    { id: '1', title: 'Review MR !847' },
    { id: '2', title: 'Review MR !902' },
    { id: '3', title: 'Review MR !915' },
    { id: '4', title: 'Review MR !918' },
  ];

  it('shows current item and progress', () => {
    render(<BatchMode items={mockItems} />);

    expect(screen.getByText('BATCH: CODE REVIEWS')).toBeInTheDocument();
    expect(screen.getByText('1 of 4')).toBeInTheDocument();
    expect(screen.getByText('Review MR !847')).toBeInTheDocument();
  });

  it('shows progress bar', () => {
    render(<BatchMode items={mockItems} />);

    const progress = screen.getByRole('progressbar');
    expect(progress).toHaveAttribute('aria-valuenow', '0');
    expect(progress).toHaveAttribute('aria-valuemax', '4');
  });

  it('Done advances to next item', async () => {
    render(<BatchMode items={mockItems} />);

    await userEvent.click(screen.getByRole('button', { name: /done/i }));

    expect(screen.getByText('2 of 4')).toBeInTheDocument();
    expect(screen.getByText('Review MR !902')).toBeInTheDocument();
    expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '1');
  });

  it('Skip advances without marking complete', async () => {
    const onComplete = vi.fn();
    render(<BatchMode items={mockItems} onComplete={onComplete} />);

    await userEvent.click(screen.getByRole('button', { name: /skip/i }));

    expect(screen.getByText('2 of 4')).toBeInTheDocument();
    expect(onComplete).not.toHaveBeenCalled();
  });

  it('Escape exits batch mode', async () => {
    const onExit = vi.fn();
    render(<BatchMode items={mockItems} onExit={onExit} />);

    await userEvent.keyboard('{Escape}');

    expect(onExit).toHaveBeenCalled();
  });

  it('shows celebration on completion', async () => {
    render(<BatchMode items={mockItems} />);

    // Complete all 4
    for (let i = 0; i < 4; i++) {
      await userEvent.click(screen.getByRole('button', { name: /done/i }));
    }

    expect(screen.getByText(/All done/)).toBeInTheDocument();
    expect(screen.getByTestId('confetti')).toBeInTheDocument();
  });

  it('keyboard shortcuts work', async () => {
    const onComplete = vi.fn();
    render(<BatchMode items={mockItems} onComplete={onComplete} />);

    await userEvent.keyboard('{Meta>}d{/Meta}'); // ⌘D for Done

    expect(onComplete).toHaveBeenCalledWith('1');
  });
});
```

**Implementation (GREEN):**

```tsx
// src/components/BatchMode.tsx
interface BatchModeProps {
  items: WorkItem[];
  type: ItemType;
  onComplete: (id: string) => void;
  onSkip: (id: string) => void;
  onExit: () => void;
}

export function BatchMode({ items, type, onComplete, onSkip, onExit }: BatchModeProps) {
  const [currentIndex, setCurrentIndex] = useState(0);
  const [completedIds, setCompletedIds] = useState<Set<string>>(new Set());
  const [showCelebration, setShowCelebration] = useState(false);

  const currentItem = items[currentIndex];
  const progress = completedIds.size;

  useKeyboardShortcuts({
    'mod+o': () => currentItem && open(currentItem.url),
    'mod+d': handleDone,
    'mod+s': handleSkip,
    'escape': onExit,
  });

  const handleDone = () => {
    onComplete(currentItem.id);
    setCompletedIds(prev => new Set(prev).add(currentItem.id));
    advanceOrComplete();
  };

  const handleSkip = () => {
    onSkip(currentItem.id);
    advanceOrComplete();
  };

  const advanceOrComplete = () => {
    if (currentIndex === items.length - 1) {
      setShowCelebration(true);
    } else {
      setCurrentIndex(prev => prev + 1);
    }
  };

  if (showCelebration) {
    return <BatchCelebration completed={completedIds.size} total={items.length} onClose={onExit} />;
  }

  return (
    <div className="fixed inset-0 bg-background z-50 flex flex-col">
      <header className="p-4 text-center border-b">
        <h1 className="text-xl font-bold">BATCH: {getTypeLabel(type)}</h1>
        <p className="text-muted-foreground">{currentIndex + 1} of {items.length}</p>
        <Progress value={progress} max={items.length} className="mt-2" />
      </header>

      <main className="flex-1 flex items-center justify-center p-8">
        <BatchCard item={currentItem} />
      </main>

      <footer className="p-4 flex justify-center gap-4 border-t">
        <Button onClick={() => open(currentItem.url)}>
          Open in GL <kbd className="ml-2">⌘O</kbd>
        </Button>
        <Button onClick={handleDone}>
          Done <kbd className="ml-2">⌘D</kbd>
        </Button>
        <Button variant="ghost" onClick={handleSkip}>
          Skip <kbd className="ml-2">⌘S</kbd>
        </Button>
      </footer>

      <p className="text-center text-muted-foreground pb-4">ESC to exit batch</p>
    </div>
  );
}
```

**Acceptance criteria:**
- All tests pass
- Progress tracking works
- Done/Skip advance correctly
- Keyboard shortcuts work
- Celebration shown on completion


Implement contract tests for CLI outputs

# Contract Tests for CLI Outputs

**Parent epic:** Phase 0: Test Infrastructure

**Purpose:** Detect schema drift in lore/br/bv CLI outputs before it breaks MC at runtime.

**Strategy:**
1. Fixtures contain real CLI outputs
2. Rust types with `#[serde(deny_unknown_fields)]` fail on unexpected fields
3. CI job regenerates fixtures and diffs against committed versions

**Contract test structure:**

```rust
// src-tauri/tests/contract_test.rs

#[test]
fn lore_me_response_matches_fixture() {
    let fixture = include_str!("fixtures/lore/me_with_reviews.json");

    // This will fail if:
    // 1. Required field is missing
    // 2. Field type changed
    // 3. Unknown field added (deny_unknown_fields)
    let result: Result<LoreMeResponse, _> = serde_json::from_str(fixture);

    assert!(result.is_ok(), "Fixture parse failed: {:?}", result.err());
}

#[test]
fn lore_me_empty_response_matches_fixture() {
    let fixture = include_str!("fixtures/lore/me_empty.json");
    let result: LoreMeResponse = serde_json::from_str(fixture)
        .expect("Empty response fixture should parse");

    assert!(result.data.since_last_check.is_empty());
}

#[test]
fn br_create_response_matches_fixture() {
    let fixture = include_str!("fixtures/br/create_success.json");
    let result: BrCreateResponse = serde_json::from_str(fixture)
        .expect("BR create fixture should parse");

    assert!(result.id.starts_with("bd-") || result.id.starts_with("br-"));
}

#[test]
fn br_list_response_matches_fixture() {
    let fixture = include_str!("fixtures/br/list.json");
    let result: Vec<BeadItem> = serde_json::from_str(fixture)
        .expect("BR list fixture should parse");

    assert!(!result.is_empty());
}

#[test]
fn bv_triage_response_matches_fixture() {
    let fixture = include_str!("fixtures/bv/triage.json");
    let result: BvTriageResponse = serde_json::from_str(fixture)
        .expect("BV triage fixture should parse");

    // Validate expected structure
    assert!(result.recommendations.is_some() || result.quick_ref.is_some());
}
```

**Type definitions with strict parsing:**

```rust
// src-tauri/src/data/types.rs

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]  // CRITICAL: Fails on new fields
pub struct LoreMeResponse {
    pub ok: bool,
    pub data: LoreMeData,
    #[serde(default)]
    pub meta: Option<LoreMeta>,
}

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
pub struct LoreMeData {
    pub issues: Vec<LoreIssue>,
    pub mrs: LoreMrData,
    pub since_last_check: Vec<LoreEvent>,
}
```

**Fixture regeneration script:**

```bash
#!/bin/bash
# scripts/regenerate-fixtures.sh
set -e

FIXTURE_DIR="src-tauri/tests/fixtures"

echo "Regenerating CLI fixtures..."

# Lore fixtures
lore --robot me > "$FIXTURE_DIR/lore/me_current.json"
lore --robot me --issues > "$FIXTURE_DIR/lore/issues.json"
lore --robot me --mrs > "$FIXTURE_DIR/lore/mrs.json"

# BR fixtures
br list --json > "$FIXTURE_DIR/br/list.json"

# BV fixtures
bv --robot-triage > "$FIXTURE_DIR/bv/triage.json" 2>/dev/null || echo '{}' > "$FIXTURE_DIR/bv/triage.json"

echo "Fixtures regenerated. Run 'git diff' to see changes."
```

**CI job:**

```yaml
contract-tests:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4

    - name: Install CLIs
      run: |
        cargo install lore br bv

    - name: Regenerate fixtures
      run: ./scripts/regenerate-fixtures.sh

    - name: Check for drift
      run: |
        if ! git diff --exit-code src-tauri/tests/fixtures/; then
          echo "::error::CLI output schema has changed! Update types and fixtures."
          exit 1
        fi

    - name: Run contract tests
      run: cargo test contract
```

**Acceptance criteria:**
- All fixture files parse with deny_unknown_fields
- Regeneration script works
- CI fails on schema drift
- Clear error messages for parsing failures