Configure Playwright for E2E testing

# Playwright E2E Test Configuration

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

**Why Playwright:**
- Cross-platform desktop app testing
- Works with Tauri apps via WebDriver/CDP
- Excellent debugging tools (trace viewer, screenshots)
- Reliable waiting and assertion APIs

**What to configure:**
1. Install @playwright/test
2. Create playwright.config.ts:
   - Base URL: http://localhost:1420 (Tauri dev server)
   - Test directory: tests/e2e/
   - Retry configuration for flaky tests
   - Screenshot/video on failure
   - Timeout settings appropriate for native app
3. Configure Tauri test mode:
   - Environment variable MC_TEST_MODE=true
   - Mock CLI paths via environment
4. Add npm scripts:
   - "test:e2e": "playwright test"
   - "test:e2e:headed": "playwright test --headed"
   - "test:e2e:debug": "playwright test --debug"

**E2E test patterns (to be written later):**
- focus-flow.spec.ts: Focus view interactions
- batch-mode.spec.ts: Batch mode workflow
- quick-capture.spec.ts: Global hotkey capture

**Acceptance criteria:**
- `npm run test:e2e` launches Playwright runner
- Tests can interact with Tauri webview content
- Screenshots capture on failure
- Tests can detect browser popups (for "Open in GitLab" flows)


Global hotkey to toggle window (⌘⇧M)

# Global Hotkey Registration

**Parent epic:** Phase 1: Foundation

**UX principle:** Mission Control should be instantly accessible from anywhere. ⌘⇧M (Cmd+Shift+M) brings it up or hides it.

**Implementation:**

1. **Tauri global shortcut plugin:**
   ```bash
   cargo add tauri-plugin-global-shortcut
   ```

2. **Register hotkey (src-tauri/src/shortcuts.rs):**
   ```rust
   use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut};

   pub fn setup_shortcuts<R: Runtime>(app: &tauri::App<R>) -> Result<()> {
       let toggle_shortcut = Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyM);

       app.global_shortcut().on_shortcut(toggle_shortcut, |app, _| {
           if let Some(window) = app.get_webview_window("main") {
               if window.is_visible().unwrap_or(false) {
                   window.hide().ok();
               } else {
                   window.show().ok();
                   window.set_focus().ok();
               }
           }
       })?;

       Ok(())
   }
   ```

3. **Window behavior on toggle:**
   - If hidden: Show and focus
   - If visible but not focused: Focus
   - If visible and focused: Hide

4. **Settings integration (future):**
   - Allow user to customize hotkey
   - Store in settings.json
   - Re-register on change

**Platform considerations:**
- macOS: ⌘⇧M (Command+Shift+M)
- Future Windows/Linux: Ctrl+Shift+M

**Acceptance criteria:**
- Pressing ⌘⇧M from any app brings up Mission Control
- Pressing again hides it
- Hotkey works even when MC is in background
- No conflict with common system shortcuts


Implement FocusView container with focus selection

# FocusView Container

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

**Purpose:** Container component that manages focus state and connects FocusCard to the store.

**Behavior:**
1. If user has set a focus → show FocusCard with that item
2. If no focus set → show suggestion from queue or prompt to pick
3. After completing/skipping → auto-select next item or show empty state

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

```typescript
// tests/views/FocusView.test.tsx
describe('FocusView', () => {
  it('shows FocusCard when focus is set', () => {
    const store = createStore({ focusId: '1', items: [mockItem] });
    render(<FocusView />, { wrapper: StoreProvider(store) });

    expect(screen.getByTestId('focus-card')).toBeInTheDocument();
    expect(screen.getByText(mockItem.title)).toBeInTheDocument();
  });

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

    expect(screen.getByText(/Nothing to focus on/)).toBeInTheDocument();
    expect(screen.getByText(/Create a task/)).toBeInTheDocument();
  });

  it('shows suggestion when no focus but items exist', () => {
    const store = createStore({ focusId: null, items: [mockItem] });
    render(<FocusView />, { wrapper: StoreProvider(store) });

    expect(screen.getByText(/Suggested/)).toBeInTheDocument();
    expect(screen.getByText(mockItem.title)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /Set as focus/i })).toBeInTheDocument();
  });

  it('auto-advances to next item after complete', async () => {
    const store = createStore({ focusId: '1', items: [item1, item2] });
    render(<FocusView />, { wrapper: StoreProvider(store) });

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

    // Should show next item as suggestion
    expect(screen.getByText(item2.title)).toBeInTheDocument();
  });

  it('shows celebration on last item complete', async () => {
    const store = createStore({ focusId: '1', items: [item1] });
    render(<FocusView />, { wrapper: StoreProvider(store) });

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

    expect(screen.getByText(/All caught up/)).toBeInTheDocument();
  });
});
```

**Implementation:**

```tsx
// src/views/FocusView.tsx
export function FocusView() {
  const { focusId, items, setFocus, clearFocus } = useStore();
  const { start, defer, skip, complete } = useActions();
  const [showReasonPrompt, setShowReasonPrompt] = useState(false);
  const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);

  const focusItem = items.find(i => i.id === focusId);
  const suggestion = !focusId && items.length > 0 ? items[0] : null;

  // Empty state
  if (!focusItem && !suggestion) {
    return (
      <div className="flex flex-col items-center justify-center h-full">
        <CheckCircle className="w-16 h-16 text-green-500 mb-4" />
        <h2 className="text-2xl font-bold">All caught up!</h2>
        <p className="text-muted-foreground">Nothing to focus on right now.</p>
        <Button className="mt-4" onClick={() => invoke('show_quick_capture')}>
          Create a task
        </Button>
      </div>
    );
  }

  // Suggestion state
  if (suggestion && !focusItem) {
    return (
      <div className="flex flex-col items-center justify-center h-full">
        <p className="text-muted-foreground mb-2">Suggested next</p>
        <FocusCard
          item={suggestion}
          onStart={() => {
            setFocus(suggestion.id);
            start(suggestion);
          }}
          onDefer={(d) => defer(suggestion, d)}
          onSkip={() => skip(suggestion)}
        />
        <Button
          variant="outline"
          className="mt-4"
          onClick={() => setFocus(suggestion.id)}
        >
          Set as focus
        </Button>
      </div>
    );
  }

  // Focus state
  return (
    <div className="flex flex-col items-center justify-center h-full">
      <FocusCard
        item={focusItem!}
        onStart={() => handleAction('start', focusItem!)}
        onDefer={(d) => handleAction('defer', focusItem!, d)}
        onSkip={() => handleAction('skip', focusItem!)}
      />

      {showReasonPrompt && (
        <ReasonPrompt
          action={pendingAction!.type}
          itemTitle={focusItem!.title}
          onSubmit={handleReasonSubmit}
          onCancel={() => setShowReasonPrompt(false)}
        />
      )}
    </div>
  );
}
```

**Acceptance criteria:**
- Focus item displays when set
- Suggestion shows when no focus
- Empty state for no items
- Actions trigger reason prompt
- Auto-advance after completion


Implement Framer Motion animations

# Framer Motion Animations

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

**Animation targets:**
| Component | Animation |
|-----------|-----------|
| FocusCard | Scale-in on mount, smooth transitions |
| QueueItem | Drag preview, reorder animation |
| Popover | Slide-in from tray |
| QuickCapture | Scale + fade overlay |
| BatchMode | Progress bar, celebration confetti |
| ReasonPrompt | Dialog slide-up |

**Implementation examples:**

```tsx
// FocusCard entrance
<motion.div
  initial={{ scale: 0.95, opacity: 0 }}
  animate={{ scale: 1, opacity: 1 }}
  transition={{ type: 'spring', duration: 0.3 }}
>
  <FocusCard ... />
</motion.div>

// Queue item reorder with layout animation
<motion.div layout transition={{ type: 'spring', stiffness: 300, damping: 30 }}>
  <QueueItem ... />
</motion.div>

// Batch progress
<motion.div
  className="h-2 bg-primary rounded"
  initial={{ width: 0 }}
  animate={{ width: `${(progress / total) * 100}%` }}
  transition={{ duration: 0.3 }}
/>
```

**Celebration confetti:**
```tsx
import Confetti from 'react-confetti';

function BatchCelebration({ completed, total, onClose }) {
  return (
    <div className="fixed inset-0 flex items-center justify-center">
      <Confetti data-testid="confetti" />
      <motion.div
        initial={{ scale: 0 }}
        animate={{ scale: 1 }}
        transition={{ type: 'spring', delay: 0.2 }}
      >
        <h1 className="text-4xl font-bold">All done!</h1>
        <p>{completed}/{total} completed</p>
        <Button onClick={onClose}>Back to Focus</Button>
      </motion.div>
    </div>
  );
}
```

**ADHD-friendly principles:**
- Subtle, quick animations (150-300ms)
- No jarring movements
- Clear visual feedback for actions
- Celebratory moments for completion

**Acceptance criteria:**
- Smooth 60fps animations
- No layout shifts or jank
- Animations disabled for reduced-motion preference
- Celebration feels rewarding


Implement lore CLI wrapper with JSON parsing

# Lore CLI Wrapper (RED → GREEN)

**Parent epic:** Phase 1: Foundation

**Why CLI wrapper:**
MC integrates with lore via `lore --robot` CLI, not as a library. This provides:
- Clean API boundary (schema changes are isolated)
- No Rust dependency on lore internals
- Battle-tested JSON output format

**TDD approach:**

1. **RED: Write parsing tests first:**
   ```rust
   // src-tauri/tests/lore_parsing_test.rs
   #[test]
   fn parse_lore_me_with_reviews() {
       let fixture = include_str!("fixtures/lore/me_with_reviews.json");
       let result: LoreMeResponse = serde_json::from_str(fixture).unwrap();

       assert_eq!(result.issues.len(), 0);
       assert_eq!(result.mrs.authored.len(), 0);
       assert_eq!(result.mrs.reviewing.len(), 3);
   }

   #[test]
   fn parse_lore_me_empty() {
       let fixture = include_str!("fixtures/lore/me_empty.json");
       let result: LoreMeResponse = serde_json::from_str(fixture).unwrap();

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

2. **GREEN: Implement types and wrapper:**
   ```rust
   // src-tauri/src/data/lore.rs
   #[derive(Deserialize, Debug)]
   #[serde(deny_unknown_fields)]  // Catch schema drift!
   pub struct LoreMeResponse {
       pub ok: bool,
       pub data: LoreMeData,
       pub meta: Option<LoreMeta>,
   }

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

   pub struct RealLoreCli;
   impl LoreCli for RealLoreCli {
       fn me(&self) -> Result<LoreMeResponse> {
           let output = Command::new("lore")
               .args(["--robot", "me"])
               .output()?;

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

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

3. **Contract test:**
   ```rust
   #[test]
   fn lore_response_schema_unchanged() {
       // This test fails if lore's output format changes
       let fixture = include_str!("fixtures/lore/me_with_reviews.json");
       let _: LoreMeResponse = serde_json::from_str(fixture)
           .expect("Fixture should match current schema");
   }
   ```

**Types to define:**
- LoreMeResponse (top-level)
- LoreIssue (assigned issues)
- LoreMr (MRs with authored/reviewing)
- LoreEvent (since_last_check events)
- LoreMeta (optional metadata)

**Acceptance criteria:**
- All fixture files parse successfully
- Types have #[serde(deny_unknown_fields)] for drift detection
- RealLoreCli shells out to actual lore command
- MockLoreCli returns fixture data for tests
- Error messages are actionable (not just "parse failed")


Phase 3: Focus View

# Focus View — THE ONE THING

**Context:** The Focus View is the default view and the heart of Mission Control's UX. It shows THE single most important thing you should be doing right now.

**UX principle:** This is NOT a list. It's a single, prominent card that demands attention.

**Duration estimate:** 1-2 days

**Scope includes:**
1. FocusCard component with prominent display
2. Action buttons: Start, Defer (1h, tomorrow, custom), Skip
3. Keyboard shortcuts: Enter=Start, ⌘1=1hr, ⌘2=tomorrow, ⌘S=Skip
4. Decision logging with reason capture
5. Quick tags for common reasons

**Visual design:**
```
┌─────────────────────────────────────────────────────────────┐
│                                                             │
│                    ┌───────────────────┐                    │
│                    │  🔴  MR REVIEW    │                    │
│                    └───────────────────┘                    │
│                                                             │
│     Fix authentication token refresh logic                  │
│     ─────────────────────────────────────                   │
│                                                             │
│     !847 in platform/core • 47 lines changed                │
│                                                             │
│     ┌─────────────────────────────────────────────────┐     │
│     │  @sarah requested 2 days ago                    │     │
│     │  "Can you take a look? I need this for the      │     │
│     │   release tomorrow"                             │     │
│     └─────────────────────────────────────────────────┘     │
│                                                             │
│     ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌───────┐    │
│     │  Start   │  │ 1 hour   │  │ Tomorrow │  │ Skip  │    │
│     │    ↵     │  │   ⌘1     │  │    ⌘2    │  │  ⌘S   │    │
│     └──────────┘  └──────────┘  └──────────┘  └───────┘    │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│  Queue: 3 more reviews • 2 assigned issues • 5 mentions     │
└─────────────────────────────────────────────────────────────┘
```

**Behavior:**
- "Start" opens GitLab in browser
- Defer options: 1 hour, tomorrow, custom
- Skip removes from today's list (logged with reason)

**Dependencies:**
- Requires Phase 2 (Bridge) for data
- Blocks Phase 5 (Batch Mode) which uses similar card UI

**Acceptance criteria:**
- Focus card renders current focus item prominently
- All actions (Start/Defer/Skip) work with logging
- Keyboard shortcuts function
- Reason capture prompts on significant actions


Implement TanStack Query data fetching layer

# TanStack Query Data Fetching

**Parent epic:** Phase 1: Foundation

**Purpose:** Handle async data fetching, caching, and invalidation for lore data and bead operations.

**Query structure:**

```typescript
// Work items from lore
useQuery({ queryKey: ['work-items'], queryFn: fetchWorkItems })

// Bead details (on demand)
useQuery({ queryKey: ['bead', id], queryFn: () => fetchBead(id) })

// BV triage recommendations
useQuery({ queryKey: ['triage'], queryFn: fetchTriage, staleTime: 5 * 60 * 1000 })
```

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

```typescript
// tests/hooks/useWorkItems.test.ts
describe('useWorkItems', () => {
  it('fetches work items from lore', async () => {
    const mockItems = [{ id: '1', title: 'Test' }];
    vi.mocked(invoke).mockResolvedValue(mockItems);

    const { result } = renderHook(() => useWorkItems(), {
      wrapper: QueryClientProvider
    });

    await waitFor(() => {
      expect(result.current.data).toEqual(mockItems);
    });
  });

  it('shows loading state initially', () => {
    const { result } = renderHook(() => useWorkItems(), {
      wrapper: QueryClientProvider
    });

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

  it('handles error gracefully', async () => {
    vi.mocked(invoke).mockRejectedValue(new Error('lore failed'));

    const { result } = renderHook(() => useWorkItems(), {
      wrapper: QueryClientProvider
    });

    await waitFor(() => {
      expect(result.current.error).toBeDefined();
      expect(result.current.error.message).toBe('lore failed');
    });
  });

  it('refetches on lore-db-changed event', async () => {
    const { result } = renderHook(() => useWorkItems(), {
      wrapper: QueryClientProvider
    });

    await waitFor(() => expect(result.current.isSuccess).toBe(true));

    // Emit event
    await emitEvent('lore-db-changed');

    // Should have refetched
    expect(invoke).toHaveBeenCalledTimes(2);
  });

  it('merges manual items with lore items', async () => {
    const loreItems = [{ id: '1', source: 'gitlab' }];
    const manualItems = [{ id: '2', source: 'manual' }];
    vi.mocked(invoke).mockImplementation((cmd) => {
      if (cmd === 'get_lore_items') return Promise.resolve(loreItems);
      if (cmd === 'get_manual_items') return Promise.resolve(manualItems);
    });

    const { result } = renderHook(() => useWorkItems(), {
      wrapper: QueryClientProvider
    });

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

**Implementation:**

```typescript
// src/hooks/useWorkItems.ts
export function useWorkItems() {
  const queryClient = useQueryClient();

  // Listen for lore.db changes
  useEffect(() => {
    const unlisten = listen('lore-db-changed', () => {
      queryClient.invalidateQueries({ queryKey: ['work-items'] });
    });
    return () => { unlisten.then(fn => fn()); };
  }, [queryClient]);

  return useQuery({
    queryKey: ['work-items'],
    queryFn: async () => {
      const [loreItems, manualItems] = await Promise.all([
        invoke<WorkItem[]>('get_lore_items'),
        invoke<WorkItem[]>('get_manual_items'),
      ]);
      return [...loreItems, ...manualItems];
    },
    staleTime: 30 * 1000, // Consider fresh for 30s
  });
}

// src/hooks/useTriage.ts
export function useTriage() {
  return useQuery({
    queryKey: ['triage'],
    queryFn: () => invoke<TriageResult>('get_triage'),
    staleTime: 5 * 60 * 1000, // BV recommendations cache 5min
    enabled: true,
  });
}

// src/lib/queryClient.ts
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 1,
      refetchOnWindowFocus: true,
    },
  },
});
```

**Mutations for actions:**

```typescript
// src/hooks/useActions.ts
export function useCompleteBead() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (id: string) => invoke('complete_bead', { id }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['work-items'] });
    },
  });
}
```

**Acceptance criteria:**
- Work items fetch and cache correctly
- Loading/error states handled
- Invalidation on lore.db change
- Mutations update cache
- Stale time appropriate for each query


Implement click-to-focus in Queue view

# Click-to-Focus in Queue View

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

**Purpose:** Clicking an item in the Queue should set it as THE ONE THING and navigate to Focus View.

**TDD: Click-to-focus tests (RED first):**

```typescript
// tests/components/QueueList.test.tsx
describe('QueueList click-to-focus', () => {
  it('calls setFocus when item clicked', async () => {
    const setFocus = vi.fn();
    render(<QueueList items={mockItems} onSetFocus={setFocus} />);

    await userEvent.click(screen.getByText('Review MR !847'));

    expect(setFocus).toHaveBeenCalledWith('1');
  });

  it('navigates to focus view after setting focus', async () => {
    const setActiveView = vi.fn();
    const store = createStore({ setActiveView });
    render(<QueueList items={mockItems} />, { wrapper: StoreProvider(store) });

    await userEvent.click(screen.getByText('Review MR !847'));

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

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

    await userEvent.click(screen.getByText('Review MR !847'));

    // ReasonPrompt should appear
    expect(screen.getByText(/Setting focus to/)).toBeInTheDocument();
  });

  it('logs decision with context', async () => {
    const logDecision = vi.fn();
    render(<QueueList items={mockItems} logDecision={logDecision} />);

    await userEvent.click(screen.getByText('Review MR !847'));
    await userEvent.click(screen.getByRole('button', { name: /confirm/i }));

    expect(logDecision).toHaveBeenCalledWith(expect.objectContaining({
      action: 'set_focus',
      bead_id: '1',
      context: expect.objectContaining({
        available_items: expect.any(Array),
        queue_size: mockItems.length,
      })
    }));
  });
});
```

**Implementation update for QueueItem:**

```tsx
// In QueueList.tsx
function QueueItem({
  item,
  onSetFocus
}: {
  item: WorkItem;
  onSetFocus: (id: string) => void;
}) {
  const [showReasonPrompt, setShowReasonPrompt] = useState(false);
  const { logDecision, setActiveView } = useStore();

  const handleClick = () => {
    setShowReasonPrompt(true);
  };

  const handleConfirm = ({ reason, tags }) => {
    logDecision({
      action: 'set_focus',
      bead_id: item.id,
      reason,
      tags,
      context: captureContext(),
    });
    onSetFocus(item.id);
    setActiveView('focus');
    setShowReasonPrompt(false);
  };

  return (
    <>
      <div
        className="flex items-center gap-3 p-2 rounded cursor-pointer hover:bg-muted"
        onClick={handleClick}
      >
        <StalenessIndicator staleness={getStaleness(item.createdAt)} />
        <span className="flex-1 truncate">{item.title}</span>
        <span className="text-muted-foreground text-sm">{formatAge(item.createdAt)}</span>
      </div>

      {showReasonPrompt && (
        <ReasonPrompt
          action="set_focus"
          itemTitle={item.title}
          onSubmit={handleConfirm}
          onCancel={() => setShowReasonPrompt(false)}
        />
      )}
    </>
  );
}
```

**Acceptance criteria:**
- Click on queue item triggers focus flow
- Reason prompt appears for decision logging
- Focus is set in store
- Navigation to Focus View happens
- Decision logged with full context


Implement staleness color visualization

# Staleness Color Visualization (RED → GREEN)

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

**Color scheme:**
| Age | Color | CSS Class | Visual |
|-----|-------|-----------|--------|
| < 24h | Green | text-green-500 | Fresh, bright |
| 1-2 days | Default | text-foreground | Normal |
| 3-6 days | Amber | text-amber-500 | Warning |
| 7+ days | Red + pulse | text-red-500 animate-pulse | Urgent |

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

```typescript
// tests/lib/staleness.test.ts
describe('getStaleness', () => {
  it('returns fresh for < 24h', () => {
    const date = new Date(Date.now() - 12 * 60 * 60 * 1000); // 12h ago
    expect(getStaleness(date)).toBe('fresh');
  });

  it('returns normal for 1-2 days', () => {
    const date = new Date(Date.now() - 36 * 60 * 60 * 1000); // 36h ago
    expect(getStaleness(date)).toBe('normal');
  });

  it('returns stale for 3-6 days', () => {
    const date = new Date(Date.now() - 4 * 24 * 60 * 60 * 1000); // 4d ago
    expect(getStaleness(date)).toBe('stale');
  });

  it('returns urgent for 7+ days', () => {
    const date = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); // 10d ago
    expect(getStaleness(date)).toBe('urgent');
  });
});

// tests/components/StalenessIndicator.test.tsx
describe('StalenessIndicator', () => {
  it('shows green dot for fresh', () => {
    render(<StalenessIndicator staleness="fresh" />);
    expect(screen.getByTestId('staleness-indicator')).toHaveClass('bg-green-500');
  });

  it('shows pulsing red for urgent', () => {
    render(<StalenessIndicator staleness="urgent" />);
    const indicator = screen.getByTestId('staleness-indicator');
    expect(indicator).toHaveClass('bg-red-500', 'animate-pulse');
  });
});
```

**Implementation (GREEN):**

```typescript
// src/lib/staleness.ts
export type Staleness = 'fresh' | 'normal' | 'stale' | 'urgent';

export function getStaleness(date: Date): Staleness {
  const hoursAgo = (Date.now() - date.getTime()) / (1000 * 60 * 60);

  if (hoursAgo < 24) return 'fresh';
  if (hoursAgo < 48) return 'normal';
  if (hoursAgo < 168) return 'stale'; // 7 days
  return 'urgent';
}

export function getStalenessClasses(staleness: Staleness): string {
  switch (staleness) {
    case 'fresh': return 'text-green-500';
    case 'normal': return 'text-foreground';
    case 'stale': return 'text-amber-500';
    case 'urgent': return 'text-red-500 animate-pulse';
  }
}

// src/components/StalenessIndicator.tsx
export function StalenessIndicator({ staleness }: { staleness: Staleness }) {
  const classes = {
    fresh: 'bg-green-500',
    normal: 'bg-gray-400',
    stale: 'bg-amber-500',
    urgent: 'bg-red-500 animate-pulse',
  };

  return (
    <div
      data-testid="staleness-indicator"
      className={cn('w-2 h-2 rounded-full', classes[staleness])}
      aria-label={`Item is ${staleness}`}
    />
  );
}
```

**Acceptance criteria:**
- All staleness tests pass
- Colors visible in both light/dark themes
- Pulse animation smooth
- Accessible (aria-label for screen readers)


Implement schema migration utilities for state files

Implement schema versioning and migration utilities for MC's local JSON state files. PLAN.md specifies: 'Each JSON file includes a schema_version field. On load, migrate if version < current.'

BACKGROUND:
- MC stores state in JSON files: gitlab_bead_map.json, state.json, settings.json, decision_log.jsonl
- Schema will evolve as features are added
- Need forward migration without data loss
- Need graceful handling of future versions (warn but don't crash)

IMPLEMENTATION (Rust):
- Define schema version constants per file type
- Migration trait: fn migrate(from_version: u32, data: Value) -> Result<Value>
- Register migration functions: v1→v2, v2→v3, etc.
- On load: check version, run migrations in sequence, save updated file
- Handle unknown version: log warning, continue with best effort

SCHEMA VERSION TRACKING:
- gitlab_bead_map.json: schema_version: 1
- state.json: schema_version: 1
- settings.json: schema_version: 1
- decision_log.jsonl: no versioning (append-only, entries self-describe)

TESTING (TDD):
- Test loading v1 file with v1 code (no migration)
- Test loading v1 file with v2 code (migration runs)
- Test migration chain v1→v2→v3
- Test unknown version handling
- Test migration failure rollback

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

INVARIANT:
- Migrations must be idempotent (re-running doesn't corrupt)


Implement mapping file with atomic writes and schema versioning

# Mapping File Management (RED → GREEN)

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

**File location:** ~/.local/share/mc/gitlab_bead_map.json

**Schema:**
```json
{
  "schema_version": 1,
  "cursor": {
    "last_check_timestamp": "2026-02-25T10:30:00Z",
    "last_reconciliation": "2026-02-25T06:00:00Z"
  },
  "mappings": {
    "mr_review:gitlab.com:12345:847": {
      "bead_id": "br-x7f",
      "created_at": "2026-02-23T14:00:00Z",
      "suspect_orphan": false,
      "pending": false
    }
  }
}
```

**TDD: File operations tests (RED first):**

```rust
#[test]
fn atomic_write_survives_crash() {
    let temp = tempfile::tempdir().unwrap();
    let path = temp.path().join("map.json");

    // Write initial state
    write_mapping_atomic(&path, &mapping_v1)?;

    // Simulate crash during write (leave .tmp file)
    std::fs::write(path.with_extension("json.tmp"), "corrupt")?;

    // Recovery should use last good state
    let recovered = load_mapping(&path)?;
    assert_eq!(recovered, mapping_v1);
}

#[test]
fn schema_migration_v1_to_v2() {
    let v1_json = r#"{"schema_version": 1, ...}"#;
    let temp = tempfile::tempdir().unwrap();
    let path = temp.path().join("map.json");
    std::fs::write(&path, v1_json)?;

    let mapping = load_mapping(&path)?;

    // Should auto-migrate to v2
    assert_eq!(mapping.schema_version, 2);
}

#[test]
fn corrupted_file_loads_backup() {
    let temp = tempfile::tempdir().unwrap();
    let path = temp.path().join("map.json");
    let backup = path.with_extension("json.bak");

    // Write valid backup
    write_mapping_atomic(&path, &valid_mapping)?;
    std::fs::copy(&path, &backup)?;

    // Corrupt main file
    std::fs::write(&path, "not json")?;

    // Should fall back to backup
    let recovered = load_mapping(&path)?;
    assert_eq!(recovered, valid_mapping);
}
```

**Implementation (GREEN):**

```rust
pub fn write_mapping_atomic(path: &Path, mapping: &Mapping) -> Result<()> {
    // 1. Write backup of current file
    if path.exists() {
        let backup = path.with_extension("json.bak");
        std::fs::copy(path, &backup)?;
    }

    // 2. Write to temp file
    let tmp = path.with_extension("json.tmp");
    let file = std::fs::File::create(&tmp)?;
    serde_json::to_writer_pretty(file, mapping)?;

    // 3. Atomic rename (POSIX guarantees atomicity)
    std::fs::rename(&tmp, path)?;

    Ok(())
}

pub fn load_mapping(path: &Path) -> Result<Mapping> {
    // 1. Check for interrupted write (.tmp exists)
    let tmp = path.with_extension("json.tmp");
    if tmp.exists() {
        std::fs::remove_file(&tmp)?;  // Discard incomplete write
    }

    // 2. Try loading main file
    match std::fs::read_to_string(path) {
        Ok(json) => {
            let mapping: Mapping = serde_json::from_str(&json)
                .or_else(|_| load_backup(path))?;
            migrate_if_needed(mapping)
        }
        Err(_) if path.exists() => load_backup(path),
        Err(_) => Ok(Mapping::default()),
    }
}
```

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

**Acceptance criteria:**
- Atomic writes prevent corruption
- Crash recovery finds last good state
- Schema migrations preserve data
- Backup file exists after every write


Implement FocusCard component

# FocusCard Component (RED → GREEN)

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

**Purpose:** The FocusCard is the single most important UI element in Mission Control. It displays THE ONE THING prominently with all relevant context.

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

```typescript
// tests/components/FocusCard.test.tsx
describe('FocusCard', () => {
  const mockItem: WorkItem = {
    id: 'br-x7f',
    title: 'Fix authentication token refresh logic',
    type: 'mr_review',
    source: 'gitlab',
    metadata: {
      iid: 847,
      project: 'platform/core',
      author: '@sarah',
      linesChanged: 47,
      message: "Can you take a look? I need this for the release tomorrow"
    },
    createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), // 2 days ago
  };

  it('renders item title prominently', () => {
    render(<FocusCard item={mockItem} />);

    expect(screen.getByRole('heading')).toHaveTextContent('Fix authentication token refresh logic');
  });

  it('shows item type badge', () => {
    render(<FocusCard item={mockItem} />);

    expect(screen.getByTestId('type-badge')).toHaveTextContent('MR REVIEW');
  });

  it('displays metadata context', () => {
    render(<FocusCard item={mockItem} />);

    expect(screen.getByText('!847 in platform/core')).toBeInTheDocument();
    expect(screen.getByText('47 lines changed')).toBeInTheDocument();
    expect(screen.getByText('@sarah requested 2 days ago')).toBeInTheDocument();
  });

  it('calls onStart when Start button clicked', async () => {
    const onStart = vi.fn();
    render(<FocusCard item={mockItem} onStart={onStart} />);

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

    expect(onStart).toHaveBeenCalledWith(mockItem.id);
  });

  it('shows staleness indicator based on age', () => {
    const oldItem = { ...mockItem, createdAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000) };
    render(<FocusCard item={oldItem} />);

    expect(screen.getByTestId('staleness-indicator')).toHaveClass('bg-amber-500');
  });

  it('shows red pulsing for very stale items', () => {
    const veryOldItem = { ...mockItem, createdAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000) };
    render(<FocusCard item={veryOldItem} />);

    expect(screen.getByTestId('staleness-indicator')).toHaveClass('bg-red-500', 'animate-pulse');
  });
});
```

**Implementation (GREEN):**

```tsx
// src/components/FocusCard.tsx
interface FocusCardProps {
  item: WorkItem;
  onStart: (id: string) => void;
  onDefer: (id: string, duration: Duration) => void;
  onSkip: (id: string) => void;
}

export function FocusCard({ item, onStart, onDefer, onSkip }: FocusCardProps) {
  const staleness = getStaleness(item.createdAt);

  return (
    <Card className="max-w-2xl mx-auto">
      <CardHeader className="text-center">
        <TypeBadge type={item.type} staleness={staleness} />
        <CardTitle className="text-2xl mt-4">{item.title}</CardTitle>
      </CardHeader>

      <CardContent className="space-y-4">
        <Metadata item={item} />

        {item.metadata.message && (
          <blockquote className="border-l-4 pl-4 italic">
            {item.metadata.message}
          </blockquote>
        )}
      </CardContent>

      <CardFooter className="flex justify-center gap-4">
        <Button onClick={() => onStart(item.id)}>
          Start <kbd className="ml-2">↵</kbd>
        </Button>
        <Button variant="outline" onClick={() => onDefer(item.id, '1h')}>
          1 hour <kbd className="ml-2">⌘1</kbd>
        </Button>
        <Button variant="outline" onClick={() => onDefer(item.id, 'tomorrow')}>
          Tomorrow <kbd className="ml-2">⌘2</kbd>
        </Button>
        <Button variant="ghost" onClick={() => onSkip(item.id)}>
          Skip <kbd className="ml-2">⌘S</kbd>
        </Button>
      </CardFooter>
    </Card>
  );
}
```

**Staleness calculation:**
- Fresh (< 24h): green/bright
- Aging (1-2 days): normal
- Stale (3-6 days): amber
- Very stale (7+ days): red pulsing

**Acceptance criteria:**
- All tests pass
- Card renders all item information
- Staleness colors work correctly
- Action callbacks fire on button clicks


Create fixture directory structure and capture CLI outputs

# CLI Fixture Directory and Initial Captures

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

**Why fixtures:**
MC's correctness depends on correctly parsing lore and br CLI outputs.
Real outputs change rarely but MUST match our parsing.
Fixtures serve two purposes:
1. Test data for unit/integration tests
2. Contract tests to detect CLI schema drift

**Directory structure:**

```
src-tauri/tests/fixtures/
├── lore/
│   ├── me_empty.json           # Empty since_last_check, no items
│   ├── me_with_reviews.json    # 3 MR reviews in since_last_check
│   ├── me_with_issues.json     # Issues assigned to user
│   ├── me_mixed.json           # Mix of reviews, issues, mentions
│   └── me_stale_cursor.json    # Empty since_last_check but has open items
└── br/
    ├── create_success.json     # Successful bead creation
    ├── create_error.json       # Validation error
    ├── close_success.json      # Successful close
    ├── list.json               # List output with multiple beads
    └── ready.json              # Ready (non-blocked) beads
```

**Fixture capture script:**

```bash
#!/bin/bash
# scripts/regenerate-fixtures.sh
set -e
mkdir -p src-tauri/tests/fixtures/{lore,br}

# Lore fixtures
lore --robot me > src-tauri/tests/fixtures/lore/me_current.json

# BR fixtures
br create --title "Test fixture" --type task --json > src-tauri/tests/fixtures/br/create_success.json
br list --json > src-tauri/tests/fixtures/br/list.json
```

**Initial fixtures to create manually:**
- me_empty.json: Minimal valid response with empty arrays
- me_with_reviews.json: 3 sample MR reviews with all required fields
- me_stale_cursor.json: Simulates cursor recovery scenario

**Acceptance criteria:**
- All fixture files exist with valid JSON
- scripts/regenerate-fixtures.sh captures real CLI outputs
- Rust tests can include_str! fixtures without path issues
- CI job can compare regenerated fixtures against committed versions


Implement bridge state machine with lifecycle transitions

# Bridge State Machine (RED → GREEN)

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

**State machine overview:**

```
                    ┌─────────────────────────────────────┐
                    │                                     │
                    ▼                                     │
┌────────┐     ┌────────┐     ┌─────────────┐     ┌──────┴───┐
│ (new)  │────▶│ active │────▶│ suspect_    │────▶│  closed  │
│ event  │     │        │     │ orphan      │     │          │
└────────┘     └────────┘     └─────────────┘     └──────────┘
                    │                                     ▲
                    │         (user closes bead)          │
                    └─────────────────────────────────────┘
```

**TDD: State transition tests (RED first):**

```rust
#[test]
fn new_event_creates_active_mapping() {
    let mut bridge = BridgeState::new(mock_lore(), mock_beads());
    let event = lore_event("mr_review:gitlab.com:12345:847");

    bridge.process_event(event);

    let entry = bridge.mapping.get("mr_review:gitlab.com:12345:847").unwrap();
    assert!(!entry.suspect_orphan);
    assert!(!entry.pending);
    assert!(entry.bead_id.is_some());
}

#[test]
fn duplicate_event_skips() {
    let mut bridge = setup_with_existing_mapping("mr_review:...", "br-x7f");
    let event = lore_event("mr_review:...");

    bridge.process_event(event.clone());
    bridge.process_event(event); // Duplicate

    // Should still have exactly one mapping
    assert_eq!(bridge.mapping.len(), 1);
    assert_invariants(&bridge);
}

#[test]
fn missing_once_sets_suspect_orphan() {
    let mut bridge = setup_with_existing_mapping("mr_review:...", "br-x7f");

    // Reconciliation with item missing from lore
    bridge.reconcile(&[/* item not present */]);

    let entry = bridge.mapping.get("mr_review:...").unwrap();
    assert!(entry.suspect_orphan);
    // Bead should NOT be closed yet (first strike)
    assert!(bridge.beads.exists("br-x7f").unwrap());
}

#[test]
fn missing_twice_closes_bead() {
    let mut bridge = setup_with_suspect_orphan("mr_review:...", "br-x7f");

    // Second reconciliation, still missing
    bridge.reconcile(&[/* item not present */]);

    // Entry should be removed, bead should be closed
    assert!(bridge.mapping.get("mr_review:...").is_none());
    assert!(!bridge.beads.exists("br-x7f").unwrap());
}

#[test]
fn reappears_clears_suspect_flag() {
    let mut bridge = setup_with_suspect_orphan("mr_review:...", "br-x7f");

    // Reconciliation with item present again
    bridge.reconcile(&[lore_item("mr_review:...")]);

    let entry = bridge.mapping.get("mr_review:...").unwrap();
    assert!(!entry.suspect_orphan);
}

#[test]
fn user_close_removes_mapping() {
    let mut bridge = setup_with_existing_mapping("mr_review:...", "br-x7f");

    bridge.user_closed_bead("br-x7f");

    assert!(bridge.mapping.get("mr_review:...").is_none());
}
```

**Implementation (GREEN):**

```rust
pub struct BridgeState<L: LoreCli, B: BeadsCli> {
    lore: L,
    beads: B,
    mapping: Mapping,
}

impl<L: LoreCli, B: BeadsCli> BridgeState<L, B> {
    pub fn process_event(&mut self, event: LoreEvent) -> Result<()> {
        let key = event.to_mapping_key();

        // Skip if already mapped (idempotent)
        if self.mapping.contains_key(&key) {
            return Ok(());
        }

        // Create bead
        let bead_id = self.beads.create(&event.title(), "gitlab")?;

        // Add to mapping
        self.mapping.insert(key, MappingEntry {
            bead_id: Some(bead_id),
            created_at: Utc::now(),
            suspect_orphan: false,
            pending: false,
        });

        Ok(())
    }

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

        // ... two-strike logic
    }
}
```

**Acceptance criteria:**
- All 6 state transition tests pass
- Invariant assertion runs after every operation
- State machine handles all edge cases


Implement batch celebration component

# Batch Celebration Component

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

**Purpose:** Rewarding visual feedback when completing a batch. ADHD-friendly dopamine hit!

**Design:**
- Confetti animation
- Large "All done!" text
- Stats (completed count, time taken)
- Optional sound effect
- Button to return to Focus View

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

```typescript
// tests/components/BatchCelebration.test.tsx
describe('BatchCelebration', () => {
  it('shows confetti animation', () => {
    render(<BatchCelebration completed={4} total={4} />);

    expect(screen.getByTestId('confetti')).toBeInTheDocument();
  });

  it('shows completion stats', () => {
    render(<BatchCelebration completed={4} total={4} timeTaken={1200} />);

    expect(screen.getByText('All done!')).toBeInTheDocument();
    expect(screen.getByText('4/4 completed')).toBeInTheDocument();
    expect(screen.getByText('20 minutes')).toBeInTheDocument();
  });

  it('shows partial completion message when skipped', () => {
    render(<BatchCelebration completed={3} total={4} skipped={1} />);

    expect(screen.getByText('3/4 completed')).toBeInTheDocument();
    expect(screen.getByText('1 skipped')).toBeInTheDocument();
  });

  it('calls onClose when button clicked', async () => {
    const onClose = vi.fn();
    render(<BatchCelebration onClose={onClose} />);

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

    expect(onClose).toHaveBeenCalled();
  });

  it('plays sound effect if enabled', () => {
    const mockPlay = vi.fn();
    vi.spyOn(Audio.prototype, 'play').mockImplementation(mockPlay);

    render(<BatchCelebration soundEnabled={true} />);

    expect(mockPlay).toHaveBeenCalled();
  });

  it('respects reduced motion preference', () => {
    vi.spyOn(window, 'matchMedia').mockReturnValue({
      matches: true, // prefers-reduced-motion
    } as MediaQueryList);

    render(<BatchCelebration />);

    expect(screen.queryByTestId('confetti')).not.toBeInTheDocument();
    expect(screen.getByText('All done!')).toBeInTheDocument();
  });
});
```

**Implementation:**

```tsx
// src/components/BatchCelebration.tsx
import Confetti from 'react-confetti';
import { useWindowSize } from 'react-use';
import { motion } from 'framer-motion';

interface BatchCelebrationProps {
  completed: number;
  total: number;
  skipped?: number;
  timeTaken?: number; // seconds
  soundEnabled?: boolean;
  onClose: () => void;
}

export function BatchCelebration({
  completed,
  total,
  skipped = 0,
  timeTaken,
  soundEnabled = true,
  onClose
}: BatchCelebrationProps) {
  const { width, height } = useWindowSize();
  const prefersReducedMotion = useReducedMotion();

  // Play celebration sound
  useEffect(() => {
    if (soundEnabled && !prefersReducedMotion) {
      const audio = new Audio('/sounds/celebration.mp3');
      audio.volume = 0.3;
      audio.play().catch(() => {}); // Ignore autoplay errors
    }
  }, [soundEnabled, prefersReducedMotion]);

  return (
    <div className="fixed inset-0 bg-background flex flex-col items-center justify-center">
      {!prefersReducedMotion && (
        <Confetti
          data-testid="confetti"
          width={width}
          height={height}
          recycle={false}
          numberOfPieces={200}
          gravity={0.3}
        />
      )}

      <motion.div
        initial={{ scale: 0, opacity: 0 }}
        animate={{ scale: 1, opacity: 1 }}
        transition={{ type: 'spring', duration: 0.5, delay: 0.2 }}
        className="text-center"
      >
        <motion.div
          animate={{ rotate: [0, 10, -10, 0] }}
          transition={{ duration: 0.5, delay: 0.5 }}
          className="text-6xl mb-4"
        >
          🎉
        </motion.div>

        <h1 className="text-4xl font-bold mb-2">All done!</h1>

        <p className="text-xl text-muted-foreground mb-1">
          {completed}/{total} completed
        </p>

        {skipped > 0 && (
          <p className="text-muted-foreground">{skipped} skipped</p>
        )}

        {timeTaken && (
          <p className="text-muted-foreground mt-2">
            {formatDuration(timeTaken)}
          </p>
        )}

        <Button className="mt-8" size="lg" onClick={onClose}>
          Back to Focus
        </Button>
      </motion.div>
    </div>
  );
}

function formatDuration(seconds: number): string {
  const minutes = Math.round(seconds / 60);
  if (minutes < 60) return `${minutes} minutes`;
  const hours = Math.floor(minutes / 60);
  const remainingMins = minutes % 60;
  return `${hours}h ${remainingMins}m`;
}
```

**Assets needed:**
- `/public/sounds/celebration.mp3` — Short celebration sound

**Acceptance criteria:**
- Confetti animation plays
- Stats display correctly
- Sound plays if enabled
- Respects prefers-reduced-motion
- Button navigates back to focus


Implement invariant assertion helpers

# Invariant Assertion Helpers

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

**Bridge Invariants (must ALWAYS hold):**

| ID | Invariant |
|----|-----------|
| INV-1 | **No duplicate beads.** Each mapping key maps to exactly one bead ID. |
| INV-2 | **No orphan beads.** Every bead ID in the map exists in beads. |
| INV-3 | **No false closes.** Items only auto-closed after missing in TWO reconciliations. |
| INV-4 | **Cursor monotonicity.** Cursor only advances forward, never backward. |

**Implementation:**

```rust
/// Validates all bridge invariants. Call after every operation in tests.
pub fn assert_invariants<B: BeadsCli>(
    mapping: &Mapping,
    beads: &B,
    prev_cursor: Option<&Cursor>,
) -> Result<(), InvariantViolation> {
    // INV-1: No duplicate bead IDs
    let bead_ids: Vec<_> = mapping.values()
        .filter_map(|e| e.bead_id.as_ref())
        .collect();
    let unique: HashSet<_> = bead_ids.iter().collect();
    if bead_ids.len() != unique.len() {
        return Err(InvariantViolation::DuplicateBeads);
    }

    // INV-2: No orphan beads
    for entry in mapping.values() {
        if let Some(id) = &entry.bead_id {
            if !beads.exists(id)? {
                return Err(InvariantViolation::OrphanBead(id.clone()));
            }
        }
    }

    // INV-4: Cursor monotonicity
    if let Some(prev) = prev_cursor {
        let curr = &mapping.cursor;
        if curr.last_check_timestamp < prev.last_check_timestamp {
            return Err(InvariantViolation::CursorRegression {
                prev: prev.last_check_timestamp,
                curr: curr.last_check_timestamp,
            });
        }
    }

    Ok(())
}

#[derive(Debug)]
pub enum InvariantViolation {
    DuplicateBeads,
    OrphanBead(String),
    FalseClose(String),
    CursorRegression { prev: DateTime<Utc>, curr: DateTime<Utc> },
}
```

**Test helper macro:**

```rust
macro_rules! assert_bridge_invariants {
    ($bridge:expr) => {
        assert_invariants(&$bridge.mapping, &$bridge.beads, None)
            .expect("Bridge invariants violated!");
    };
    ($bridge:expr, $prev_cursor:expr) => {
        assert_invariants(&$bridge.mapping, &$bridge.beads, Some($prev_cursor))
            .expect("Bridge invariants violated!");
    };
}
```

**Usage in tests:**

```rust
#[test]
fn reconciliation_maintains_invariants() {
    let mut bridge = setup_bridge();

    bridge.process_event(event1)?;
    assert_bridge_invariants!(bridge);

    bridge.reconcile(&[])?;
    assert_bridge_invariants!(bridge);

    bridge.reconcile(&[])?; // Second miss → close
    assert_bridge_invariants!(bridge);
}
```

**INV-3 testing note:**
INV-3 (no false closes) is tested via state machine tests rather than assertion helper, since it requires tracking the sequence of reconciliations.

**Acceptance criteria:**
- Assertion helper catches all invariant violations
- Clear error messages identify which invariant failed
- Macro available for concise test assertions
- All state machine tests call invariant assertions


Implement shared TypeScript type definitions

# TypeScript Type Definitions

**Parent epic:** Phase 1: Foundation

**Purpose:** Shared types used across frontend components. Should match Rust types for IPC.

**Types to define:**

```typescript
// src/types/index.ts

// Work item types
export type ItemSource = 'gitlab' | 'manual';
export type ItemType = 'mr_review' | 'issue' | 'mr_authored' | 'mention' | 'feedback' | 'manual';

export interface WorkItem {
  id: string;
  title: string;
  type: ItemType;
  source: ItemSource;
  createdAt: Date;
  url?: string;
  triaged: boolean;
  snoozedUntil?: Date;
  skippedToday?: boolean;
  metadata?: WorkItemMetadata;
}

export interface WorkItemMetadata {
  // GitLab fields
  iid?: number;
  project?: string;
  projectId?: number;
  author?: string;
  linesChanged?: number;
  message?: string;

  // Computed fields
  staleness?: Staleness;
}

// Staleness
export type Staleness = 'fresh' | 'normal' | 'stale' | 'urgent';

// Sync status
export type SyncState = 'synced' | 'syncing' | 'stale' | 'error' | 'offline';

export interface SyncStatus {
  state: SyncState;
  lastSync: Date | null;
  error: string | null;
}

// Decision logging
export type ActionType =
  | 'set_focus'
  | 'reorder'
  | 'defer'
  | 'snooze'
  | 'skip'
  | 'complete'
  | 'create_manual'
  | 'triage';

export interface DecisionEntry {
  timestamp?: Date;
  action: ActionType;
  bead_id: string;
  reason?: string;
  tags?: string[];
  context?: DecisionContext;
}

export interface DecisionContext {
  previous_focus?: string;
  queue_size?: number;
  available_items?: string[];
  time_of_day?: 'morning' | 'afternoon' | 'evening';
  day_of_week?: string;
  items_completed_today?: number;
}

// App state (persisted)
export interface AppState {
  focusId: string | null;
  queueOrder: string[];
  activeView: ViewType;
}

export type ViewType = 'focus' | 'queue' | 'inbox' | 'settings';

// Filter types
export interface Filter {
  type?: ItemType;
  source?: ItemSource;
  minAge?: number;
  text?: string;
}

// Reorder data
export interface ReorderData {
  itemId: string;
  oldIndex: number;
  newIndex: number;
  oldOrder: string[];
  newOrder: string[];
}
```

**Rust type matching:**

```rust
// src-tauri/src/types.rs
// These must serialize to match TypeScript

#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkItem {
    pub id: String,
    pub title: String,
    #[serde(rename = "type")]
    pub item_type: ItemType,
    pub source: ItemSource,
    pub created_at: DateTime<Utc>,
    pub url: Option<String>,
    pub triaged: bool,
    pub snoozed_until: Option<DateTime<Utc>>,
    pub skipped_today: Option<bool>,
    pub metadata: Option<WorkItemMetadata>,
}

#[derive(Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ItemType {
    MrReview,
    Issue,
    MrAuthored,
    Mention,
    Feedback,
    Manual,
}
```

**TDD: Type tests:**

```typescript
// tests/unit/types.test.ts
describe('Type guards', () => {
  it('isWorkItem validates required fields', () => {
    expect(isWorkItem({ id: '1', title: 'Test', type: 'manual', source: 'manual' })).toBe(true);
    expect(isWorkItem({ id: '1' })).toBe(false);
  });

  it('parseWorkItem handles date conversion', () => {
    const raw = { ...mockItem, createdAt: '2026-02-25T10:00:00Z' };
    const parsed = parseWorkItem(raw);

    expect(parsed.createdAt).toBeInstanceOf(Date);
  });
});
```

**Acceptance criteria:**
- All types match between Rust and TypeScript
- Date serialization works correctly
- Type guards available for runtime validation
- No `any` types in codebase


Implement QuickCapture overlay component

# QuickCapture Overlay Component (RED → GREEN)

**Parent epic:** Phase 6: Quick Capture

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

```typescript
// tests/components/QuickCapture.test.tsx
describe('QuickCapture', () => {
  it('renders overlay centered on screen', () => {
    render(<QuickCapture isOpen={true} />);

    const overlay = screen.getByTestId('quick-capture-overlay');
    expect(overlay).toHaveClass('fixed', 'inset-0');
    expect(screen.getByRole('dialog')).toBeInTheDocument();
  });

  it('auto-focuses text input', () => {
    render(<QuickCapture isOpen={true} />);

    expect(screen.getByRole('textbox')).toHaveFocus();
  });

  it('captures text input', async () => {
    render(<QuickCapture isOpen={true} />);

    await userEvent.type(screen.getByRole('textbox'), 'Check webhook retry logic');

    expect(screen.getByRole('textbox')).toHaveValue('Check webhook retry logic');
  });

  it('creates bead on Enter', async () => {
    const onSave = vi.fn();
    render(<QuickCapture isOpen={true} onSave={onSave} />);

    await userEvent.type(screen.getByRole('textbox'), 'Check webhook retry logic{Enter}');

    expect(onSave).toHaveBeenCalledWith('Check webhook retry logic');
  });

  it('cancels on Escape', async () => {
    const onCancel = vi.fn();
    render(<QuickCapture isOpen={true} onCancel={onCancel} />);

    await userEvent.type(screen.getByRole('textbox'), 'partial text');
    await userEvent.keyboard('{Escape}');

    expect(onCancel).toHaveBeenCalled();
  });

  it('does not create bead for empty input', async () => {
    const onSave = vi.fn();
    render(<QuickCapture isOpen={true} onSave={onSave} />);

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

    expect(onSave).not.toHaveBeenCalled();
  });

  it('clears input after save', async () => {
    render(<QuickCapture isOpen={true} />);

    await userEvent.type(screen.getByRole('textbox'), 'Some thought{Enter}');

    // On next open, should be empty
    expect(screen.getByRole('textbox')).toHaveValue('');
  });
});
```

**Implementation (GREEN):**

```tsx
// src/components/QuickCapture.tsx
import { motion, AnimatePresence } from 'framer-motion';

interface QuickCaptureProps {
  isOpen: boolean;
  onSave: (text: string) => void;
  onCancel: () => void;
}

export function QuickCapture({ isOpen, onSave, onCancel }: QuickCaptureProps) {
  const [text, setText] = useState('');
  const inputRef = useRef<HTMLTextAreaElement>(null);

  useEffect(() => {
    if (isOpen) {
      inputRef.current?.focus();
    }
  }, [isOpen]);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      if (text.trim()) {
        onSave(text.trim());
        setText('');
      }
    } else if (e.key === 'Escape') {
      onCancel();
      setText('');
    }
  };

  return (
    <AnimatePresence>
      {isOpen && (
        <motion.div
          data-testid="quick-capture-overlay"
          className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.15 }}
        >
          <motion.div
            role="dialog"
            className="bg-background rounded-lg p-6 w-96 shadow-2xl"
            initial={{ scale: 0.9, opacity: 0 }}
            animate={{ scale: 1, opacity: 1 }}
            exit={{ scale: 0.9, opacity: 0 }}
            transition={{ type: 'spring', duration: 0.2 }}
          >
            <Textarea
              ref={inputRef}
              value={text}
              onChange={(e) => setText(e.target.value)}
              onKeyDown={handleKeyDown}
              placeholder="Quick thought..."
              className="min-h-[100px] resize-none"
              autoFocus
            />

            <div className="flex justify-between mt-4 text-sm text-muted-foreground">
              <span>⏎ Save & close</span>
              <span>ESC Cancel</span>
            </div>
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}
```

**Integration with Tauri:**

```rust
// src-tauri/src/commands/capture.rs
#[tauri::command]
pub async fn quick_capture(
    text: String,
    beads: State<'_, Box<dyn BeadsCli>>,
    decision_log: State<'_, DecisionLogger>,
) -> Result<String, String> {
    let bead_id = beads.create(&text, "manual")
        .map_err(|e| e.to_string())?;

    decision_log.log(DecisionEntry {
        action: Action::CreateManual,
        bead_id: bead_id.clone(),
        reason: None,
        tags: vec![],
        context: DecisionContext::capture(),
    }).map_err(|e| e.to_string())?;

    Ok(bead_id)
}
```

**Acceptance criteria:**
- Overlay renders with animation
- Auto-focus on open
- Enter saves and dismisses
- Escape cancels
- Bead created via br CLI


Implement bridge sync orchestrator

# Bridge Sync Orchestrator

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

**Purpose:** The main function that coordinates the sync cycle: file watcher → lore call → event processing → bead creation → state update.

**Sync flow:**

```
lore.db mtime changes (file watcher)
         │
         ▼
    debounce (500ms)
         │
         ▼
    call lore --robot me
         │
         ▼
    ┌────┴────┐
    │ success │────────────────────────────────────┐
    └────┬────┘                                    │
         │                                         │
         ▼                                         ▼
    process since_last_check              check if reconciliation due
         │                                         │
         ▼                                         ▼
    for each event:                      if due: full reconciliation
      - check idempotency
      - create bead if new
      - update mapping
         │
         ▼
    advance cursor (only on full success)
         │
         ▼
    emit 'sync-complete' event to frontend
```

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

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

#[tokio::test]
async fn sync_processes_new_events() {
    let lore = MockLoreCli::with_events(vec![
        event("mr_review", "gitlab.com", 123, 847),
        event("issue", "gitlab.com", 123, 312),
    ]);
    let beads = MockBeadsCli::new();
    let orchestrator = SyncOrchestrator::new(lore, beads);

    orchestrator.sync().await.unwrap();

    assert_eq!(beads.create_calls().len(), 2);
}

#[tokio::test]
async fn sync_skips_existing_mappings() {
    let lore = MockLoreCli::with_events(vec![
        event("mr_review", "gitlab.com", 123, 847),
    ]);
    let beads = MockBeadsCli::new();
    let mapping = mapping_with("mr_review:gitlab.com:123:847", "br-existing");
    let orchestrator = SyncOrchestrator::with_mapping(lore, beads, mapping);

    orchestrator.sync().await.unwrap();

    assert_eq!(beads.create_calls().len(), 0); // No new beads
}

#[tokio::test]
async fn sync_triggers_reconciliation_when_due() {
    let lore = MockLoreCli::with_events(vec![]);
    let beads = MockBeadsCli::new();
    let mapping = mapping_with_stale_reconciliation(); // 7 hours old
    let orchestrator = SyncOrchestrator::with_mapping(lore, beads, mapping);

    orchestrator.sync().await.unwrap();

    assert!(lore.was_called("me_issues"));
    assert!(lore.was_called("me_mrs"));
}

#[tokio::test]
async fn sync_emits_status_events() {
    let (tx, mut rx) = tokio::sync::mpsc::channel(10);
    let orchestrator = SyncOrchestrator::with_event_sender(tx);

    orchestrator.sync().await.unwrap();

    let events: Vec<_> = collect_events(&mut rx);
    assert!(events.contains(&SyncEvent::Started));
    assert!(events.contains(&SyncEvent::Completed { items_processed: 0 }));
}

#[tokio::test]
async fn sync_handles_lore_failure_gracefully() {
    let lore = MockLoreCli::that_fails();
    let orchestrator = SyncOrchestrator::new(lore, beads);

    let result = orchestrator.sync().await;

    assert!(result.is_err());
    // Cursor should NOT have advanced
    assert_eq!(orchestrator.cursor(), original_cursor);
}
```

**Implementation:**

```rust
// src-tauri/src/bridge/orchestrator.rs
pub struct SyncOrchestrator<L: LoreCli, B: BeadsCli> {
    lore: L,
    beads: B,
    bridge: BridgeState<L, B>,
    event_tx: Option<mpsc::Sender<SyncEvent>>,
}

impl<L: LoreCli, B: BeadsCli> SyncOrchestrator<L, B> {
    pub async fn sync(&mut self) -> Result<SyncResult> {
        self.emit(SyncEvent::Started);

        // Fetch from lore
        let response = match self.lore.me() {
            Ok(r) => r,
            Err(e) => {
                self.emit(SyncEvent::Error(e.to_string()));
                return Err(e);
            }
        };

        // Check if reconciliation is due
        if self.bridge.should_run_reconciliation() {
            self.run_full_reconciliation().await?;
        }

        // Process incremental events
        let mut processed = 0;
        for event in &response.data.since_last_check {
            match self.bridge.process_event(event.clone()) {
                Ok(created) => {
                    if created {
                        processed += 1;
                    }
                }
                Err(e) => {
                    // Log but continue
                    tracing::warn!("Failed to process event: {}", e);
                }
            }
        }

        // Advance cursor only on success
        self.bridge.advance_cursor();
        self.bridge.save_mapping()?;

        self.emit(SyncEvent::Completed { items_processed: processed });

        Ok(SyncResult { items_processed: processed })
    }

    async fn run_full_reconciliation(&mut self) -> Result<()> {
        self.emit(SyncEvent::ReconciliationStarted);

        let issues = self.lore.me_issues()?;
        let mrs = self.lore.me_mrs()?;

        let all_items: Vec<_> = issues.into_iter()
            .chain(mrs.into_iter())
            .collect();

        self.bridge.reconcile(&all_items)?;

        self.emit(SyncEvent::ReconciliationCompleted);
        Ok(())
    }

    fn emit(&self, event: SyncEvent) {
        if let Some(tx) = &self.event_tx {
            tx.try_send(event).ok();
        }
    }
}

#[derive(Debug, Clone)]
pub enum SyncEvent {
    Started,
    Completed { items_processed: usize },
    ReconciliationStarted,
    ReconciliationCompleted,
    Error(String),
}
```

**Integration with Tauri:**

```rust
// In setup, start background sync task
fn setup_sync_task(app: &tauri::App) {
    let orchestrator = app.state::<Arc<Mutex<SyncOrchestrator>>>();
    let app_handle = app.handle();

    // File watcher triggers sync
    let watcher = LoreDbWatcher::new(lore_db_path, move || {
        let orch = orchestrator.clone();
        let handle = app_handle.clone();

        tauri::async_runtime::spawn(async move {
            let mut orch = orch.lock().await;
            match orch.sync().await {
                Ok(result) => {
                    handle.emit_all("sync-complete", result).ok();
                }
                Err(e) => {
                    handle.emit_all("sync-error", e.to_string()).ok();
                }
            }
        });
    })?;
}
```

**Acceptance criteria:**
- Sync processes all event types
- Idempotency prevents duplicates
- Reconciliation runs when due
- Events emitted for UI updates
- Errors don't corrupt state


Implement Tauri command handlers for frontend IPC

# Tauri Command Handlers

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

**Purpose:** Rust functions exposed to frontend via Tauri's invoke system. These are the IPC bridge between React and Rust.

**Commands to implement:**

| Command | Input | Output | Description |
|---------|-------|--------|-------------|
| `get_work_items` | — | `Vec<WorkItem>` | Fetch all items (lore + manual) |
| `get_focus` | — | `Option<String>` | Get current focus ID |
| `set_focus` | `id: String` | `()` | Set focus item |
| `clear_focus` | — | `()` | Clear focus |
| `update_item` | `id, updates` | `()` | Update item state |
| `complete_item` | `id: String` | `()` | Mark item complete |
| `quick_capture` | `text: String` | `String` | Create manual bead |
| `log_decision` | `entry: DecisionEntry` | `()` | Log to decision log |
| `read_state` | — | `AppState` | Read persisted state |
| `write_state` | `state: AppState` | `()` | Write persisted state |
| `trigger_sync` | — | `()` | Manual sync trigger |
| `get_sync_status` | — | `SyncStatus` | Get current sync state |

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

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

#[tokio::test]
async fn get_work_items_returns_merged_items() {
    let app = setup_test_app();
    let lore = MockLoreCli::with_items(vec![lore_item("1")]);
    let state = MockState::with_manual_items(vec![manual_item("2")]);

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

    assert_eq!(result.len(), 2);
    assert!(result.iter().any(|i| i.id == "1" && i.source == "gitlab"));
    assert!(result.iter().any(|i| i.id == "2" && i.source == "manual"));
}

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

    app.invoke::<()>("set_focus", SetFocusArgs { id: "br-123".into() }).await;

    let focus = app.invoke::<Option<String>>("get_focus", ()).await;
    assert_eq!(focus, Some("br-123".into()));
}

#[tokio::test]
async fn quick_capture_creates_bead() {
    let app = setup_test_app();
    let beads = app.state::<MockBeadsCli>();

    let id: String = app.invoke("quick_capture", QuickCaptureArgs {
        text: "Test thought".into()
    }).await;

    assert!(id.starts_with("bd-") || id.starts_with("br-"));
    assert!(beads.was_called_with("create", "Test thought"));
}

#[tokio::test]
async fn log_decision_appends_to_file() {
    let app = setup_test_app();
    let log_path = app.state::<Config>().decision_log_path();

    app.invoke::<()>("log_decision", LogDecisionArgs {
        entry: DecisionEntry {
            action: Action::SetFocus,
            bead_id: "br-123".into(),
            reason: Some("Test".into()),
            ..Default::default()
        }
    }).await;

    let content = std::fs::read_to_string(log_path).unwrap();
    assert!(content.contains("set_focus"));
    assert!(content.contains("br-123"));
}
```

**Implementation:**

```rust
// src-tauri/src/commands/mod.rs
pub mod work_items;
pub mod actions;
pub mod capture;
pub mod decisions;
pub mod state;
pub mod sync;

// src-tauri/src/commands/work_items.rs
#[tauri::command]
pub async fn get_work_items(
    lore: State<'_, Arc<dyn LoreCli>>,
    state: State<'_, AppState>,
) -> Result<Vec<WorkItem>, String> {
    let lore_items = lore.me()
        .map_err(|e| e.to_string())?
        .to_work_items();

    let manual_items = state.get_manual_items();

    Ok([lore_items, manual_items].concat())
}

#[tauri::command]
pub async fn get_focus(
    state: State<'_, AppState>,
) -> Option<String> {
    state.get_focus_id()
}

#[tauri::command]
pub async fn set_focus(
    id: String,
    state: State<'_, AppState>,
) -> Result<(), String> {
    state.set_focus_id(Some(id));
    state.persist().map_err(|e| e.to_string())
}

// src-tauri/src/commands/capture.rs
#[tauri::command]
pub async fn quick_capture(
    text: String,
    beads: State<'_, Arc<dyn BeadsCli>>,
    state: State<'_, AppState>,
    decision_log: State<'_, DecisionLogger>,
) -> Result<String, String> {
    let bead_id = beads.create(&text, "manual")
        .map_err(|e| e.to_string())?;

    // Add to manual items
    state.add_manual_item(WorkItem {
        id: bead_id.clone(),
        title: text,
        source: "manual".into(),
        created_at: Utc::now(),
        ..Default::default()
    });

    // Log decision
    decision_log.log(DecisionEntry {
        action: Action::CreateManual,
        bead_id: bead_id.clone(),
        ..Default::default()
    }).ok();

    Ok(bead_id)
}
```

**Registration in main.rs:**

```rust
fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            commands::work_items::get_work_items,
            commands::work_items::get_focus,
            commands::work_items::set_focus,
            commands::work_items::clear_focus,
            commands::actions::update_item,
            commands::actions::complete_item,
            commands::capture::quick_capture,
            commands::decisions::log_decision,
            commands::state::read_state,
            commands::state::write_state,
            commands::sync::trigger_sync,
            commands::sync::get_sync_status,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
```

**Acceptance criteria:**
- All commands have tests
- Error handling returns user-friendly messages
- State mutations persist correctly
- Async operations don't block UI


Phase 6: Quick Capture

# Quick Capture — Trust the System

**Context:** Global hotkey (⌘⇧C) summons a minimal overlay for capturing thoughts instantly. Creates a bead immediately, triage later.

**UX principle:** "One hotkey, type it, gone. System triages later."

**Duration estimate:** 1 day

**Visual design:**
```
              ┌────────────────────────────────────────┐
              │                                        │
              │  ┌────────────────────────────────┐    │
              │  │ Quick thought...               │    │
              │  │                                │    │
              │  │ Need to check if webhook       │    │
              │  │ retry uses exponential backoff │    │
              │  └────────────────────────────────┘    │
              │                                        │
              │  ⏎ Save & close     ESC Cancel         │
              │                                        │
              └────────────────────────────────────────┘
```

**Scope includes:**
1. Global hotkey registration (⌘⇧C)
2. Floating overlay that appears above other apps
3. Text input with auto-focus
4. Instant bead creation via br CLI
5. Smooth dismiss animation

**Behavior:**
- ⌘⇧C from anywhere summons overlay
- Start typing immediately (auto-focused)
- Enter saves and dismisses
- ESC cancels
- Returns to previous context

**Dependencies:**
- Requires Foundation (hotkey registration)
- Requires Bridge (bead creation)

**Acceptance criteria:**
- Hotkey works from any app
- Overlay appears quickly (<200ms)
- Text captured and bead created
- Smooth dismiss animation
- Previous focus restored


Phase 7: Polish + E2E Tests

# Polish + E2E Testing — Production Ready

**Context:** Final phase to add visual polish (animations, staleness colors) and comprehensive E2E testing to ensure the app works correctly end-to-end.

**Duration estimate:** Ongoing (1-2 days initial, then continuous)

**Scope includes:**
1. Framer Motion animations throughout
2. Staleness visualization (color decay)
3. Menu bar badge counts
4. Settings UI
5. E2E tests for critical flows

**E2E test coverage:**
| Test | Flow |
|------|------|
| focus-flow.spec.ts | Launch → See focus → Start → Verify browser opens |
| batch-mode.spec.ts | Queue with reviews → Batch → Complete all → Celebration |
| quick-capture.spec.ts | Hotkey → Type → Enter → Verify bead created |
| sync-status.spec.ts | Mock failure → Error shown → Recover → Success |

**Coverage requirements:**
| Layer | Minimum | Focus Areas |
|-------|---------|-------------|
| Rust bridge | 90% | State transitions, crash recovery |
| Frontend hooks | 85% | Data fetching, state management |
| Frontend components | 70% | User interactions |
| E2E | N/A | Critical user flows |

**Dependencies:**
- Requires all previous phases complete
- Can start some work (animations) earlier

**Acceptance criteria:**
- Smooth animations throughout
- Staleness colors work correctly
- All E2E tests pass
- Coverage targets met


Set up Rust trait-based mocking infrastructure

# Rust Trait-Based Mocking for CLI Integration

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

**Why trait-based mocking:**
MC shells out to external CLIs (lore, br, bv). Production code runs real commands.
Tests need to inject mock responses without touching actual CLIs.
Rust traits allow compile-time polymorphism with zero runtime cost.

**Traits to define:**

```rust
// src/data/traits.rs
pub trait LoreCli: Send + Sync {
    fn me(&self) -> Result<LoreMeResponse>;
    fn me_issues(&self) -> Result<Vec<LoreIssue>>;
    fn me_mrs(&self) -> Result<Vec<LoreMr>>;
}

pub trait BeadsCli: Send + Sync {
    fn create(&self, title: &str, bead_type: &str) -> Result<String>;
    fn close(&self, id: &str, reason: &str) -> Result<()>;
    fn exists(&self, id: &str) -> Result<bool>;
}
```

**Production implementations:**
- RealLoreCli: Executes `lore --robot` commands, parses JSON output
- RealBeadsCli: Executes `br` commands, parses JSON output

**Mock implementations:**
- MockLoreCli: Returns fixture data from HashMap
- MockBeadsCli: Records calls, returns preconfigured responses

**Test helper module:**

```rust
// tests/common/mod.rs
pub fn mock_lore(fixture: &str) -> MockLoreCli {
    let json = include_str!(concat!("fixtures/lore/", fixture));
    MockLoreCli { response: serde_json::from_str(json).unwrap() }
}
```

**Acceptance criteria:**
- Bridge code accepts `impl LoreCli` instead of hardcoded calls
- Tests can inject MockLoreCli with fixture data
- All CLI interactions are testable without real CLIs installed
- No conditional compilation needed (pure trait injection)


Implement single-instance lock with flock

# Single-Instance Lock (RED → GREEN)

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

**Problem being solved:**
Multiple MC instances would race to create beads, causing duplicates and corrupted state.

**Solution:** OS advisory lock via `flock(2)`.

**Lock file:** `~/.local/share/mc/mc.lock`

**Why flock over "file exists":**
- Automatically released on crash (no stale lockfiles)
- No cleanup needed on abnormal exit
- Race-free (OS handles atomicity)

**TDD: Single-instance tests (RED first):**

```rust
#[test]
fn first_instance_acquires_lock() {
    let temp = tempfile::tempdir().unwrap();
    let lock_path = temp.path().join("mc.lock");

    let lock = InstanceLock::acquire(&lock_path);

    assert!(lock.is_ok());
}

#[test]
fn second_instance_blocked() {
    let temp = tempfile::tempdir().unwrap();
    let lock_path = temp.path().join("mc.lock");

    let _lock1 = InstanceLock::acquire(&lock_path).unwrap();
    let lock2 = InstanceLock::acquire(&lock_path);

    assert!(lock2.is_err());
    assert!(matches!(lock2.unwrap_err(), LockError::AlreadyRunning));
}

#[test]
fn lock_released_on_drop() {
    let temp = tempfile::tempdir().unwrap();
    let lock_path = temp.path().join("mc.lock");

    {
        let _lock = InstanceLock::acquire(&lock_path).unwrap();
    } // Lock dropped here

    // Second acquisition should succeed
    let lock2 = InstanceLock::acquire(&lock_path);
    assert!(lock2.is_ok());
}

#[test]
fn lock_released_on_panic() {
    let temp = tempfile::tempdir().unwrap();
    let lock_path = temp.path().join("mc.lock");

    std::panic::catch_unwind(|| {
        let _lock = InstanceLock::acquire(&lock_path).unwrap();
        panic!("Simulated crash");
    }).ok();

    // Lock should be released after panic
    let lock2 = InstanceLock::acquire(&lock_path);
    assert!(lock2.is_ok());
}
```

**Implementation (GREEN):**

```rust
use std::fs::{File, OpenOptions};
use std::os::unix::io::AsRawFd;

pub struct InstanceLock {
    _file: File,  // Held to keep lock alive
}

impl InstanceLock {
    pub fn acquire(path: &Path) -> Result<Self, LockError> {
        // Create parent directory if needed
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }

        // Open lock file (create if missing)
        let file = OpenOptions::new()
            .write(true)
            .create(true)
            .open(path)?;

        // Attempt non-blocking exclusive lock
        let fd = file.as_raw_fd();
        let result = unsafe {
            libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB)
        };

        if result != 0 {
            let errno = std::io::Error::last_os_error();
            if errno.raw_os_error() == Some(libc::EWOULDBLOCK) {
                return Err(LockError::AlreadyRunning);
            }
            return Err(LockError::Io(errno));
        }

        Ok(Self { _file: file })
    }
}

#[derive(Debug)]
pub enum LockError {
    AlreadyRunning,
    Io(std::io::Error),
}
```

**Startup behavior:**
1. Open `mc.lock` (create if missing)
2. Attempt `flock(fd, LOCK_EX | LOCK_NB)` (non-blocking exclusive lock)
3. If `EWOULDBLOCK` → another instance holds lock → show error dialog, exit
4. If lock acquired → proceed, OS auto-releases on process exit/crash

**UI for blocked startup:**
- Show dialog: "Mission Control is already running"
- Option: "Bring to front" (if we can signal other instance)
- Option: "Force close other" (dangerous, needs confirmation)

**Acceptance criteria:**
- Only one MC instance can run at a time
- Lock released automatically on exit/crash
- No stale lockfiles after abnormal termination
- Clear error message when blocked


Implement QueueList component with sections

# QueueList Component (RED → GREEN)

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

**Purpose:** Display all pending work items organized into collapsible sections by type.

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

```typescript
// tests/components/QueueList.test.tsx
describe('QueueList', () => {
  const mockItems: WorkItem[] = [
    { id: '1', type: 'mr_review', title: 'Review MR !847', createdAt: daysAgo(2) },
    { id: '2', type: 'mr_review', title: 'Review MR !902', createdAt: daysAgo(1) },
    { id: '3', type: 'issue', title: 'Issue #312', createdAt: daysAgo(5) },
    { id: '4', type: 'manual', title: 'Write tests', createdAt: daysAgo(0) },
  ];

  it('renders sections by item type', () => {
    render(<QueueList items={mockItems} />);

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

  it('shows staleness colors correctly', () => {
    render(<QueueList items={mockItems} />);

    const issue = screen.getByText('Issue #312');
    expect(issue.closest('[data-testid="queue-item"]')).toHaveClass('staleness-amber');

    const fresh = screen.getByText('Write tests');
    expect(fresh.closest('[data-testid="queue-item"]')).toHaveClass('staleness-green');
  });

  it('shows batch button for sections with multiple items', () => {
    render(<QueueList items={mockItems} />);

    expect(screen.getByText(/Batch All.*25min/)).toBeInTheDocument(); // Reviews section
    expect(screen.queryByText(/Batch All.*ISSUES/)).not.toBeInTheDocument(); // Only 1 issue
  });

  it('calls onItemClick when item clicked', async () => {
    const onItemClick = vi.fn();
    render(<QueueList items={mockItems} onItemClick={onItemClick} />);

    await userEvent.click(screen.getByText('Review MR !847'));

    expect(onItemClick).toHaveBeenCalledWith('1');
  });

  it('sections are collapsible', async () => {
    render(<QueueList items={mockItems} />);

    const reviewsHeader = screen.getByText('REVIEWS (2)');
    await userEvent.click(reviewsHeader);

    expect(screen.queryByText('Review MR !847')).not.toBeVisible();
  });
});
```

**Implementation (GREEN):**

```tsx
// src/components/QueueList.tsx
interface QueueListProps {
  items: WorkItem[];
  onItemClick: (id: string) => void;
  onBatchStart: (type: ItemType) => void;
}

export function QueueList({ items, onItemClick, onBatchStart }: QueueListProps) {
  const sections = useMemo(() => groupByType(items), [items]);

  return (
    <div className="space-y-4">
      {sections.map(section => (
        <Collapsible key={section.type} defaultOpen>
          <CollapsibleTrigger className="flex justify-between w-full">
            <span className="font-semibold">
              {section.label} ({section.items.length})
            </span>
            {section.items.length > 1 && (
              <Button
                variant="ghost"
                size="sm"
                onClick={(e) => {
                  e.stopPropagation();
                  onBatchStart(section.type);
                }}
              >
                Batch All · {estimateDuration(section.items)}
              </Button>
            )}
          </CollapsibleTrigger>

          <CollapsibleContent>
            <div className="space-y-1 mt-2">
              {section.items.map(item => (
                <QueueItem
                  key={item.id}
                  item={item}
                  onClick={() => onItemClick(item.id)}
                />
              ))}
            </div>
          </CollapsibleContent>
        </Collapsible>
      ))}
    </div>
  );
}

function QueueItem({ item, onClick }: { item: WorkItem; onClick: () => void }) {
  const staleness = getStaleness(item.createdAt);

  return (
    <div
      data-testid="queue-item"
      className={cn(
        'flex items-center gap-3 p-2 rounded cursor-pointer hover:bg-muted',
        `staleness-${staleness}`
      )}
      onClick={onClick}
    >
      <StalenessIndicator staleness={staleness} />
      <span className="flex-1 truncate">{item.title}</span>
      <span className="text-muted-foreground text-sm">{formatAge(item.createdAt)}</span>
      {item.metadata?.author && (
        <span className="text-muted-foreground text-sm">{item.metadata.author}</span>
      )}
    </div>
  );
}
```

**Acceptance criteria:**
- Items grouped by type in collapsible sections
- Staleness colors applied correctly
- Batch button shows with time estimate
- Click handler fires
- Sections collapse/expand


Implement sync status indicator

# Sync Status Indicator

**Parent epic:** Phase 1: Foundation

**Purpose:** Show users when data was last synced and surface any errors clearly.

**UX requirement from AC-009:**
> **Given** lore cron syncs periodically
> **When** viewing any MC screen
> **Then** sync status is visible (last sync time, success/failure)
> **And** errors are surfaced with actionable info

**States:**
| State | Visual | Action |
|-------|--------|--------|
| Synced | Green dot, "Synced 2m ago" | None |
| Syncing | Spinner, "Syncing..." | None |
| Stale | Amber dot, "Last sync 30m ago" | "Refresh" button |
| Error | Red dot, error message | "Retry" button |
| Offline | Gray dot, "lore unavailable" | "Check lore" link |

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

```typescript
// tests/components/SyncStatus.test.tsx
describe('SyncStatus', () => {
  it('shows synced state with time', () => {
    render(<SyncStatus status="synced" lastSync={new Date(Date.now() - 2 * 60 * 1000)} />);

    expect(screen.getByTestId('sync-indicator')).toHaveClass('bg-green-500');
    expect(screen.getByText(/Synced 2m ago/)).toBeInTheDocument();
  });

  it('shows syncing spinner', () => {
    render(<SyncStatus status="syncing" />);

    expect(screen.getByTestId('sync-spinner')).toBeInTheDocument();
    expect(screen.getByText('Syncing...')).toBeInTheDocument();
  });

  it('shows error with retry button', () => {
    const onRetry = vi.fn();
    render(<SyncStatus status="error" error="lore command failed" onRetry={onRetry} />);

    expect(screen.getByTestId('sync-indicator')).toHaveClass('bg-red-500');
    expect(screen.getByText(/lore command failed/)).toBeInTheDocument();

    await userEvent.click(screen.getByRole('button', { name: /retry/i }));
    expect(onRetry).toHaveBeenCalled();
  });

  it('shows stale warning after 15 minutes', () => {
    render(<SyncStatus status="synced" lastSync={new Date(Date.now() - 20 * 60 * 1000)} />);

    expect(screen.getByTestId('sync-indicator')).toHaveClass('bg-amber-500');
    expect(screen.getByText(/Last sync 20m ago/)).toBeInTheDocument();
  });
});
```

**Implementation:**

```tsx
// src/components/SyncStatus.tsx
type SyncState = 'synced' | 'syncing' | 'stale' | 'error' | 'offline';

interface SyncStatusProps {
  status: SyncState;
  lastSync?: Date;
  error?: string;
  onRetry?: () => void;
}

export function SyncStatus({ status, lastSync, error, onRetry }: SyncStatusProps) {
  const isStale = lastSync && (Date.now() - lastSync.getTime()) > 15 * 60 * 1000;
  const effectiveStatus = status === 'synced' && isStale ? 'stale' : status;

  const indicators = {
    synced: { color: 'bg-green-500', text: `Synced ${formatRelative(lastSync)}` },
    syncing: { color: 'bg-blue-500 animate-pulse', text: 'Syncing...' },
    stale: { color: 'bg-amber-500', text: `Last sync ${formatRelative(lastSync)}` },
    error: { color: 'bg-red-500', text: error || 'Sync failed' },
    offline: { color: 'bg-gray-400', text: 'lore unavailable' },
  };

  const { color, text } = indicators[effectiveStatus];

  return (
    <div className="flex items-center gap-2 text-sm">
      {effectiveStatus === 'syncing' ? (
        <Loader2 data-testid="sync-spinner" className="w-3 h-3 animate-spin" />
      ) : (
        <div data-testid="sync-indicator" className={cn('w-2 h-2 rounded-full', color)} />
      )}

      <span className="text-muted-foreground">{text}</span>

      {(effectiveStatus === 'error' || effectiveStatus === 'stale') && onRetry && (
        <Button variant="ghost" size="sm" onClick={onRetry}>
          Retry
        </Button>
      )}
    </div>
  );
}
```

**Integration:**
- Show in top-right of every view
- Update on file watcher events
- Trigger manual refresh on Retry

**Acceptance criteria:**
- All sync states display correctly
- Time updates every minute
- Retry triggers lore refresh
- Errors show actionable message


Implement ReasonPrompt component with quick tags

# ReasonPrompt Component (RED → GREEN)

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

**Purpose:** Every significant action prompts for an optional reason to learn patterns.

**Visual design:**
```
┌─────────────────────────────────────────────────────────────┐
│  Setting focus to: Review MR !847                            │
│                                                              │
│  Why? (optional, helps learn your patterns)                  │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ Sarah pinged me, she's blocked on release              │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  Quick tags: [Blocking] [Urgent] [Context switch] [Energy]   │
│                                                              │
│  [Confirm]                                    [Skip reason]  │
└─────────────────────────────────────────────────────────────┘
```

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

```typescript
// tests/components/ReasonPrompt.test.tsx
describe('ReasonPrompt', () => {
  it('renders with action context', () => {
    render(<ReasonPrompt action="set_focus" itemTitle="Review MR !847" />);

    expect(screen.getByText('Setting focus to: Review MR !847')).toBeInTheDocument();
  });

  it('captures text input', async () => {
    const onSubmit = vi.fn();
    render(<ReasonPrompt onSubmit={onSubmit} />);

    await userEvent.type(
      screen.getByRole('textbox'),
      'Sarah pinged me, she is blocked'
    );
    await userEvent.click(screen.getByRole('button', { name: /confirm/i }));

    expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
      reason: 'Sarah pinged me, she is blocked'
    }));
  });

  it('allows selecting quick tags', async () => {
    const onSubmit = vi.fn();
    render(<ReasonPrompt onSubmit={onSubmit} />);

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

    expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
      tags: ['blocking', 'urgent']
    }));
  });

  it('allows skipping reason', async () => {
    const onSubmit = vi.fn();
    render(<ReasonPrompt onSubmit={onSubmit} />);

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

    expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
      reason: null,
      tags: []
    }));
  });

  it('submits on Enter in text field', async () => {
    const onSubmit = vi.fn();
    render(<ReasonPrompt onSubmit={onSubmit} />);

    await userEvent.type(screen.getByRole('textbox'), 'Quick note{Enter}');

    expect(onSubmit).toHaveBeenCalled();
  });

  it('cancels on Escape', async () => {
    const onCancel = vi.fn();
    render(<ReasonPrompt onCancel={onCancel} />);

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

    expect(onCancel).toHaveBeenCalled();
  });
});
```

**Implementation (GREEN):**

```tsx
// src/components/ReasonPrompt.tsx
const QUICK_TAGS = [
  { id: 'blocking', label: 'Blocking', description: 'Someone is waiting on this' },
  { id: 'urgent', label: 'Urgent', description: 'Time-sensitive' },
  { id: 'context_switch', label: 'Context switch', description: 'Good mental break point' },
  { id: 'energy', label: 'Energy', description: 'Matches current energy level' },
  { id: 'flow', label: 'Flow', description: 'In the zone for this type of work' },
];

interface ReasonPromptProps {
  action: string;
  itemTitle: string;
  onSubmit: (data: { reason: string | null; tags: string[] }) => void;
  onCancel: () => void;
}

export function ReasonPrompt({ action, itemTitle, onSubmit, onCancel }: ReasonPromptProps) {
  const [reason, setReason] = useState('');
  const [selectedTags, setSelectedTags] = useState<string[]>([]);
  const inputRef = useRef<HTMLTextAreaElement>(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  const handleSubmit = () => {
    onSubmit({ reason: reason.trim() || null, tags: selectedTags });
  };

  const handleSkip = () => {
    onSubmit({ reason: null, tags: [] });
  };

  const toggleTag = (tagId: string) => {
    setSelectedTags(prev =>
      prev.includes(tagId)
        ? prev.filter(t => t !== tagId)
        : [...prev, tagId]
    );
  };

  return (
    <Dialog open onOpenChange={(open) => !open && onCancel()}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>
            {formatActionTitle(action)}: {itemTitle}
          </DialogTitle>
        </DialogHeader>

        <div className="space-y-4">
          <div>
            <Label>Why? (optional)</Label>
            <Textarea
              ref={inputRef}
              value={reason}
              onChange={(e) => setReason(e.target.value)}
              onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSubmit()}
              placeholder="Helps learn your patterns..."
            />
          </div>

          <div className="flex flex-wrap gap-2">
            {QUICK_TAGS.map(tag => (
              <Badge
                key={tag.id}
                variant={selectedTags.includes(tag.id) ? 'default' : 'outline'}
                className="cursor-pointer"
                onClick={() => toggleTag(tag.id)}
              >
                {tag.label}
              </Badge>
            ))}
          </div>
        </div>

        <DialogFooter>
          <Button variant="ghost" onClick={handleSkip}>Skip reason</Button>
          <Button onClick={handleSubmit}>Confirm</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
```

**Acceptance criteria:**
- Text input works with reason capture
- Quick tags toggle on/off
- Both reason and tags captured in submission
- Skip option available
- Keyboard navigation works (Enter, Escape)


Implement full reconciliation pass with cursor recovery

# Full Reconciliation with Cursor Recovery (RED → GREEN)

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

**Problem being solved:**
`since_last_check` is incremental. If MC is offline, clock skews, or lore's cursor gets stale, events can be missed.

**Solution:** Periodic full reconciliation pass.

| Trigger | Action |
|---------|--------|
| App startup | Full reconciliation |
| Every 6 hours | Full reconciliation |
| `since_last_check` empty but items exist | Full reconciliation |

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

```rust
#[test]
fn startup_triggers_full_reconciliation() {
    let lore = MockLoreCli::with_fixture("me_with_reviews.json");
    let beads = MockBeadsCli::new();
    let mapping = Mapping::empty();

    let bridge = BridgeState::init(lore, beads, mapping)?;

    // Should have created beads for all reviews
    assert!(!bridge.mapping.is_empty());
}

#[test]
fn periodic_reconciliation_heals_missed_events() {
    let mut bridge = setup_with_stale_mapping();

    // Lore has items not in our mapping (missed events)
    let lore_items = vec![
        mr_item("mr:gitlab:123:847"), // Missing from mapping
        mr_item("mr:gitlab:123:902"), // Already in mapping
    ];

    bridge.reconcile(&lore_items)?;

    // Should have created bead for missed item
    assert!(bridge.mapping.contains_key("mr:gitlab:123:847"));
}

#[test]
fn stale_cursor_triggers_reconciliation() {
    let lore = MockLoreCli::with_fixture("me_stale_cursor.json");
    // Fixture has: empty since_last_check BUT has open items

    let bridge = BridgeState::init(lore, beads, mapping)?;

    // Should have detected stale cursor and run full reconciliation
    assert!(bridge.did_run_full_reconciliation());
}

#[test]
fn cursor_advances_only_on_success() {
    let mut bridge = setup_bridge();
    let old_cursor = bridge.cursor().clone();

    // Process events
    bridge.process_incremental()?;

    let new_cursor = bridge.cursor();
    assert!(new_cursor.last_check_timestamp > old_cursor.last_check_timestamp);
}

#[test]
fn cursor_unchanged_on_failure() {
    let lore = MockLoreCli::that_fails();
    let mut bridge = setup_bridge_with(lore);
    let old_cursor = bridge.cursor().clone();

    let result = bridge.process_incremental();

    assert!(result.is_err());
    assert_eq!(bridge.cursor(), &old_cursor, "Cursor should not advance on failure");
}
```

**Implementation (GREEN):**

```rust
impl<L: LoreCli, B: BeadsCli> BridgeState<L, B> {
    pub fn process_incremental(&mut self) -> Result<()> {
        let response = self.lore.me()?;

        // Check for stale cursor (empty since_last_check but open items exist)
        if response.data.since_last_check.is_empty()
           && (!response.data.issues.is_empty() || !response.data.mrs.reviewing.is_empty()) {
            return self.run_full_reconciliation();
        }

        // Process incremental events
        for event in &response.data.since_last_check {
            self.process_event(event.clone())?;
        }

        // Advance cursor only after all events processed
        self.cursor.last_check_timestamp = Utc::now();

        Ok(())
    }

    pub fn run_full_reconciliation(&mut self) -> Result<()> {
        let issues = self.lore.me_issues()?;
        let mrs = self.lore.me_mrs()?;

        let all_items: Vec<_> = issues.into_iter()
            .chain(mrs.into_iter())
            .collect();

        self.reconcile(&all_items)?;
        self.cursor.last_reconciliation = Utc::now();

        Ok(())
    }

    pub fn should_run_reconciliation(&self) -> bool {
        // Every 6 hours
        let hours_since = (Utc::now() - self.cursor.last_reconciliation).num_hours();
        hours_since >= 6
    }
}
```

**Cursor semantics:**
| Operation | Cursor Update |
|-----------|---------------|
| Successful incremental sync | Advance `last_check_timestamp` |
| Successful full reconciliation | Advance `last_reconciliation` |
| Partial/failed sync | **Do not advance** (retry will reprocess) |

**Acceptance criteria:**
- Startup runs full reconciliation
- 6-hour timer triggers reconciliation
- Stale cursor detected and handled
- Cursor only advances on success


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