Add test commands to package.json and Cargo.toml

# Test Command Configuration

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

**package.json scripts to add:**

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

**Cargo.toml test configuration:**

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

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

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

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

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

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

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

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

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


Implement filter/search with command palette

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

**Implementation (GREEN):**

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

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

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

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

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

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

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

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

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

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

Implemented CommandPalette with text search, type:/stale: filter commands, keyboard navigation. All 17 tests pass.


Implement bv integration for triage recommendations

Add get_recommendations command using tauri_specta::command for type-safe IPC

Implemented get_triage and get_next_pick Tauri commands with 5 passing tests


Implement complete action for finished work

Implement complete_item using tauri_specta::command with specta::Type on response

Implemented close_bead, log_decision, and update_item Tauri commands with tests. Changes were committed in qrzmwrsw (bd-4s6 bv triage commit) due to jj auto-merge behavior.


Phase 4: Queue + Inbox Views

# Queue + Inbox Views

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

**Duration estimate:** 2-3 days

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

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

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

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

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

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


Replace SettingsView placeholder with Settings component

# Task: Wire Settings component to SettingsView

**File:** `src/components/SettingsView.tsx`

**Problem:** SettingsView.tsx is a placeholder div. The full Settings.tsx component exists but isn't used.

**Implementation:**

Option A — If Settings.tsx is standalone:
```typescript
// src/components/SettingsView.tsx
import { Settings } from './Settings';

export function SettingsView(): React.ReactElement {
  return <Settings />;
}
```

Option B — If Settings needs container styling:
```typescript
export function SettingsView(): React.ReactElement {
  return (
    <div className="settings-view p-6">
      <h1 className="text-2xl font-bold mb-6">Settings</h1>
      <Settings />
    </div>
  );
}
```

**Note:** Settings persistence will be handled in Slice 7. This task just renders the UI.

**Verification:**
- Press Cmd+, to open settings
- Settings UI appears with sections (Sync, Notifications, Shortcuts)
- Changes don't persist yet (that's Slice 7)

**Estimated effort:** 10 minutes

Replaced SettingsView placeholder with actual Settings component using local state


Implement InboxView container

# InboxView Container

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

**Implementation:**

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

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

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

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

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

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

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

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

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

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

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

Implemented InboxView container with store integration, keyboard navigation, triage actions, and inbox zero state. All tests passing.


Write tests for Slice 0+1: Action + ReasonPrompt integration

# Task: Test Action Wiring + ReasonPrompt Integration

**Files:**
- `tests/components/FocusView.test.tsx` (new or extend)
- `tests/hooks/useActions.test.ts` (extend)

**Why:** These tests validate that the entire action flow works: user clicks → prompt shows → confirm/cancel → backend called or not.

**Unit Tests (Vitest):**

1. **useActions with tags:**
```typescript
test('skip() includes tags in log_decision', async () => {
  const mockInvoke = vi.fn();
  // ... setup
  await actions.skip(item, 'Test reason', ['urgent', 'blocking']);
  expect(mockInvoke).toHaveBeenCalledWith('log_decision', {
    entry: expect.objectContaining({
      tags: ['urgent', 'blocking']
    })
  });
});

test('skip() with no tags defaults to empty array', async () => {
  // ...
  expect(mockInvoke).toHaveBeenCalledWith('log_decision', {
    entry: expect.objectContaining({
      tags: []
    })
  });
});
```

2. **FocusView with pending action:**
```typescript
test('shows ReasonPrompt when pendingAction is set', () => {
  render(<FocusView />);
  fireEvent.click(screen.getByText('Skip'));
  expect(screen.getByText(/Skipping:/)).toBeVisible();
});

test('cancels action on Escape', () => {
  render(<FocusView />);
  fireEvent.click(screen.getByText('Skip'));
  fireEvent.keyDown(document, { key: 'Escape' });
  expect(screen.queryByText(/Skipping:/)).not.toBeInTheDocument();
  // verify no backend call
});

test('executes action on Confirm', async () => {
  render(<FocusView />);
  fireEvent.click(screen.getByText('Skip'));
  fireEvent.click(screen.getByText('Confirm'));
  await waitFor(() => {
    expect(mockInvoke).toHaveBeenCalledWith('log_decision', expect.any(Object));
  });
});
```

**Integration Test (Playwright):**

From Testing Strategy in PLAN-FOLLOWUP.md:
```typescript
test('Prompt gating: action NOT executed until confirmed', async ({ page }) => {
  // Seed data
  await seedFocusStore(page);

  // Click Skip
  await page.getByText('Skip').click();

  // Verify action NOT executed yet (no backend call)
  // Verify prompt visible
  await expect(page.getByText(/Skipping:/)).toBeVisible();

  // Confirm
  await page.getByText('Confirm').click();

  // Verify action executed
  // (check decision log or state change)
});
```

**Depends on:** bd-1hi (ReasonPrompt wired)

**Estimated effort:** 45 minutes


Implement useTauriEvents React hook for event communication

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

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

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

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

FILE LOCATION:
src/hooks/useTauriEvents.ts

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

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


Expose inbox store for test seeding

# Task: Expose inbox store to window for E2E test seeding

**File:** `src/stores/inbox-store.ts`

**Why:** E2E tests need to seed the inbox store with test data. Following the pattern already used for focus store.

**Implementation:**

Add at the bottom of inbox-store.ts:
```typescript
// Expose store for E2E testing
if (typeof window !== 'undefined') {
  (window as Record<string, unknown>).__MC_INBOX_STORE__ = useInboxStore;
}
```

**Pattern reference:** Check how `__MC_FOCUS_STORE__` is exposed in focus-store.ts.

**Depends on:** (none — can be done immediately)

**Estimated effort:** 5 minutes


Implement handleDragEnd with pending reorder

# Task: Implement drag end handler with ReasonPrompt integration

**File:** `src/components/QueueView.tsx`

**Why:** When a drag completes, we need to calculate the new position, show ReasonPrompt for reason capture, then execute the reorder via the action pipeline.

**Implementation:**

1. Add pending reorder state that stores the item reference (not just indices):
```typescript
interface PendingReorder {
  item: FocusItem;  // Store item reference to avoid index mismatch
  fromIndex: number;
  toIndex: number;
}
const [pendingReorder, setPendingReorder] = useState<PendingReorder | null>(null);
```

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

  // Calculate indices within the queue (not including current focus)
  const oldIndex = queue.findIndex((i) => i.id === active.id);
  const newIndex = queue.findIndex((i) => i.id === over.id);

  if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
    // Store item reference along with indices
    setPendingReorder({
      item: queue[oldIndex],
      fromIndex: oldIndex,
      toIndex: newIndex
    });
  }
}, [queue]);
```

3. Handle ReasonPrompt confirmation with error handling:
```typescript
const handleConfirmReorder = useCallback(
  async ({ reason, tags }: { reason: string | null; tags: string[] }) => {
    if (!pendingReorder) return;

    const { item, fromIndex, toIndex } = pendingReorder;

    try {
      // Log decision using stored item reference
      await logDecision({
        action: 'reorder',
        bead_id: item.id,
        reason,
        tags,
      });

      // Execute reorder in store
      reorderQueue(fromIndex, toIndex);
    } catch (error) {
      console.error('Failed to reorder:', error);
      // TODO: Show error toast
    }

    setPendingReorder(null);
  },
  [pendingReorder, reorderQueue]
);
```

4. Render ReasonPrompt for reorder:
```typescript
{pendingReorder && (
  <ReasonPrompt
    action="reorder"
    itemTitle={pendingReorder.item.title}
    onSubmit={handleConfirmReorder}
    onCancel={() => setPendingReorder(null)}
  />
)}
```

**Key fix:** Store the item reference in pendingReorder state instead of relying on queue[fromIndex] later. This avoids index mismatch issues if the queue changes between drag end and confirmation.

**Depends on:** bd-2rv (DndContext set up)

**Tests:**
- Integration test: drag item -> ReasonPrompt appears
- Integration test: confirm -> order persists
- Integration test: cancel -> order unchanged
- Unit test: pendingReorder stores correct item reference

**Estimated effort:** 30 minutes


Add pendingAction state to FocusView

# Task: Add pending action state management to FocusView

**File:** `src/components/FocusView.tsx`

**Why:** Currently FocusView action handlers (handleSkip, handleDefer1h, etc.) immediately execute `act()` which bypasses all backend calls. We need a two-phase approach: trigger → prompt → execute.

**Implementation:**

1. Define PendingAction interface:
```typescript
interface PendingAction {
  type: FocusAction;  // 'skip' | 'start' | 'defer_1h' | 'defer_tomorrow' | 'complete'
  item: ActionItem;
  duration?: DeferDuration;  // for defer actions
}
```

2. Add state:
```typescript
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
```

3. Replace action handlers to set pending state instead of executing:
```typescript
const handleSkip = useCallback(() => {
  if (current) {
    setPendingAction({ type: 'skip', item: current });
  }
}, [current]);

const handleDefer1h = useCallback(() => {
  if (current) {
    setPendingAction({ type: 'defer_1h', item: current, duration: '1h' });
  }
}, [current]);
```

**Pattern (from IMP-0):**
```
User clicks "Skip" → setPendingAction({ type: 'skip', item }) → ReasonPrompt shown
User confirms → useActions().skip(item, reason, tags) → update_item + log_decision + act()
User cancels → clearPendingAction → nothing happens
```

**Depends on:** bd-1dw (useActions with tags support)

**Tests:**
- Unit test: clicking Skip sets pendingAction with correct type
- Unit test: pendingAction is null initially
- Unit test: clearing pendingAction resets state

**Estimated effort:** 20 minutes


Implement BvCli trait for triage recommendation mocking

Define BvCli trait with specta::Type on response types for auto-generated TypeScript bindings

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


Implement app navigation and routing

# App Navigation and Routing

**Parent epic:** Phase 1: Foundation

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

**Implementation:**

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

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

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

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

      <div className="flex-1" />

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

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

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

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

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

Implemented keyboard shortcuts for navigation (Cmd+1/2/3/4/,) in AppShell. Added useKeyboardShortcuts hook with 11 tests, Navigation component with 12 tests, and SettingsView placeholder. All 372 tests pass.


Phase 1: Foundation

# Foundation — Tauri + React + Lore Integration

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

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

**Duration estimate:** 2-3 days

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

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

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

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

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


Call hydration on app startup

# Task: Hydrate stores during app initialization

**File:** `src/components/AppShell.tsx` or `src/App.tsx`

**Why:** Ensure all stores load their persisted state before rendering UI.

**Implementation:**

1. Create a hydration hook or effect:
```typescript
useEffect(() => {
  async function hydrateStores() {
    await Promise.all([
      useFocusStore.getState().hydrate?.(),
      useSettingsStore.getState().hydrate(),
    ]);
  }

  hydrateStores();
}, []);
```

2. Optionally show loading state during hydration:
```typescript
const [isHydrating, setIsHydrating] = useState(true);

useEffect(() => {
  async function hydrateStores() {
    await Promise.all([...]);
    setIsHydrating(false);
  }

  hydrateStores();
}, []);

if (isHydrating) {
  return <LoadingSpinner />;
}
```

**Depends on:** bd-2lc (settings store), bd-1sw (focus hydrate)

**Estimated effort:** 15 minutes

Completed: settings store hydration wired in AppShell.tsx, focus-store uses Zustand persist auto-hydration


Implement BatchMode component

# BatchMode Component (RED → GREEN)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

**Implementation (GREEN):**

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

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

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

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

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

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

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

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

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

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

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

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

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

Completed: BatchMode component with progress bar, action buttons, keyboard shortcuts, item transitions


Implement contract tests for CLI outputs

# Contract Tests for CLI Outputs

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

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

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

**Contract test structure:**

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

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

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

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

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

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

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

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

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

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

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

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

**Type definitions with strict parsing:**

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

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

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

**Fixture regeneration script:**

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

FIXTURE_DIR="src-tauri/tests/fixtures"

echo "Regenerating CLI fixtures..."

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

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

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

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

**CI job:**

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

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

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

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

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

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

Completed: Contract tests in lore.rs/beads.rs


Implement keyboard shortcuts for Focus View

# Keyboard Shortcuts for Focus View (RED → GREEN)

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

**Shortcuts:**
| Key | Action |
|-----|--------|
| Enter | Start (open in browser) |
| ⌘1 | Defer 1 hour |
| ⌘2 | Defer tomorrow |
| ⌘S | Skip |
| ⌘⇧C | Quick capture (global, but also here) |

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

```typescript
// tests/components/FocusCard.test.tsx
describe('FocusCard keyboard shortcuts', () => {
  it('calls onStart when Enter pressed', async () => {
    const onStart = vi.fn();
    render(<FocusCard item={mockItem} onStart={onStart} />);

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

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

  it('calls onDefer with 1h when ⌘1 pressed', async () => {
    const onDefer = vi.fn();
    render(<FocusCard item={mockItem} onDefer={onDefer} />);

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

    expect(onDefer).toHaveBeenCalledWith(mockItem.id, '1h');
  });

  it('calls onDefer with tomorrow when ⌘2 pressed', async () => {
    const onDefer = vi.fn();
    render(<FocusCard item={mockItem} onDefer={onDefer} />);

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

    expect(onDefer).toHaveBeenCalledWith(mockItem.id, 'tomorrow');
  });

  it('calls onSkip when ⌘S pressed', async () => {
    const onSkip = vi.fn();
    render(<FocusCard item={mockItem} onSkip={onSkip} />);

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

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

  it('does not trigger shortcuts when input focused', async () => {
    const onStart = vi.fn();
    render(
      <>
        <FocusCard item={mockItem} onStart={onStart} />
        <input data-testid="other-input" />
      </>
    );

    screen.getByTestId('other-input').focus();
    await userEvent.keyboard('{Enter}');

    expect(onStart).not.toHaveBeenCalled();
  });
});
```

**Implementation (GREEN):**

```typescript
// src/hooks/useKeyboard.ts
export function useKeyboardShortcuts(
  shortcuts: Record<string, () => void>,
  options: { enabled?: boolean } = {}
) {
  const { enabled = true } = options;

  useEffect(() => {
    if (!enabled) return;

    const handler = (e: KeyboardEvent) => {
      // Don't trigger in input/textarea
      if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
        return;
      }

      const key = buildKeyString(e);
      const action = shortcuts[key];
      if (action) {
        e.preventDefault();
        action();
      }
    };

    window.addEventListener('keydown', handler);
    return () => window.removeEventListener('keydown', handler);
  }, [shortcuts, enabled]);
}

function buildKeyString(e: KeyboardEvent): string {
  const parts = [];
  if (e.metaKey || e.ctrlKey) parts.push('mod');
  if (e.shiftKey) parts.push('shift');
  if (e.altKey) parts.push('alt');
  parts.push(e.key.toLowerCase());
  return parts.join('+');
}
```

**Usage in FocusCard:**

```tsx
export function FocusCard({ item, onStart, onDefer, onSkip }) {
  useKeyboardShortcuts({
    'enter': () => onStart(item.id),
    'mod+1': () => onDefer(item.id, '1h'),
    'mod+2': () => onDefer(item.id, 'tomorrow'),
    'mod+s': () => onSkip(item.id),
  });

  // ... render
}
```

**Acceptance criteria:**
- All shortcut tests pass
- Shortcuts don't fire when typing in inputs
- Visual feedback shows shortcut hints on buttons
- Works on both Mac (⌘) and potential future Windows (Ctrl)

Done in FocusCard.tsx - Enter/Cmd+S shortcuts


Phase 0: Test Infrastructure

# Test Infrastructure Foundation

**Context:** Mission Control follows TDD (RED → GREEN → REFACTOR). Before any feature work, we need a complete testing foundation that supports both Rust backend tests and React frontend tests.

**Why this matters:**
- Every feature will be implemented RED → GREEN (failing test first, then implementation)
- CLI integration (lore, br, bv) requires fixture-based contract testing
- Crash recovery and state machine tests need trait-based mocking in Rust
- E2E tests need Playwright + Tauri integration

**Scope includes:**
1. Configure Vitest for frontend unit/component tests
2. Configure Playwright for E2E tests
3. Set up Rust test harness with trait-based mocking
4. Create fixture directory structure (tests/fixtures/lore/, tests/fixtures/br/)
5. Capture initial CLI fixtures from real lore/br outputs
6. Add test commands to package.json and Cargo.toml

**Duration estimate:** 0.5 day

**Success criteria:**
- `npm run test` runs Vitest with React Testing Library
- `npm run test:e2e` runs Playwright
- `cargo test` runs Rust tests with mocking infrastructure
- Fixture files exist with real CLI output samples
- Scripts exist to regenerate fixtures

**Architecture notes:**
- Rust uses trait-based mocking (LoreCli, BeadsCli traits)
- Frontend uses @tauri-apps/api/mocks for IPC mocking
- Fixtures capture real CLI outputs for contract testing
- CI will compare fresh fixture regeneration against committed fixtures

Completed: Vitest + Playwright + Rust mocks + contract tests all configured


Wire closeBead through actions pipeline

# Task: Call closeBead when completing items

**Files:**
- `src/hooks/useActions.ts`
- Depends on Slice 0+1 for action wiring

**Why:** The `close_bead` Tauri command exists but is never called. When a user completes an item, we should close the corresponding bead.

**Implementation:**

In useActions.ts, the complete action should call close_bead:
```typescript
complete: async (item: ActionItem, reason: string | null, tags?: string[]) => {
  try {
    // Close the bead in backend first
    await invoke('close_bead', {
      beadId: item.id,
      reason: reason || 'Completed via Mission Control'
    });

    // Log the decision
    await logDecision({
      action: 'complete',
      bead_id: item.id,
      reason,
      tags: tags ?? [],
    });

    // Update local state (advance queue to next item)
    useFocusStore.getState().act('complete');
  } catch (error) {
    console.error('Failed to complete item:', error);
    throw error; // Re-throw so FocusView can show error to user
  }
},
```

**Error handling:** If close_bead fails (e.g., bead already closed), we should:
1. Still try to log the decision
2. Still advance the queue locally
3. Show warning to user (not hard error)

**Alternative approach (more resilient):**
```typescript
complete: async (item: ActionItem, reason: string | null, tags?: string[]) => {
  // Log decision first (most important for pattern learning)
  await logDecision({
    action: 'complete',
    bead_id: item.id,
    reason,
    tags: tags ?? [],
  });

  // Close bead - warn but don't fail if this fails
  try {
    await invoke('close_bead', {
      beadId: item.id,
      reason: reason || 'Completed via Mission Control'
    });
  } catch (error) {
    console.warn('Failed to close bead (may already be closed):', error);
  }

  // Always advance queue
  useFocusStore.getState().act('complete');
},
```

**Depends on:** bd-1qj (Slice 0+1 for action wiring)

**Verification:**
- Complete an item in FocusView
- Check `br show <bead-id>` - should be closed
- Queue advances to next item

**Tests:**
- Unit test: complete() calls close_bead with correct params
- Unit test: complete() logs decision before advancing
- Unit test: complete() advances queue even if close_bead fails

**Estimated effort:** 20 minutes

Verified: closeBead already wired in useActions.complete from Slice 0+1


Register tray in lib.rs setup

# Task: Register system tray during Tauri setup

**File:** `src-tauri/src/lib.rs`

**Why:** The tray must be created during Tauri's setup phase to be available when the app starts.

**Implementation:**

1. Add module declaration at top:
```rust
mod tray;
```

2. In the `run()` function, add setup hook:
```rust
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_shell::init())
        .setup(|app| {
            // Set up system tray (with graceful fallback per A6)
            if let Err(e) = tray::setup_tray(app.handle()) {
                // Log warning but don't fail app startup
                eprintln!("Warning: Failed to set up system tray: {}", e);
            }
            Ok(())
        })
        // ... rest of setup
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
```

**Note on graceful fallback (per AC-F3.11):**
If tray creation fails, we log a warning and continue. The app works without tray — only the tray-specific features are unavailable.

**Depends on:** bd-vz6 (tray module created)

**Tests:**
- Manual verification: app starts with tray icon
- Manual verification: app starts without crash if tray fails

**Estimated effort:** 15 minutes


Slice 3: System Tray Integration

# Epic: Add System Tray Icon with Badge

**Context:** TrayPopover component is fully implemented, but there's no actual system tray integration. The app only shows in dock/taskbar. Users need quick access to THE ONE THING without opening the full app.

**Architecture Decision (from A3 & A6):**
- Use Tauri's native system tray API
- macOS: Full TrayPopover anchored to menu bar
- Windows/Linux: Toggle main window visibility on click
- Graceful fallback: If tray API fails, app continues without tray (no user error)

**Estimated effort:** 1 day

**Acceptance Criteria (from PLAN-FOLLOWUP.md):**
- AC-F3.1: System tray icon appears on app start
- AC-F3.2: Badge updates when item count changes
- AC-F3.3: Click on macOS shows TrayPopover with item details
- AC-F3.4: Empty state shows "All clear" when no items
- AC-F3.5: Click on Windows/Linux toggles window visibility
- AC-F3.6: "Start" in popover opens GitLab URL, dismisses popover
- AC-F3.7: "Defer" snoozes item, updates popover to next item
- AC-F3.8: Deferring last item shows "All clear"
- AC-F3.9: "Full window" opens/focuses main window
- AC-F3.10: Clicking outside dismisses popover
- AC-F3.11: Graceful degradation if tray API fails

**No dependencies:** Can be done in parallel with Slice 2.


Implement Inbox view with triage actions

# Inbox View with Triage Actions (RED → GREEN)

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

**Purpose:** New items land in Inbox first for triage. Achievable inbox zero is the goal.

**Triage actions:**
| Action | Result |
|--------|--------|
| Accept | Move to Queue |
| Defer | Snooze for later |
| Archive | Mark as "not actionable for me" |

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

```typescript
// tests/components/Inbox.test.tsx
describe('Inbox', () => {
  const mockNewItems: WorkItem[] = [
    { id: '1', type: 'mention', title: 'You were mentioned in #312', triaged: false },
    { id: '2', type: 'mr_feedback', title: 'Comment on MR !847', triaged: false },
  ];

  it('shows only untriaged items', () => {
    const items = [...mockNewItems, { id: '3', triaged: true, ... }];
    render(<Inbox items={items} />);

    expect(screen.getAllByTestId('inbox-item')).toHaveLength(2);
  });

  it('shows inbox zero state when empty', () => {
    render(<Inbox items={[]} />);

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

  it('accept moves item to queue', async () => {
    const onTriage = vi.fn();
    render(<Inbox items={mockNewItems} onTriage={onTriage} />);

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

    expect(onTriage).toHaveBeenCalledWith('1', 'accept');
  });

  it('defer shows duration picker', async () => {
    render(<Inbox items={mockNewItems} />);

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

    expect(screen.getByRole('dialog')).toBeInTheDocument();
    expect(screen.getByText('1 hour')).toBeInTheDocument();
    expect(screen.getByText('Tomorrow')).toBeInTheDocument();
  });

  it('archive removes item from queue', async () => {
    const onTriage = vi.fn();
    render(<Inbox items={mockNewItems} onTriage={onTriage} />);

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

    expect(onTriage).toHaveBeenCalledWith('1', 'archive');
  });

  it('keyboard shortcuts work for triage', async () => {
    const onTriage = vi.fn();
    render(<Inbox items={mockNewItems} onTriage={onTriage} />);

    // Focus first item
    await userEvent.tab();
    // Press A for Accept
    await userEvent.keyboard('a');

    expect(onTriage).toHaveBeenCalledWith('1', 'accept');
  });
});
```

**Implementation (GREEN):**

```tsx
// src/components/Inbox.tsx
export function Inbox({ items, onTriage }: InboxProps) {
  const untriagedItems = items.filter(i => !i.triaged);

  if (untriagedItems.length === 0) {
    return (
      <div className="flex flex-col items-center justify-center py-12">
        <CheckCircle className="w-16 h-16 text-green-500 mb-4" />
        <h2 className="text-2xl font-bold">Inbox Zero</h2>
        <p className="text-muted-foreground">All caught up!</p>
      </div>
    );
  }

  return (
    <div className="space-y-2">
      <h2 className="text-lg font-semibold">Inbox ({untriagedItems.length})</h2>

      {untriagedItems.map((item, index) => (
        <InboxItem
          key={item.id}
          item={item}
          onAccept={() => onTriage(item.id, 'accept')}
          onDefer={(duration) => onTriage(item.id, 'defer', duration)}
          onArchive={() => onTriage(item.id, 'archive')}
          shortcuts={index === 0 ? { accept: 'a', defer: 'd', archive: 'x' } : undefined}
        />
      ))}
    </div>
  );
}

function InboxItem({ item, onAccept, onDefer, onArchive, shortcuts }: InboxItemProps) {
  return (
    <Card className="p-4">
      <div className="flex items-start gap-4">
        <TypeIcon type={item.type} />

        <div className="flex-1">
          <h3 className="font-medium">{item.title}</h3>
          <p className="text-sm text-muted-foreground">{item.metadata?.snippet}</p>
        </div>

        <div className="flex gap-2">
          <Button size="sm" onClick={onAccept}>
            Accept {shortcuts?.accept && <kbd className="ml-1">{shortcuts.accept}</kbd>}
          </Button>
          <DeferPicker onSelect={onDefer} />
          <Button size="sm" variant="ghost" onClick={onArchive}>
            Archive {shortcuts?.archive && <kbd className="ml-1">{shortcuts.archive}</kbd>}
          </Button>
        </div>
      </div>
    </Card>
  );
}
```

**Acceptance criteria:**
- Only untriaged items shown
- Inbox zero celebration state
- Accept/Defer/Archive actions work
- Keyboard shortcuts for fast triage
- Triage decisions logged

Completed: Implemented Inbox view with Accept/Defer/Archive triage actions, keyboard shortcuts, defer duration picker, and inbox zero state. All 12 tests pass.


Implement Settings UI

# Settings UI

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

**Purpose:** Allow users to customize Mission Control behavior.

**Settings to include:**

| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| Toggle hotkey | Keybinding | ⌘⇧M | Customize window toggle |
| Capture hotkey | Keybinding | ⌘⇧C | Customize quick capture |
| Lore DB path | File path | ~/.local/share/lore/lore.db | Custom lore location |
| Reconciliation interval | Number | 6 | Hours between full reconciliation |
| Show floating widget | Boolean | false | Enable persistent mini-view |
| Default defer duration | Select | 1h | Default snooze time |
| Sound effects | Boolean | true | Celebration sounds |

**File location:** `~/.local/share/mc/settings.json`

**Schema:**
```json
{
  "schema_version": 1,
  "hotkeys": {
    "toggle": "Meta+Shift+M",
    "capture": "Meta+Shift+C"
  },
  "lore_db_path": null,
  "reconciliation_hours": 6,
  "floating_widget": false,
  "default_defer": "1h",
  "sounds": true
}
```

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

```typescript
// tests/components/Settings.test.tsx
describe('Settings', () => {
  it('loads current settings on mount', async () => {
    render(<Settings />);

    await waitFor(() => {
      expect(screen.getByDisplayValue('⌘⇧M')).toBeInTheDocument();
    });
  });

  it('saves settings on change', async () => {
    const mockSave = vi.fn();
    render(<Settings onSave={mockSave} />);

    await userEvent.click(screen.getByLabelText('Sound effects'));

    expect(mockSave).toHaveBeenCalledWith(expect.objectContaining({
      sounds: false
    }));
  });

  it('validates hotkey format', async () => {
    render(<Settings />);

    await userEvent.clear(screen.getByLabelText('Toggle hotkey'));
    await userEvent.type(screen.getByLabelText('Toggle hotkey'), 'invalid');

    expect(screen.getByText(/Invalid hotkey format/)).toBeInTheDocument();
  });
});
```

**Implementation:**

```tsx
// src/views/SettingsView.tsx
export function SettingsView() {
  const { data: settings, mutate } = useSettings();

  return (
    <div className="p-6 max-w-lg mx-auto">
      <h1 className="text-2xl font-bold mb-6">Settings</h1>

      <div className="space-y-6">
        <section>
          <h2 className="text-lg font-semibold mb-3">Hotkeys</h2>
          <HotkeyInput
            label="Toggle window"
            value={settings.hotkeys.toggle}
            onChange={(v) => mutate({ ...settings, hotkeys: { ...settings.hotkeys, toggle: v }})}
          />
          <HotkeyInput
            label="Quick capture"
            value={settings.hotkeys.capture}
            onChange={(v) => mutate({ ...settings, hotkeys: { ...settings.hotkeys, capture: v }})}
          />
        </section>

        <section>
          <h2 className="text-lg font-semibold mb-3">Behavior</h2>
          <Switch
            label="Sound effects"
            checked={settings.sounds}
            onCheckedChange={(v) => mutate({ ...settings, sounds: v })}
          />
          <Switch
            label="Floating widget"
            checked={settings.floating_widget}
            onCheckedChange={(v) => mutate({ ...settings, floating_widget: v })}
          />
        </section>
      </div>
    </div>
  );
}
```

**Acceptance criteria:**
- All settings load and save correctly
- Hotkey changes take effect immediately
- Invalid inputs show validation errors
- Settings persist across app restarts

Settings UI implemented with full TDD coverage: theme toggle, notifications, sounds, floating widget, hotkey config with validation, reconciliation interval, default defer duration, keyboard shortcuts display, lore path config, and data directory info. 21 passing tests.


Write tests for Slice 7: State Persistence

# Task: Test State Persistence

**Files:**
- `tests/stores/settings-store.test.ts` (new)
- `tests/hooks/useActions.test.ts` (extend for closeBead)

**Unit Tests:**

1. **Settings Store:**
```typescript
describe('useSettingsStore', () => {
  test('hydrate loads settings from backend', async () => {
    const mockInvoke = vi.fn().mockResolvedValue({
      settings: { syncInterval: 5, notificationsEnabled: false }
    });
    vi.mock('@tauri-apps/api/core', () => ({ invoke: mockInvoke }));

    await useSettingsStore.getState().hydrate();

    expect(useSettingsStore.getState().syncInterval).toBe(5);
    expect(useSettingsStore.getState().notificationsEnabled).toBe(false);
  });

  test('update persists changes to backend', async () => {
    const mockInvoke = vi.fn();

    await useSettingsStore.getState().update({ syncInterval: 30 });

    expect(mockInvoke).toHaveBeenCalledWith('write_state', expect.objectContaining({
      state: expect.objectContaining({
        settings: expect.objectContaining({ syncInterval: 30 })
      })
    }));
  });
});
```

2. **closeBead integration:**
```typescript
test('complete action calls close_bead', async () => {
  const mockInvoke = vi.fn();

  await actions.complete(item, 'Done', []);

  expect(mockInvoke).toHaveBeenCalledWith('close_bead', {
    beadId: item.id,
    reason: 'Done'
  });
});
```

**Depends on:** bd-hxb, bd-l0l, bd-2y5 (all Slice 7 implementation)

**Estimated effort:** 30 minutes


System tray icon with badge counts

# System Tray Icon Implementation

**Parent epic:** Phase 1: Foundation

**UX principle:** Ambient awareness, not interruption. The tray icon shows at-a-glance status without being modal.

**Implementation:**

1. **Tauri tray setup (src-tauri/src/tray.rs):**
   ```rust
   use tauri::{
       tray::{TrayIconBuilder, TrayIconEvent},
       Manager, Runtime,
   };

   pub fn setup_tray<R: Runtime>(app: &tauri::App<R>) -> Result<()> {
       let tray = TrayIconBuilder::new()
           .icon(app.default_window_icon().unwrap().clone())
           .tooltip("Mission Control")
           .on_tray_icon_event(|tray, event| {
               match event {
                   TrayIconEvent::Click { button: LeftButton, .. } => {
                       // Toggle popover
                   }
                   _ => {}
               }
           })
           .build(app)?;
       Ok(())
   }
   ```

2. **Badge count updates:**
   - Tray icon can show numeric badge (macOS native)
   - Update badge when work item count changes
   - Badge reflects: pending items + inbox items

3. **Icon assets:**
   - Create icons at multiple sizes (16x16, 32x32, 64x64)
   - Template image for macOS (works with light/dark menu bar)
   - Colored variants for badge states

4. **Menu on right-click:**
   - "Show Mission Control" (opens full window)
   - "Quick Capture" (opens capture overlay)
   - Separator
   - "Quit"

**Integration with frontend:**
- Tauri event to update badge count
- Frontend sends count updates via invoke

**Acceptance criteria:**
- Tray icon appears when app runs
- Left-click shows popover (or toggles window)
- Right-click shows context menu
- Badge count updates reflect actual pending items
- Works on macOS (primary target)

Completed: System tray icon with menu (Show/Quit), left-click toggles window


Create tray.rs module for system tray logic

# Task: Create Rust module for system tray

**File:** `src-tauri/src/tray.rs` (new)

**Why:** Centralizes all tray-related logic: icon creation, badge updates, click handling.

**Implementation:**

```rust
// src-tauri/src/tray.rs
use tauri::{
    tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
    Manager, Runtime,
};

pub fn setup_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<(), Box<dyn std::error::Error>> {
    // Get icon with proper error handling (no panic on missing icon)
    let icon = app
        .default_window_icon()
        .ok_or("No default window icon configured")?
        .clone();

    let tray = TrayIconBuilder::new()
        .icon(icon)
        .tooltip("Mission Control")
        .on_tray_icon_event(|tray, event| {
            if let TrayIconEvent::Click {
                button: MouseButton::Left,
                button_state: MouseButtonState::Up,
                ..
            } = event
            {
                let app = tray.app_handle();

                // Emit event to frontend to show popover
                if let Err(e) = app.emit("tray-clicked", ()) {
                    eprintln!("Failed to emit tray-clicked: {}", e);
                }

                // Toggle main window visibility
                if let Some(window) = app.get_webview_window("main") {
                    match window.is_visible() {
                        Ok(true) => { let _ = window.hide(); }
                        Ok(false) => {
                            let _ = window.show();
                            let _ = window.set_focus();
                        }
                        Err(e) => eprintln!("Failed to check window visibility: {}", e),
                    }
                }
            }
        })
        .build(app)?;

    // Store tray handle for badge updates
    app.manage(TrayState { tray });

    Ok(())
}

pub struct TrayState {
    pub tray: tauri::tray::TrayIcon,
}
```

**Key improvements:**
- Removed unsafe unwrap() calls
- Proper error handling for missing icon
- Emit errors logged instead of ignored
- Window visibility check handles errors

**Depends on:** bd-1p7 (tray plugin enabled)

**Tests:**
- Unit test: setup_tray returns error when no icon configured
- Unit test: TrayState stored in app state
- (Full integration testing requires actual Tauri runtime)

**Estimated effort:** 30 minutes


Implement menu bar popover

# Menu Bar Popover

**Parent epic:** Phase 1: Foundation

**Purpose:** Quick glance at THE ONE THING without opening full window. Click tray icon → popover appears.

**Visual design:**
```
                              ┌─────────────────────────────┐
                              │  THE ONE THING              │
                              │  Review MR !847             │
                              │  2d waiting · @sarah        │
                              │                             │
                              │  [Start]  [Defer]  [Skip]   │
                              ├─────────────────────────────┤
                              │  Queue: 4  Inbox: 3         │
                              │  ⌘⇧F Full window            │
                              └─────────────────────────────┘
```

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

```typescript
// tests/components/Popover.test.tsx
describe('TrayPopover', () => {
  it('shows current focus item', () => {
    render(<TrayPopover focusItem={mockItem} />);

    expect(screen.getByText('THE ONE THING')).toBeInTheDocument();
    expect(screen.getByText('Review MR !847')).toBeInTheDocument();
  });

  it('shows queue and inbox counts', () => {
    render(<TrayPopover queueCount={4} inboxCount={3} />);

    expect(screen.getByText('Queue: 4')).toBeInTheDocument();
    expect(screen.getByText('Inbox: 3')).toBeInTheDocument();
  });

  it('shows empty state when no focus', () => {
    render(<TrayPopover focusItem={null} />);

    expect(screen.getByText(/Nothing focused/)).toBeInTheDocument();
  });

  it('Start action opens browser', async () => {
    const mockOpen = vi.fn();
    render(<TrayPopover focusItem={mockItem} onStart={mockOpen} />);

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

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

**Implementation:**

```tsx
// src/components/TrayPopover.tsx
interface TrayPopoverProps {
  focusItem: WorkItem | null;
  queueCount: number;
  inboxCount: number;
  onStart: () => void;
  onDefer: (duration: string) => void;
  onSkip: () => void;
  onOpenFull: () => void;
}

export function TrayPopover({
  focusItem,
  queueCount,
  inboxCount,
  onStart,
  onDefer,
  onSkip,
  onOpenFull
}: TrayPopoverProps) {
  return (
    <div className="w-72 p-4 bg-background rounded-lg shadow-lg">
      {focusItem ? (
        <>
          <p className="text-xs text-muted-foreground mb-1">THE ONE THING</p>
          <h3 className="font-semibold truncate">{focusItem.title}</h3>
          <p className="text-sm text-muted-foreground">
            {formatAge(focusItem.createdAt)} · {focusItem.metadata?.author}
          </p>

          <div className="flex gap-2 mt-3">
            <Button size="sm" onClick={onStart}>Start</Button>
            <Button size="sm" variant="outline" onClick={() => onDefer('1h')}>Defer</Button>
            <Button size="sm" variant="ghost" onClick={onSkip}>Skip</Button>
          </div>
        </>
      ) : (
        <p className="text-muted-foreground">Nothing focused. Pick something from the queue!</p>
      )}

      <Separator className="my-3" />

      <div className="flex justify-between text-sm">
        <span>Queue: {queueCount}</span>
        <span>Inbox: {inboxCount}</span>
      </div>

      <Button variant="link" size="sm" className="mt-2 w-full" onClick={onOpenFull}>
        ⌘⇧F Full window
      </Button>
    </div>
  );
}
```

**Tauri integration:**

```rust
// Popover window configuration in tauri.conf.json
{
  "windows": [
    {
      "label": "popover",
      "width": 288,
      "height": 200,
      "decorations": false,
      "alwaysOnTop": true,
      "visible": false,
      "skipTaskbar": true
    }
  ]
}
```

**Acceptance criteria:**
- Popover appears on tray icon click
- Shows THE ONE THING or empty state
- Actions work (Start/Defer/Skip)
- Shows queue and inbox counts
- Can open full window from popover

Implemented TrayPopover React component with focus item display, quick actions (Start/Defer/Skip), queue/inbox counts, and full window link. 18 tests pass. Tauri window configuration for popover positioning would be handled separately in tauri.conf.json.


Register global hotkey for Quick Capture (⌘⇧C)

# Global Hotkey for Quick Capture

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

**Requirement:** ⌘⇧C (Cmd+Shift+C) must work from ANY app, even when Mission Control is not focused.

**Implementation:**

```rust
// src-tauri/src/shortcuts.rs
pub fn setup_shortcuts<R: Runtime>(app: &tauri::App<R>) -> Result<()> {
    use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut};

    // Toggle window (⌘⇧M)
    let toggle_shortcut = Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyM);

    // Quick capture (⌘⇧C)
    let capture_shortcut = Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyC);

    app.global_shortcut().on_shortcut(capture_shortcut, |app, _| {
        // Show capture overlay
        if let Some(window) = app.get_webview_window("main") {
            // Make window visible if hidden
            if !window.is_visible().unwrap_or(false) {
                window.show().ok();
            }
            window.set_focus().ok();

            // Emit event to show capture overlay
            app.emit_all("show-quick-capture", ()).ok();
        }
    })?;

    Ok(())
}
```

**Frontend listener:**

```typescript
// src/App.tsx
function App() {
  const [showCapture, setShowCapture] = useState(false);

  useEffect(() => {
    const unlisten = listen('show-quick-capture', () => {
      setShowCapture(true);
    });

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

  return (
    <>
      {/* ... other views */}
      <QuickCapture
        isOpen={showCapture}
        onSave={async (text) => {
          await invoke('quick_capture', { text });
          setShowCapture(false);
        }}
        onCancel={() => setShowCapture(false)}
      />
    </>
  );
}
```

**Window behavior:**
1. If MC is hidden → show window + show overlay
2. If MC is visible but overlay closed → show overlay
3. If overlay is open → do nothing (or close it?)

**Testing considerations:**
- Global shortcuts are hard to test automatically
- Manual testing required for cross-app behavior
- E2E test can verify overlay behavior after trigger

**Acceptance criteria:**
- ⌘⇧C works from any app
- Overlay appears within 200ms
- Window comes to front if needed
- No conflict with other common shortcuts


File watcher on lore.db for change detection

# File Watcher for Lore Database Changes (RED → GREEN)

**Parent epic:** Phase 1: Foundation

**Why file watching:**
Lore runs on a cron schedule (not triggered by MC). When lore syncs new data, MC should refresh automatically. We watch lore.db's mtime for changes rather than polling the CLI.

**TDD approach:**

1. **RED: Write file watcher tests:**
   ```rust
   // src-tauri/tests/watcher_test.rs
   #[tokio::test]
   async fn watcher_detects_mtime_change() {
       let temp = tempfile::tempdir().unwrap();
       let db_path = temp.path().join("lore.db");
       std::fs::write(&db_path, "initial").unwrap();

       let (tx, mut rx) = tokio::sync::mpsc::channel(1);
       let _watcher = FileWatcher::new(&db_path, tx);

       // Modify file
       std::fs::write(&db_path, "modified").unwrap();

       // Should receive change notification
       let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())
           .await
           .expect("Should receive event")
           .expect("Channel shouldn't close");

       assert!(matches!(event, WatchEvent::Modified));
   }

   #[tokio::test]
   async fn watcher_debounces_rapid_changes() {
       // Multiple rapid writes should coalesce to single event
   }
   ```

2. **GREEN: Implement watcher:**
   ```rust
   // src-tauri/src/watcher.rs
   use notify::{Watcher, RecursiveMode, Config, RecommendedWatcher};

   pub struct LoreDbWatcher {
       _watcher: RecommendedWatcher,
   }

   impl LoreDbWatcher {
       pub fn new<F>(db_path: &Path, on_change: F) -> Result<Self>
       where
           F: Fn() + Send + 'static
       {
           let debounce_duration = Duration::from_millis(500);
           let config = Config::default()
               .with_poll_interval(Duration::from_secs(2));

           let mut watcher = RecommendedWatcher::new(
               move |res: Result<notify::Event, _>| {
                   if let Ok(event) = res {
                       if event.kind.is_modify() {
                           on_change();
                       }
                   }
               },
               config,
           )?;

           watcher.watch(db_path, RecursiveMode::NonRecursive)?;

           Ok(Self { _watcher: watcher })
       }
   }
   ```

3. **Integration with Tauri:**
   ```rust
   // When lore.db changes, emit event to frontend
   let watcher = LoreDbWatcher::new(&lore_db_path, move || {
       app_handle.emit_all("lore-db-changed", ()).ok();
   })?;
   ```

**Lore.db location:**
- Default: `~/.local/share/lore/lore.db`
- Should be configurable via settings

**Debouncing:**
- Lore writes may trigger multiple filesystem events
- Debounce to single refresh per 500ms window

**Acceptance criteria:**
- File changes detected within 2 seconds
- Rapid changes debounced to single event
- Frontend receives Tauri event on change
- Works with both native and poll-based watchers
- Graceful handling of missing/moved database file

Implemented in src-tauri/src/watcher.rs - watches lore.db mtime, emits 'lore-data-changed' event. 62 Rust tests pass.


Add staleness styles to FocusCard

# Task: Apply staleness indicator to FocusCard

**File:** `src/components/FocusCard.tsx`

**Implementation:**

1. Import staleness utilities:
```typescript
import { getStalenessLevel, stalenessStyles } from '@/lib/staleness';
```

2. Calculate staleness in component:
```typescript
const stalenessLevel = getStalenessLevel(item.createdAt);
const stalenessClass = stalenessStyles[stalenessLevel];
```

3. Apply to card container:
```tsx
<div className={cn(
  'rounded-lg border bg-card p-6',
  stalenessClass
)}>
  {/* ... card content ... */}
</div>
```

**Visual verification:**
- Fresh item: thin green left border
- Normal item: no left border
- Stale item: thin amber left border
- Critical item: thin red left border with pulse animation

**Depends on:** bd-1fw (staleness utility)

**Estimated effort:** 10 minutes


Slice 4: E2E Test Updates

# Epic: Fix Failing Playwright Tests

**Context:** 2 of 11 Playwright tests are failing, but these are NOT code bugs — the tests are outdated:

1. **"shows Inbox placeholder"** — expects old text "Inbox view coming in Phase 4b", but InboxView is now fully implemented showing "Inbox Zero" with celebration UI.

2. **"Queue tab shows item count badge"** — selector `page.getByText("3")` is too broad and matches multiple elements. Need to use specific test ID.

**Root cause:** Tests weren't updated when components were implemented.

**Estimated effort:** 0.5 day

**Acceptance Criteria:**
- All 11 E2E tests pass
- Tests reflect current component implementations
- Badge selector uses data-testid for precision


Implement crash-safe write-ahead pattern with pending flag

# Crash Recovery with Write-Ahead Pattern (RED → GREEN)

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

**Problem being solved:**
If MC crashes mid-sync, we risk:
- Duplicates: bead created but not mapped
- Lost events: cursor advanced but bead not created

**Solution:** Write-ahead pattern with idempotent operations.

**For each new event:**
```
1. Check if key exists in mapping → if yes, skip (idempotent)
2. Write mapping entry FIRST: {key} → {bead_id: null, pending: true}
3. Create bead via `br create`
4. Update mapping: {bead_id: actual_id, pending: false}
5. On success of all events: advance cursor
```

**Crash recovery (on startup):**
```
1. Scan mapping for entries with pending: true
2. For each pending entry:
   - If bead_id is null → retry `br create`, update mapping
   - If bead_id exists but pending → verify bead exists, clear pending flag
3. Do NOT advance cursor until all pending entries resolved
```

**TDD: Crash recovery tests (RED first):**

```rust
#[test]
fn crash_before_br_create_retries_on_startup() {
    // Simulate state: mapping written but br create never ran
    let mapping = mapping_with_pending("mr:gitlab:123:847", None);  // null bead_id
    let beads = MockBeadsCli::new();

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

    // Should have retried create
    assert!(beads.calls().contains(&BeadsCall::Create { .. }));

    // Should have updated mapping with real bead_id
    let entry = bridge.mapping.get("mr:gitlab:123:847").unwrap();
    assert!(entry.bead_id.is_some());
    assert!(!entry.pending);
}

#[test]
fn crash_after_br_create_verifies_bead_exists() {
    // Simulate state: bead created but pending flag not cleared
    let mapping = mapping_with_pending("mr:gitlab:123:847", Some("br-x7f"));
    let beads = MockBeadsCli::with_existing("br-x7f");

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

    // Should have verified bead exists
    assert!(beads.calls().contains(&BeadsCall::Exists { id: "br-x7f" }));

    // Should have cleared pending flag
    let entry = bridge.mapping.get("mr:gitlab:123:847").unwrap();
    assert!(!entry.pending);
}

#[test]
fn crash_after_br_create_but_bead_missing_retries() {
    // Edge case: br create succeeded but bead was somehow deleted
    let mapping = mapping_with_pending("mr:gitlab:123:847", Some("br-x7f"));
    let beads = MockBeadsCli::empty();  // bead doesn't exist

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

    // Should have created a new bead
    let entry = bridge.mapping.get("mr:gitlab:123:847").unwrap();
    assert!(entry.bead_id.is_some());
    assert_ne!(entry.bead_id.as_deref(), Some("br-x7f"));  // New ID
}

#[test]
fn cursor_not_advanced_until_pending_resolved() {
    let mapping = mapping_with_pending("mr:gitlab:123:847", None);
    let old_cursor = mapping.cursor.clone();
    let beads = MockBeadsCli::new();

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

    // Even if recovery succeeds, cursor should be what it was
    // (because we don't know what events were missed)
    assert_eq!(bridge.cursor.last_check_timestamp, old_cursor.last_check_timestamp);
}
```

**Implementation (GREEN):**

```rust
impl<L: LoreCli, B: BeadsCli> BridgeState<L, B> {
    pub fn recover(lore: L, beads: B, mapping: Mapping) -> Result<Self> {
        let mut bridge = Self { lore, beads, mapping };

        // Find all pending entries
        let pending: Vec<_> = bridge.mapping.iter()
            .filter(|(_, e)| e.pending)
            .map(|(k, e)| (k.clone(), e.clone()))
            .collect();

        for (key, entry) in pending {
            if let Some(bead_id) = &entry.bead_id {
                // bead_id exists — verify it's real
                if bridge.beads.exists(bead_id)? {
                    // Just clear pending flag
                    bridge.mapping.get_mut(&key).unwrap().pending = false;
                } else {
                    // Bead was lost — recreate
                    let new_id = bridge.beads.create(&key, "gitlab")?;
                    let e = bridge.mapping.get_mut(&key).unwrap();
                    e.bead_id = Some(new_id);
                    e.pending = false;
                }
            } else {
                // bead_id is null — create was never attempted
                let bead_id = bridge.beads.create(&key, "gitlab")?;
                let e = bridge.mapping.get_mut(&key).unwrap();
                e.bead_id = Some(bead_id);
                e.pending = false;
            }
        }

        // Save recovered mapping
        bridge.save_mapping()?;

        Ok(bridge)
    }

    pub fn create_bead_for_event(&mut self, event: &LoreEvent) -> Result<()> {
        let key = event.to_mapping_key();

        // Step 1: Idempotency check
        if self.mapping.contains_key(&key) {
            return Ok(());
        }

        // Step 2: Write pending entry FIRST
        self.mapping.insert(key.clone(), MappingEntry {
            bead_id: None,
            pending: true,
            ..Default::default()
        });
        self.save_mapping()?;

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

        // Step 4: Update entry with bead_id, clear pending
        let entry = self.mapping.get_mut(&key).unwrap();
        entry.bead_id = Some(bead_id);
        entry.pending = false;
        self.save_mapping()?;

        Ok(())
    }
}
```

**Why this works:**
- Step 1 is idempotent (duplicate events skip)
- Step 2 happens before bead creation (we know we intend to create)
- Step 5 only advances cursor after ALL events processed
- Recovery finds incomplete work and finishes it

**Acceptance criteria:**
- All 4 crash recovery tests pass
- Pending flag correctly tracks in-flight operations
- Recovery handles all failure scenarios
- No duplicates or lost events possible

Already implemented in bridge.rs: pending flag in MappingEntry, process_event writes pending=true first then updates to false after bead creation, recover_pending handles crash recovery. Tests verify both crash scenarios.