Implement br CLI wrapper for bead operations

# Beads CLI Wrapper (RED → GREEN)

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

**Why CLI wrapper:**
Same pattern as lore — shell out to br CLI instead of importing beads as library.
This provides clean boundaries and guaranteed compatibility.

**TDD: br CLI tests (RED first):**

```rust
#[test]
fn parse_br_create_success() {
    let fixture = include_str!("fixtures/br/create_success.json");
    let result: BrCreateResponse = serde_json::from_str(fixture).unwrap();

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

#[test]
fn parse_br_create_error() {
    let fixture = include_str!("fixtures/br/create_error.json");
    let result: Result<BrCreateResponse, _> = serde_json::from_str(fixture);

    // Error fixture should have error field
    assert!(result.is_err() || has_error_field(fixture));
}

#[test]
fn mock_beads_records_calls() {
    let mut mock = MockBeadsCli::new();
    mock.create("Test", "gitlab")?;

    assert_eq!(mock.calls().len(), 1);
    assert_eq!(mock.calls()[0], BeadsCall::Create { title: "Test", bead_type: "gitlab" });
}
```

**Implementation (GREEN):**

```rust
pub struct RealBeadsCli;

impl BeadsCli for RealBeadsCli {
    fn create(&self, title: &str, bead_type: &str) -> Result<String> {
        let output = Command::new("br")
            .args(["create", title, "-t", bead_type, "--json"])
            .output()?;

        if !output.status.success() {
            return Err(anyhow!("br create failed: {}",
                String::from_utf8_lossy(&output.stderr)));
        }

        let response: BrCreateResponse = serde_json::from_slice(&output.stdout)?;
        Ok(response.id)
    }

    fn close(&self, id: &str, reason: &str) -> Result<()> {
        let output = Command::new("br")
            .args(["close", id, "--reason", reason])
            .output()?;

        if !output.status.success() {
            return Err(anyhow!("br close failed"));
        }

        Ok(())
    }

    fn exists(&self, id: &str) -> Result<bool> {
        let output = Command::new("br")
            .args(["show", id, "--json"])
            .output()?;

        Ok(output.status.success())
    }
}
```

**Mock implementation for tests:**

```rust
pub struct MockBeadsCli {
    responses: HashMap<String, MockResponse>,
    calls: Vec<BeadsCall>,
    next_id: u32,
}

impl BeadsCli for MockBeadsCli {
    fn create(&mut self, title: &str, bead_type: &str) -> Result<String> {
        self.calls.push(BeadsCall::Create { title: title.into(), bead_type: bead_type.into() });
        let id = format!("br-mock{}", self.next_id);
        self.next_id += 1;
        Ok(id)
    }
}
```

**Acceptance criteria:**
- All fixture files parse successfully
- RealBeadsCli executes actual br commands
- MockBeadsCli records calls for verification
- Error responses handled gracefully

Completed: BeadsCli trait and RealBeadsCli implementation exist in beads.rs


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

Completed: Full bridge implementation with incremental sync, reconciliation, two-strike close, flock lock, write-ahead pattern, and Tauri IPC commands. 45 tests.


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

Completed: Implemented Tauri-backed persistence for Zustand stores. Created tauri-storage.ts adapter, added read_state/write_state/clear_state Tauri commands, and wired up focus-store and nav-store to persist to ~/.local/share/mc/state.json


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)

Completed: DecisionLog with append/read_all exists in state.rs


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

Done in bridge.rs - suspect_orphan two-strike


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

Completed: BatchMode full-screen interface, batch store, progress tracking, celebration screen, keyboard shortcuts (Cmd+O/D/S, ESC), AnimatePresence transitions, 100 total frontend tests


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

Completed: reorderQueue in Zustand store with bounds checking, 4 tests


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

Completed: Tauri+Vite+React scaffold is working, npm run tauri:dev launches app


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

Implemented useActions hook with start, defer, skip, complete actions. All 14 tests passing. Hook integrates Tauri shell (URL opening), IPC (decision logging, state updates), and focus store (queue advancement).


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

Completed: Added cleanup_tmp_files() method to Bridge that removes orphaned .tmp files on startup. Called from lib.rs setup. Added 3 tests covering: removal of tmp files, ignoring non-tmp files, and handling missing directories.


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

Completed: QueueView with sections, QueueItem with staleness, AppShell with nav, click-to-focus, reorder support, 73 tests. Inbox and command palette are placeholdered for Phase 4b.


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)

Implemented useTauriEvent and useTauriEvents hooks in src/hooks/useTauriEvents.ts with 5 tests. Total: 134 TS tests.


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.

Completed: Added BvCli trait with RealBvCli and MockBvCli implementations. Includes response types for robot-triage and robot-next, plus error handling with McError conversion.


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

Completed: System tray icon, Cmd+Shift+M global hotkey, and wired get_lore_status to RealLoreCli with 5 new tests