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

Completed: QueueList component with type section grouping, staleness colors, item counts


Install @dnd-kit dependencies

# Task: Install @dnd-kit packages

**Command:**
```bash
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
```

**Why @dnd-kit:**
From Architecture Decision A1 in PLAN-FOLLOWUP.md:

| Factor | @dnd-kit | react-beautiful-dnd | react-dnd |
|--------|----------|---------------------|-----------|
| React 19 support | Yes | No (deprecated) | Partial |
| Bundle size | 15KB | 32KB | 45KB |
| TypeScript | Native | @types needed | Native |
| Touch support | Built-in | Limited | Plugin |
| Accessibility | WCAG 2.1 | WCAG 2.0 | Manual |
| Active maintenance | Yes | No (Atlassian EOL) | Yes |

**Packages:**
- `@dnd-kit/core` - Core drag and drop logic
- `@dnd-kit/sortable` - Sortable list utilities
- `@dnd-kit/utilities` - CSS helpers (transform, transition)

**Verification:**
```bash
npm ls @dnd-kit/core
```

**Estimated effort:** 5 minutes

Completed: installed @dnd-kit, created SortableQueueItem wrapper


Write tests for Slice 6: Quick Wiring

# Task: Test Quick Wiring Components

**Files:**
- `tests/components/AppShell.test.tsx` (extend)
- `tests/components/QueueView.test.tsx` (extend)

**Why:** Slice 6 wires up existing components but needs tests to verify the wiring works.

**Unit Tests:**

1. **Batch Mode activation:**
```typescript
test('QueueView calls onStartBatch when Batch button clicked', () => {
  const onStartBatch = vi.fn();
  render(<QueueView onStartBatch={onStartBatch} /* other props */ />);

  // Find section with items
  const batchButton = screen.getByRole('button', { name: /Batch/i });
  fireEvent.click(batchButton);

  expect(onStartBatch).toHaveBeenCalledWith(expect.any(Array));
});

test('AppShell passes onStartBatch to QueueView', () => {
  // Verify prop drilling works
});
```

2. **SyncStatus rendering:**
```typescript
test('AppShell renders SyncStatus component', () => {
  render(<AppShell />);
  // SyncStatus should show last sync time or health indicator
  expect(screen.getByTestId('sync-status')).toBeInTheDocument();
});
```

3. **SettingsView rendering:**
```typescript
test('SettingsView renders Settings component', () => {
  render(<SettingsView />);
  // Should see settings UI, not placeholder
  expect(screen.getByText(/Sync Interval/i)).toBeInTheDocument();
});
```

**Depends on:** bd-2oo, bd-3g3, bd-8mo (all Slice 6 implementation)

**Estimated effort:** 20 minutes


Create settings store with Tauri persistence

# Task: Create settings store

**File:** `src/stores/settings-store.ts` (new)

**Why:** Settings need their own store to manage sync interval, notifications, and shortcuts.

**Implementation:**

```typescript
// src/stores/settings-store.ts
import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';

interface Settings {
  syncInterval: 5 | 15 | 30;
  notificationsEnabled: boolean;
  quickCaptureShortcut: string;
}

interface SettingsStore extends Settings {
  hydrate: () => Promise<void>;
  update: (partial: Partial<Settings>) => Promise<void>;
}

const defaultSettings: Settings = {
  syncInterval: 15,
  notificationsEnabled: true,
  quickCaptureShortcut: 'CommandOrControl+Shift+C',
};

export const useSettingsStore = create<SettingsStore>()((set, get) => ({
  // Spread defaults
  ...defaultSettings,

  hydrate: async () => {
    try {
      const state = await invoke<{ settings?: Settings }>('read_state');
      if (state.settings) {
        set(state.settings);
      }
    } catch (e) {
      console.error('Failed to hydrate settings:', e);
    }
  },

  update: async (partial) => {
    // Extract only Settings properties (not methods) using the defaults as reference
    const currentSettings: Settings = {
      syncInterval: get().syncInterval,
      notificationsEnabled: get().notificationsEnabled,
      quickCaptureShortcut: get().quickCaptureShortcut,
    };

    const newSettings: Settings = { ...currentSettings, ...partial };

    try {
      // Get current full state and update settings portion
      const currentState = await invoke<Record<string, unknown>>('read_state');
      await invoke('write_state', {
        state: { ...currentState, settings: newSettings }
      });

      // Only set after successful persist
      set(partial);
    } catch (e) {
      console.error('Failed to persist settings:', e);
      throw e; // Re-throw so caller can handle
    }
  },
}));
```

**Key fix:** Don't try to destructure methods from the store state. Instead, explicitly extract only the Settings properties using get() calls.

**Tests:**
- Unit test: hydrate loads settings from backend
- Unit test: update persists settings to backend
- Unit test: update merges partial changes correctly
- Unit test: update throws on persist failure (doesn't update local state)
- Unit test: hydrate handles missing settings gracefully

**Estimated effort:** 30 minutes

Completed: created settings-store with Tauri persistence, validation, and 13 tests


Pass onStartBatch from AppShell to QueueView

# Task: Wire Batch Mode activation

**Files:**
- `src/components/AppShell.tsx`
- `src/components/QueueView.tsx`

**Problem:** QueueView has "Batch" buttons but AppShell doesn't pass the callback.

**Implementation:**

1. In AppShell.tsx, pass callback to QueueView:
```typescript
<QueueView
  onSetFocus={(id) => { setFocus(id); }}
  onSwitchToFocus={() => setView('focus')}
  onStartBatch={(items) => {
    useBatchStore.getState().startBatch(items);
  }}
/>
```

2. Verify QueueView accepts and uses the callback:
```typescript
interface QueueViewProps {
  onSetFocus: (id: string) => void;
  onSwitchToFocus: () => void;
  onStartBatch?: (items: FocusItem[]) => void;  // Should already exist
}

// In section header:
<Button onClick={() => onStartBatch?.(section.items)}>
  Batch
</Button>
```

**Verification:**
- Click "Batch" on "Reviews (4)" section header
- BatchMode component should render with those 4 items
- Progress bar shows "1 of 4"

**Estimated effort:** 15 minutes

Wired onStartBatch callback from AppShell to QueueView via useBatchStore.getState().startBatch()


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

Implemented SyncStatus component with 5 visual states (synced/syncing/stale/error/offline), relative time formatting, auto-stale detection at 15min threshold, and retry/refresh buttons. 23 tests cover all states and edge cases.


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)

Completed: Implemented ReasonPrompt component with quick tag selection, text input, and keyboard navigation. All 10 tests pass.


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

Done in bridge.rs - full_reconciliation()


Run full E2E suite and verify all 11 tests pass

# Task: Verify all E2E tests pass

**Command:**
```bash
npm run test:e2e
```

**Why:** Final validation that all test fixes work together.

**Expected result:** 11 tests passing, 0 failing.

**If tests fail:**
1. Check console output for specific failure
2. Verify selectors match current DOM structure
3. Verify store seeding works in test environment

**Depends on:** bd-3e3, bd-332, bd-ez0 (all test fixes)

**Estimated effort:** 10 minutes


Wrap QueueView sections with DndContext

# Task: Add DndContext and SortableContext to QueueView

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

**Why:** DndContext provides the drag-and-drop context, and SortableContext enables sorting within each section. Each section needs its own SortableContext because reordering happens within sections, not across them.

**Implementation:**

1. Add imports:
```typescript
import {
  DndContext,
  closestCenter,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  type DragEndEvent,
} from '@dnd-kit/core';
import {
  SortableContext,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { SortableQueueItem } from './SortableQueueItem';
```

2. Set up sensors with 150ms delay (per AC-F1.1):
```typescript
const sensors = useSensors(
  useSensor(PointerSensor, {
    activationConstraint: { delay: 150, tolerance: 5 },
  }),
  useSensor(KeyboardSensor, {
    coordinateGetter: sortableKeyboardCoordinates,
  })
);
```

3. Wrap sections with contexts:
```typescript
<DndContext
  sensors={sensors}
  collisionDetection={closestCenter}
  onDragEnd={handleDragEnd}
>
  {sections.map((section) => (
    <SortableContext
      key={section.type}
      items={section.items.map((i) => i.id)}
      strategy={verticalListSortingStrategy}
    >
      {section.items.map((item) => (
        <SortableQueueItem
          key={item.id}
          id={item.id}
          item={item}
          onClick={...}
          isFocused={...}
        />
      ))}
    </SortableContext>
  ))}
</DndContext>
```

**Depends on:** bd-1ln (SortableQueueItem created)

**Tests:**
- Integration test: items can be dragged
- Unit test: sensors configured with correct delay

**Estimated effort:** 30 minutes


Implement br CLI wrapper for bead operations

# Beads CLI Wrapper (RED → GREEN)

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

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

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

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

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

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

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

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

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

**Implementation (GREEN):**

```rust
pub struct RealBeadsCli;

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

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

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

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

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

        Ok(())
    }

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

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

**Mock implementation for tests:**

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

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

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

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


Phase 2: Bridge + Data Layer

# Bridge + Data Layer — GitLab → Beads Integration

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

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

**Duration estimate:** 2-3 days

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

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

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

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

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


Display raw lore data in UI

# Basic UI Showing Lore Data (Visual Verification)

**Parent epic:** Phase 1: Foundation

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

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

**Implementation:**

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

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

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

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

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

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

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

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

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

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

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

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

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

Implemented DebugView component with TanStack Query hook. Shows raw lore data from get_lore_status, accessible via Debug tab in navigation. Tests pass.


Implement Zustand store with persistence

# Zustand Store with Persistence

**Parent epic:** Phase 1: Foundation

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

**Store slices:**

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

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

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

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

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

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

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

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

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

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

      clearFocus();

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

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

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

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

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

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

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

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

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

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

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

      await initStore();

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

**Implementation:**

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

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

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

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

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

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

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

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

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


Wire getTriage to SuggestionCard with TanStack Query

# Task: Fetch triage recommendations for suggestions

**Files:**
- `src/components/FocusView.tsx`
- `src/lib/types.ts` (add TriageResponse type)

**Why:** The `get_triage` Tauri command exists but isn't called. When FocusView has no current item, it should show triage suggestions.

**Implementation:**

1. Define the response type in types.ts:
```typescript
export interface TriageResponse {
  topPick: FocusItem | null;
  reason: string;  // e.g., "Highest priority", "Blocking 3 others"
  alternatives: FocusItem[];
}
```

2. Use TanStack Query for data fetching in FocusView:
```typescript
import { useQuery } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api/core';

// In FocusView component
const { data: triage, isLoading: isTriageLoading, error: triageError } = useQuery({
  queryKey: ['triage'],
  queryFn: () => invoke<TriageResponse>('get_triage'),
  staleTime: 5 * 60 * 1000, // 5 minutes
  enabled: !current, // Only fetch when no current item
});

// Show suggestion when no current item
{!current && (
  <>
    {isTriageLoading && <LoadingSpinner />}
    {triageError && <ErrorMessage message="Failed to load suggestions" />}
    {triage?.topPick && (
      <SuggestionCard
        suggestion={triage.topPick}
        reason={triage.reason}
        onAccept={() => setFocus(triage.topPick!.id)}
      />
    )}
    {!triage?.topPick && !isTriageLoading && !triageError && (
      <EmptyState message="All caught up!" />
    )}
  </>
)}
```

**Per AC-F9.2:** The suggestion should include the recommendation reason (why this item is suggested).

**Query invalidation:** When user completes/skips/defers an item, invalidate the triage query:
```typescript
import { useQueryClient } from '@tanstack/react-query';

const queryClient = useQueryClient();

// After any action that changes what should be triaged:
queryClient.invalidateQueries({ queryKey: ['triage'] });
```

**Verification:**
- Clear focus (no current item)
- FocusView shows triage suggestion with reason
- Loading state shows while fetching
- Error state shows if fetch fails
- Accepting suggestion sets focus

**Tests:**
- Unit test: suggestion shows reason text
- Unit test: loading state renders correctly
- Unit test: accepting suggestion calls setFocus
- Integration test: triage refreshes after completing item

**Estimated effort:** 30 minutes

Wired get_triage query in FocusView with TanStack Query, passes recommendation reason to SuggestionCard, invalidates triage after actions


Implement decision log infrastructure

# Decision Logging Infrastructure (RED → GREEN)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

**Implementation (GREEN):**

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

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

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

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

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

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

        Ok(())
    }
}
```

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

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

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

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


Implement QueueView container

# QueueView Container

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

**Implementation:**

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Implemented QueueView container with: filtering via CommandPalette, snoozed items hiding, batch mode entry for 2+ item sections, filter by type prop. Added snoozedUntil to FocusItem type. 20 tests passing.


Fix badge selector in Queue test

# Task: Use data-testid for Queue badge assertion

**Files:**
- `tests/e2e/app.spec.ts`
- `src/components/NavBar.tsx` (add testid)

**Problem:** Test selector is too broad:
```typescript
// OLD (failing):
test('Queue tab shows item count badge when store has data', async ({ page }) => {
  // ...seeding code...
  // 1 current + 2 queue = 3
  await expect(page.getByText('3')).toBeVisible();
  // ^ Matches multiple "3" elements on page!
});
```

**Fix:**

1. Add data-testid to badge in NavBar.tsx:
```tsx
<span
  className="badge"
  data-testid="queue-badge"
>
  {count}
</span>
```

2. Update test to use specific selector:
```typescript
test('Queue tab shows item count badge when store has data', async ({ page }) => {
  try {
    await exposeStores(page);
    await seedFocusStore(page);
  } catch {
    test.skip();
    return;
  }

  // Use specific test ID selector
  const badge = page.getByTestId('queue-badge');
  await expect(badge).toBeVisible();
  await expect(badge).toHaveText('3');
});
```

**Why data-testid:** It's the recommended pattern for E2E test selectors — semantically meaningless to users but provides precise hooks for tests.

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

**Estimated effort:** 15 minutes


Implement two-strike auto-close for GitLab state changes

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

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

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

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

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

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

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

    bridge.reconcile(&lore_items)?;

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

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

    bridge.reconcile(&lore_items)?;

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

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

    bridge.reconcile(&lore_items)?;

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

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

    bridge.reconcile(&[])?;

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

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

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

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

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

**Implementation (GREEN):**

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

        let mut to_close = vec![];

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

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

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

        Ok(())
    }
}
```

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

Done in bridge.rs - suspect_orphan two-strike


Phase 5: Batch Mode

# Batch Mode — Flow State for Similar Tasks

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

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

**Duration estimate:** 1-2 days

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

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

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

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

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

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


Implement drag-to-reorder with priority logging

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

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

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

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

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

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

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

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

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

    // Perform drag
    // ...

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

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

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

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

    // Perform drag
    // ...

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

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

    // Perform drag
    // ...

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

**Implementation (GREEN):**

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

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

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

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

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

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

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

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

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

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

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

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


Fix Inbox placeholder test for InboxView

# Task: Update Inbox test to match implemented InboxView

**File:** `tests/e2e/app.spec.ts`

**Problem:** Test expects old placeholder text that no longer exists:
```typescript
// OLD (failing):
test('shows Inbox placeholder', async ({ page }) => {
  await page.getByRole('button', { name: 'Inbox' }).click();
  await expect(page.getByText('Inbox view coming in Phase 4b')).toBeVisible();
});
```

**Fix:** Update to test actual InboxView behavior:
```typescript
test('shows Inbox view with zero state', async ({ page }) => {
  await page.getByRole('button', { name: 'Inbox' }).click();
  // InboxView shows "Inbox Zero" when no untriaged items
  await expect(page.getByText('Inbox Zero')).toBeVisible();
  await expect(page.getByText('All caught up!')).toBeVisible();
});

test('shows Inbox with items when store has data', async ({ page }) => {
  try {
    await exposeStores(page);
    // Seed inbox store with test data
    await page.evaluate(() => {
      const w = window as Record<string, unknown>;
      const inboxStore = w.__MC_INBOX_STORE__ as {
        setState: (state: Record<string, unknown>) => void;
      };
      if (inboxStore) {
        inboxStore.setState({
          items: [
            {
              id: 'test-1',
              title: 'Test inbox item',
              type: 'issue',
              project: 'test/project',
              triaged: false,
            },
          ],
        });
      }
    });
  } catch {
    test.skip();
    return;
  }

  await page.getByRole('button', { name: 'Inbox' }).click();
  await expect(page.getByText('Inbox (1)')).toBeVisible();
});
```

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

**Estimated effort:** 15 minutes


Configure Vitest for frontend testing

# Vitest Configuration for React Frontend

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

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

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

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

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


Scaffold Tauri + Vite + React project

# Project Scaffolding — Tauri 2.0 + React 19 + Vite

**Parent epic:** Phase 1: Foundation

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

**Scaffolding steps:**

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

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

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

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

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

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

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

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

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


Render SyncStatus in navigation bar

# Task: Display SyncStatus component

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

**Problem:** SyncStatus.tsx is fully implemented (shows health, last sync time, retry button) but never rendered.

**Implementation:**

1. Import SyncStatus:
```typescript
import { SyncStatus } from './SyncStatus';
```

2. Render in navigation area with test ID for E2E testing:
```tsx
<nav className="...">
  {/* Navigation tabs */}
  <div className="nav-tabs">
    {/* ... */}
  </div>

  {/* Sync status at bottom of nav */}
  <div className="mt-auto" data-testid="sync-status">
    <SyncStatus />
  </div>
</nav>
```

**Where to place it:**
- Option A: Bottom of left nav bar (persistent visibility) - RECOMMENDED
- Option B: Top right corner of main content area
- Option C: Status bar at bottom of window

**Note:** Add data-testid="sync-status" wrapper for E2E test targeting (see bd-2g5).

**Verification:**
- SyncStatus visible showing last sync time
- Click retry button triggers sync
- Health indicator reflects current state
- E2E tests can find element via data-testid

**Estimated effort:** 10 minutes

Wired SyncStatus in AppShell nav bar with lore query data, added data-testid


Implement app initialization and startup sequence

# App Initialization and Startup Sequence

**Parent epic:** Phase 1: Foundation

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

**Startup sequence:**

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

**Implementation:**

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

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

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

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

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

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

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

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

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

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

Implemented app initialization module with:
- AppConfig for consistent paths
- ensure_data_dir() called at Tauri setup
- verify_cli_dependencies() with async CLI checks
- load_with_migration() for corrupt file handling
- Startup events emitted to frontend (startup-warnings, cli-availability, startup-sync-ready)
- Integration with Tauri setup callback

Design notes: Lock is per-operation (via bridge) rather than app-lifetime for better multi-window support. Reconciliation triggered by frontend via startup-sync-ready event.


Write E2E tests for critical user flows

# E2E Tests for Critical Flows

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

**Test files to create:**

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


Implement focus actions with decision logging

# Focus Actions with Decision Logging (RED → GREEN)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    vi.useRealTimers();
  });

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

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

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

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

    vi.useRealTimers();
  });

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

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

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

**Implementation (GREEN):**

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

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

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

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

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

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

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

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

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

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

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

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


Implement event-to-bead title and key formatting

# Event-to-Bead Title and Key Formatting

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

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

**Mapping rules from plan:**

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

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

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

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

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

    let title = format_bead_title(&event);

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

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

    let key = format_mapping_key(&event);

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

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

    let title = format_bead_title(&event);

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

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

    let title = format_bead_title(&event);

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

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

    let title = format_bead_title(&event);

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

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

    let key = format_mapping_key(&event);

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

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

    let key = format_mapping_key(&event);

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

**Implementation:**

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

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

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

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

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

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

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

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

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

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

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

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

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

Implemented title truncation (60 char max with ellipsis), key escaping (/ to ::), and comprehensive tests for all event types (mr_review, issue, mr_authored). Also added InvalidInput error code.


Slice 6: Quick Wiring

# Epic: Connect Built-But-Unhooked Components

**Context:** Several components are fully implemented but never rendered or connected:
- BatchMode.tsx exists but QueueView's "Batch" buttons don't activate it
- SyncStatus.tsx exists but isn't rendered anywhere
- Settings.tsx exists but SettingsView.tsx is a placeholder

**Estimated effort:** 1 hour total

**Acceptance Criteria:**
- AC-F6.1: Clicking "Batch" on section header enters batch mode
- AC-F7.1: Sync status visible in app
- AC-F7.2: Sync status retry button triggers sync
- Settings UI renders when navigating to settings

**No external dependencies:** These are simple wiring tasks.


Implement error boundary and error handling UI

# Error Boundary and Error Handling UI

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

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

**Error types:**

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

**Implementation:**

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

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

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

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

    // Could send to error tracking service
  }

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

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

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

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

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

    return this.props.children;
  }
}

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

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

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

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

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

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

Implemented ErrorBoundary and ErrorDisplay components with comprehensive tests. ErrorBoundary catches React render errors, shows fallback UI with Try Again/Reload options. ErrorDisplay handles all McErrorCode types with contextual messages, install guides for CLI tools, and retry/dismiss actions. 16 tests pass.


Implement tmp file cleanup on startup

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

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

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

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

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

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

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

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