From 954067a38b8b3cfcd91cd50a2101f87122070727 Mon Sep 17 00:00:00 2001 From: teernisse Date: Wed, 25 Feb 2026 10:40:48 -0500 Subject: [PATCH] docs: initial Mission Control planning documents - PLAN.md: Complete implementation plan with architecture, ACs, phases - CLAUDE.md: Project context for AI agents Architecture: Tauri + React, beads as universal work graph, manual-first priority with rich decision logging. --- .beads/.gitignore | 11 + .beads/config.yaml | 4 + .beads/issues.jsonl | 66 ++ .beads/metadata.json | 4 + .gitignore | 2 + CLAUDE.md | 63 ++ PLAN.md | 1441 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1591 insertions(+) create mode 100644 .beads/.gitignore create mode 100644 .beads/config.yaml create mode 100644 .beads/issues.jsonl create mode 100644 .beads/metadata.json create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 PLAN.md diff --git a/.beads/.gitignore b/.beads/.gitignore new file mode 100644 index 0000000..f32e807 --- /dev/null +++ b/.beads/.gitignore @@ -0,0 +1,11 @@ +# Database +*.db +*.db-shm +*.db-wal + +# Lock files +*.lock + +# Temporary +last-touched +*.tmp diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 0000000..4e54a22 --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1,4 @@ +# Beads Project Configuration +# issue_prefix: bd +# default_priority: 2 +# default_type: task diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl new file mode 100644 index 0000000..a034faf --- /dev/null +++ b/.beads/issues.jsonl @@ -0,0 +1,66 @@ +{"id":"bd-11k","title":"Configure Playwright for E2E testing","description":"# Playwright E2E Test Configuration\n\n**Parent epic:** Phase 0: Test Infrastructure\n\n**Why Playwright:**\n- Cross-platform desktop app testing\n- Works with Tauri apps via WebDriver/CDP\n- Excellent debugging tools (trace viewer, screenshots)\n- Reliable waiting and assertion APIs\n\n**What to configure:**\n1. Install @playwright/test\n2. Create playwright.config.ts:\n - Base URL: http://localhost:1420 (Tauri dev server)\n - Test directory: tests/e2e/\n - Retry configuration for flaky tests\n - Screenshot/video on failure\n - Timeout settings appropriate for native app\n3. Configure Tauri test mode:\n - Environment variable MC_TEST_MODE=true\n - Mock CLI paths via environment\n4. Add npm scripts:\n - \"test:e2e\": \"playwright test\"\n - \"test:e2e:headed\": \"playwright test --headed\"\n - \"test:e2e:debug\": \"playwright test --debug\"\n\n**E2E test patterns (to be written later):**\n- focus-flow.spec.ts: Focus view interactions\n- batch-mode.spec.ts: Batch mode workflow\n- quick-capture.spec.ts: Global hotkey capture\n\n**Acceptance criteria:**\n- `npm run test:e2e` launches Playwright runner\n- Tests can interact with Tauri webview content\n- Screenshots capture on failure\n- Tests can detect browser popups (for \"Open in GitLab\" flows)","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:25:03.931985Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:25:36.216611Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-11k","depends_on_id":"bd-jyz","type":"blocks","created_at":"2026-02-25T20:25:36.216595Z","created_by":"tayloreernisse"}]} +{"id":"bd-14u","title":"Global hotkey to toggle window (⌘⇧M)","description":"# Global Hotkey Registration\n\n**Parent epic:** Phase 1: Foundation\n\n**UX principle:** Mission Control should be instantly accessible from anywhere. ⌘⇧M (Cmd+Shift+M) brings it up or hides it.\n\n**Implementation:**\n\n1. **Tauri global shortcut plugin:**\n ```bash\n cargo add tauri-plugin-global-shortcut\n ```\n\n2. **Register hotkey (src-tauri/src/shortcuts.rs):**\n ```rust\n use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut};\n\n pub fn setup_shortcuts(app: &tauri::App) -> Result<()> {\n let toggle_shortcut = Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyM);\n \n app.global_shortcut().on_shortcut(toggle_shortcut, |app, _| {\n if let Some(window) = app.get_webview_window(\"main\") {\n if window.is_visible().unwrap_or(false) {\n window.hide().ok();\n } else {\n window.show().ok();\n window.set_focus().ok();\n }\n }\n })?;\n \n Ok(())\n }\n ```\n\n3. **Window behavior on toggle:**\n - If hidden: Show and focus\n - If visible but not focused: Focus\n - If visible and focused: Hide\n\n4. **Settings integration (future):**\n - Allow user to customize hotkey\n - Store in settings.json\n - Re-register on change\n\n**Platform considerations:**\n- macOS: ⌘⇧M (Command+Shift+M)\n- Future Windows/Linux: Ctrl+Shift+M\n\n**Acceptance criteria:**\n- Pressing ⌘⇧M from any app brings up Mission Control\n- Pressing again hides it\n- Hotkey works even when MC is in background\n- No conflict with common system shortcuts","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:26:22.248417Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:27:09.153870Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-14u","depends_on_id":"bd-3fd","type":"blocks","created_at":"2026-02-25T20:27:09.153855Z","created_by":"tayloreernisse"},{"issue_id":"bd-14u","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:27:07.931978Z","created_by":"tayloreernisse"}]} +{"id":"bd-1cu","title":"Implement FocusView container with focus selection","description":"# FocusView Container\n\n**Parent epic:** Phase 3: Focus View\n\n**Purpose:** Container component that manages focus state and connects FocusCard to the store.\n\n**Behavior:**\n1. If user has set a focus → show FocusCard with that item\n2. If no focus set → show suggestion from queue or prompt to pick\n3. After completing/skipping → auto-select next item or show empty state\n\n**TDD: FocusView tests (RED first):**\n\n```typescript\n// tests/views/FocusView.test.tsx\ndescribe('FocusView', () => {\n it('shows FocusCard when focus is set', () => {\n const store = createStore({ focusId: '1', items: [mockItem] });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByTestId('focus-card')).toBeInTheDocument();\n expect(screen.getByText(mockItem.title)).toBeInTheDocument();\n });\n \n it('shows empty state when no focus and no items', () => {\n const store = createStore({ focusId: null, items: [] });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByText(/Nothing to focus on/)).toBeInTheDocument();\n expect(screen.getByText(/Create a task/)).toBeInTheDocument();\n });\n \n it('shows suggestion when no focus but items exist', () => {\n const store = createStore({ focusId: null, items: [mockItem] });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByText(/Suggested/)).toBeInTheDocument();\n expect(screen.getByText(mockItem.title)).toBeInTheDocument();\n expect(screen.getByRole('button', { name: /Set as focus/i })).toBeInTheDocument();\n });\n \n it('auto-advances to next item after complete', async () => {\n const store = createStore({ focusId: '1', items: [item1, item2] });\n render(, { wrapper: StoreProvider(store) });\n \n // Complete current focus\n await userEvent.click(screen.getByRole('button', { name: /done/i }));\n \n // Should show next item as suggestion\n expect(screen.getByText(item2.title)).toBeInTheDocument();\n });\n \n it('shows celebration on last item complete', async () => {\n const store = createStore({ focusId: '1', items: [item1] });\n render(, { wrapper: StoreProvider(store) });\n \n await userEvent.click(screen.getByRole('button', { name: /done/i }));\n \n expect(screen.getByText(/All caught up/)).toBeInTheDocument();\n });\n});\n```\n\n**Implementation:**\n\n```tsx\n// src/views/FocusView.tsx\nexport function FocusView() {\n const { focusId, items, setFocus, clearFocus } = useStore();\n const { start, defer, skip, complete } = useActions();\n const [showReasonPrompt, setShowReasonPrompt] = useState(false);\n const [pendingAction, setPendingAction] = useState(null);\n \n const focusItem = items.find(i => i.id === focusId);\n const suggestion = !focusId && items.length > 0 ? items[0] : null;\n \n // Empty state\n if (!focusItem && !suggestion) {\n return (\n
\n \n

All caught up!

\n

Nothing to focus on right now.

\n \n
\n );\n }\n \n // Suggestion state\n if (suggestion && !focusItem) {\n return (\n
\n

Suggested next

\n {\n setFocus(suggestion.id);\n start(suggestion);\n }}\n onDefer={(d) => defer(suggestion, d)}\n onSkip={() => skip(suggestion)}\n />\n \n
\n );\n }\n \n // Focus state\n return (\n
\n handleAction('start', focusItem!)}\n onDefer={(d) => handleAction('defer', focusItem!, d)}\n onSkip={() => handleAction('skip', focusItem!)}\n />\n \n {showReasonPrompt && (\n setShowReasonPrompt(false)}\n />\n )}\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- Focus item displays when set\n- Suggestion shows when no focus\n- Empty state for no items\n- Actions trigger reason prompt\n- Auto-advance after completion","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:51:34.206657Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:53:41.967339Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1cu","depends_on_id":"bd-1ft","type":"blocks","created_at":"2026-02-25T20:53:41.910231Z","created_by":"tayloreernisse"},{"issue_id":"bd-1cu","depends_on_id":"bd-1kr","type":"blocks","created_at":"2026-02-25T20:53:41.939269Z","created_by":"tayloreernisse"},{"issue_id":"bd-1cu","depends_on_id":"bd-3k4","type":"blocks","created_at":"2026-02-25T20:53:41.967323Z","created_by":"tayloreernisse"}]} +{"id":"bd-1ds","title":"Implement Framer Motion animations","description":"# Framer Motion Animations\n\n**Parent epic:** Phase 7: Polish + E2E Tests\n\n**Animation targets:**\n| Component | Animation |\n|-----------|-----------|\n| FocusCard | Scale-in on mount, smooth transitions |\n| QueueItem | Drag preview, reorder animation |\n| Popover | Slide-in from tray |\n| QuickCapture | Scale + fade overlay |\n| BatchMode | Progress bar, celebration confetti |\n| ReasonPrompt | Dialog slide-up |\n\n**Implementation examples:**\n\n```tsx\n// FocusCard entrance\n\n \n\n\n// Queue item reorder with layout animation\n\n \n\n\n// Batch progress\n\n```\n\n**Celebration confetti:**\n```tsx\nimport Confetti from 'react-confetti';\n\nfunction BatchCelebration({ completed, total, onClose }) {\n return (\n
\n \n \n

All done!

\n

{completed}/{total} completed

\n \n \n
\n );\n}\n```\n\n**ADHD-friendly principles:**\n- Subtle, quick animations (150-300ms)\n- No jarring movements\n- Clear visual feedback for actions\n- Celebratory moments for completion\n\n**Acceptance criteria:**\n- Smooth 60fps animations\n- No layout shifts or jank\n- Animations disabled for reduced-motion preference\n- Celebration feels rewarding","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:35:17.741067Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:35:53.980751Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1ds","depends_on_id":"bd-25l","type":"blocks","created_at":"2026-02-25T20:35:53.980731Z","created_by":"tayloreernisse"}]} +{"id":"bd-1ev","title":"Implement lore CLI wrapper with JSON parsing","description":"# Lore CLI Wrapper (RED → GREEN)\n\n**Parent epic:** Phase 1: Foundation\n\n**Why CLI wrapper:**\nMC integrates with lore via `lore --robot` CLI, not as a library. This provides:\n- Clean API boundary (schema changes are isolated)\n- No Rust dependency on lore internals\n- Battle-tested JSON output format\n\n**TDD approach:**\n\n1. **RED: Write parsing tests first:**\n ```rust\n // src-tauri/tests/lore_parsing_test.rs\n #[test]\n fn parse_lore_me_with_reviews() {\n let fixture = include_str!(\"fixtures/lore/me_with_reviews.json\");\n let result: LoreMeResponse = serde_json::from_str(fixture).unwrap();\n \n assert_eq!(result.issues.len(), 0);\n assert_eq!(result.mrs.authored.len(), 0);\n assert_eq!(result.mrs.reviewing.len(), 3);\n }\n \n #[test]\n fn parse_lore_me_empty() {\n let fixture = include_str!(\"fixtures/lore/me_empty.json\");\n let result: LoreMeResponse = serde_json::from_str(fixture).unwrap();\n \n assert!(result.since_last_check.is_empty());\n }\n ```\n\n2. **GREEN: Implement types and wrapper:**\n ```rust\n // src-tauri/src/data/lore.rs\n #[derive(Deserialize, Debug)]\n #[serde(deny_unknown_fields)] // Catch schema drift!\n pub struct LoreMeResponse {\n pub ok: bool,\n pub data: LoreMeData,\n pub meta: Option,\n }\n \n #[derive(Deserialize, Debug)]\n pub struct LoreMeData {\n pub issues: Vec,\n pub mrs: LoreMrData,\n pub since_last_check: Vec,\n }\n \n pub struct RealLoreCli;\n impl LoreCli for RealLoreCli {\n fn me(&self) -> Result {\n let output = Command::new(\"lore\")\n .args([\"--robot\", \"me\"])\n .output()?;\n \n if !output.status.success() {\n return Err(anyhow!(\"lore failed: {}\", String::from_utf8_lossy(&output.stderr)));\n }\n \n serde_json::from_slice(&output.stdout)\n .context(\"Failed to parse lore output\")\n }\n }\n ```\n\n3. **Contract test:**\n ```rust\n #[test]\n fn lore_response_schema_unchanged() {\n // This test fails if lore's output format changes\n let fixture = include_str!(\"fixtures/lore/me_with_reviews.json\");\n let _: LoreMeResponse = serde_json::from_str(fixture)\n .expect(\"Fixture should match current schema\");\n }\n ```\n\n**Types to define:**\n- LoreMeResponse (top-level)\n- LoreIssue (assigned issues)\n- LoreMr (MRs with authored/reviewing)\n- LoreEvent (since_last_check events)\n- LoreMeta (optional metadata)\n\n**Acceptance criteria:**\n- All fixture files parse successfully\n- Types have #[serde(deny_unknown_fields)] for drift detection\n- RealLoreCli shells out to actual lore command\n- MockLoreCli returns fixture data for tests\n- Error messages are actionable (not just \"parse failed\")","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:26:35.610613Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:27:09.172784Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1ev","depends_on_id":"bd-3fd","type":"blocks","created_at":"2026-02-25T20:27:09.172767Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ev","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:27:07.950793Z","created_by":"tayloreernisse"}]} +{"id":"bd-1ft","title":"Phase 3: Focus View","description":"# Focus View — THE ONE THING\n\n**Context:** The Focus View is the default view and the heart of Mission Control's UX. It shows THE single most important thing you should be doing right now.\n\n**UX principle:** This is NOT a list. It's a single, prominent card that demands attention.\n\n**Duration estimate:** 1-2 days\n\n**Scope includes:**\n1. FocusCard component with prominent display\n2. Action buttons: Start, Defer (1h, tomorrow, custom), Skip\n3. Keyboard shortcuts: Enter=Start, ⌘1=1hr, ⌘2=tomorrow, ⌘S=Skip\n4. Decision logging with reason capture\n5. Quick tags for common reasons\n\n**Visual design:**\n```\n┌─────────────────────────────────────────────────────────────┐\n│ │\n│ ┌───────────────────┐ │\n│ │ 🔴 MR REVIEW │ │\n│ └───────────────────┘ │\n│ │\n│ Fix authentication token refresh logic │\n│ ───────────────────────────────────── │\n│ │\n│ !847 in platform/core • 47 lines changed │\n│ │\n│ ┌─────────────────────────────────────────────────┐ │\n│ │ @sarah requested 2 days ago │ │\n│ │ \"Can you take a look? I need this for the │ │\n│ │ release tomorrow\" │ │\n│ └─────────────────────────────────────────────────┘ │\n│ │\n│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────┐ │\n│ │ Start │ │ 1 hour │ │ Tomorrow │ │ Skip │ │\n│ │ ↵ │ │ ⌘1 │ │ ⌘2 │ │ ⌘S │ │\n│ └──────────┘ └──────────┘ └──────────┘ └───────┘ │\n│ │\n├─────────────────────────────────────────────────────────────┤\n│ Queue: 3 more reviews • 2 assigned issues • 5 mentions │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**Behavior:**\n- \"Start\" opens GitLab in browser\n- Defer options: 1 hour, tomorrow, custom\n- Skip removes from today's list (logged with reason)\n\n**Dependencies:**\n- Requires Phase 2 (Bridge) for data\n- Blocks Phase 5 (Batch Mode) which uses similar card UI\n\n**Acceptance criteria:**\n- Focus card renders current focus item prominently\n- All actions (Start/Defer/Skip) work with logging\n- Keyboard shortcuts function\n- Reason capture prompts on significant actions","status":"open","priority":2,"issue_type":"epic","created_at":"2026-02-25T20:30:41.475929Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:30:44.545028Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1ft","depends_on_id":"bd-2us","type":"blocks","created_at":"2026-02-25T20:30:44.544996Z","created_by":"tayloreernisse"}]} +{"id":"bd-1fy","title":"Implement TanStack Query data fetching layer","description":"# TanStack Query Data Fetching\n\n**Parent epic:** Phase 1: Foundation\n\n**Purpose:** Handle async data fetching, caching, and invalidation for lore data and bead operations.\n\n**Query structure:**\n\n```typescript\n// Work items from lore\nuseQuery({ queryKey: ['work-items'], queryFn: fetchWorkItems })\n\n// Bead details (on demand)\nuseQuery({ queryKey: ['bead', id], queryFn: () => fetchBead(id) })\n\n// BV triage recommendations\nuseQuery({ queryKey: ['triage'], queryFn: fetchTriage, staleTime: 5 * 60 * 1000 })\n```\n\n**TDD: Query tests (RED first):**\n\n```typescript\n// tests/hooks/useWorkItems.test.ts\ndescribe('useWorkItems', () => {\n it('fetches work items from lore', async () => {\n const mockItems = [{ id: '1', title: 'Test' }];\n vi.mocked(invoke).mockResolvedValue(mockItems);\n \n const { result } = renderHook(() => useWorkItems(), {\n wrapper: QueryClientProvider\n });\n \n await waitFor(() => {\n expect(result.current.data).toEqual(mockItems);\n });\n });\n \n it('shows loading state initially', () => {\n const { result } = renderHook(() => useWorkItems(), {\n wrapper: QueryClientProvider\n });\n \n expect(result.current.isLoading).toBe(true);\n });\n \n it('handles error gracefully', async () => {\n vi.mocked(invoke).mockRejectedValue(new Error('lore failed'));\n \n const { result } = renderHook(() => useWorkItems(), {\n wrapper: QueryClientProvider\n });\n \n await waitFor(() => {\n expect(result.current.error).toBeDefined();\n expect(result.current.error.message).toBe('lore failed');\n });\n });\n \n it('refetches on lore-db-changed event', async () => {\n const { result } = renderHook(() => useWorkItems(), {\n wrapper: QueryClientProvider\n });\n \n await waitFor(() => expect(result.current.isSuccess).toBe(true));\n \n // Emit event\n await emitEvent('lore-db-changed');\n \n // Should have refetched\n expect(invoke).toHaveBeenCalledTimes(2);\n });\n \n it('merges manual items with lore items', async () => {\n const loreItems = [{ id: '1', source: 'gitlab' }];\n const manualItems = [{ id: '2', source: 'manual' }];\n vi.mocked(invoke).mockImplementation((cmd) => {\n if (cmd === 'get_lore_items') return Promise.resolve(loreItems);\n if (cmd === 'get_manual_items') return Promise.resolve(manualItems);\n });\n \n const { result } = renderHook(() => useWorkItems(), {\n wrapper: QueryClientProvider\n });\n \n await waitFor(() => {\n expect(result.current.data).toHaveLength(2);\n });\n });\n});\n```\n\n**Implementation:**\n\n```typescript\n// src/hooks/useWorkItems.ts\nexport function useWorkItems() {\n const queryClient = useQueryClient();\n \n // Listen for lore.db changes\n useEffect(() => {\n const unlisten = listen('lore-db-changed', () => {\n queryClient.invalidateQueries({ queryKey: ['work-items'] });\n });\n return () => { unlisten.then(fn => fn()); };\n }, [queryClient]);\n \n return useQuery({\n queryKey: ['work-items'],\n queryFn: async () => {\n const [loreItems, manualItems] = await Promise.all([\n invoke('get_lore_items'),\n invoke('get_manual_items'),\n ]);\n return [...loreItems, ...manualItems];\n },\n staleTime: 30 * 1000, // Consider fresh for 30s\n });\n}\n\n// src/hooks/useTriage.ts\nexport function useTriage() {\n return useQuery({\n queryKey: ['triage'],\n queryFn: () => invoke('get_triage'),\n staleTime: 5 * 60 * 1000, // BV recommendations cache 5min\n enabled: true,\n });\n}\n\n// src/lib/queryClient.ts\nexport const queryClient = new QueryClient({\n defaultOptions: {\n queries: {\n retry: 1,\n refetchOnWindowFocus: true,\n },\n },\n});\n```\n\n**Mutations for actions:**\n\n```typescript\n// src/hooks/useActions.ts\nexport function useCompleteBead() {\n const queryClient = useQueryClient();\n \n return useMutation({\n mutationFn: (id: string) => invoke('complete_bead', { id }),\n onSuccess: () => {\n queryClient.invalidateQueries({ queryKey: ['work-items'] });\n },\n });\n}\n```\n\n**Acceptance criteria:**\n- Work items fetch and cache correctly\n- Loading/error states handled\n- Invalidation on lore.db change\n- Mutations update cache\n- Stale time appropriate for each query","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:53:12.495717Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:12:04.120577Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1fy","depends_on_id":"bd-3fd","type":"blocks","created_at":"2026-02-25T20:53:42.298997Z","created_by":"tayloreernisse"},{"issue_id":"bd-1fy","depends_on_id":"bd-bap","type":"blocks","created_at":"2026-02-25T21:12:04.120558Z","created_by":"tayloreernisse"},{"issue_id":"bd-1fy","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:53:42.269741Z","created_by":"tayloreernisse"}]} +{"id":"bd-1g0","title":"Implement click-to-focus in Queue view","description":"# Click-to-Focus in Queue View\n\n**Parent epic:** Phase 4: Queue + Inbox Views\n\n**Purpose:** Clicking an item in the Queue should set it as THE ONE THING and navigate to Focus View.\n\n**TDD: Click-to-focus tests (RED first):**\n\n```typescript\n// tests/components/QueueList.test.tsx\ndescribe('QueueList click-to-focus', () => {\n it('calls setFocus when item clicked', async () => {\n const setFocus = vi.fn();\n render();\n \n await userEvent.click(screen.getByText('Review MR !847'));\n \n expect(setFocus).toHaveBeenCalledWith('1');\n });\n \n it('navigates to focus view after setting focus', async () => {\n const setActiveView = vi.fn();\n const store = createStore({ setActiveView });\n render(, { wrapper: StoreProvider(store) });\n \n await userEvent.click(screen.getByText('Review MR !847'));\n \n expect(setActiveView).toHaveBeenCalledWith('focus');\n });\n \n it('prompts for reason when setting focus', async () => {\n render();\n \n await userEvent.click(screen.getByText('Review MR !847'));\n \n // ReasonPrompt should appear\n expect(screen.getByText(/Setting focus to/)).toBeInTheDocument();\n });\n \n it('logs decision with context', async () => {\n const logDecision = vi.fn();\n render();\n \n await userEvent.click(screen.getByText('Review MR !847'));\n await userEvent.click(screen.getByRole('button', { name: /confirm/i }));\n \n expect(logDecision).toHaveBeenCalledWith(expect.objectContaining({\n action: 'set_focus',\n bead_id: '1',\n context: expect.objectContaining({\n available_items: expect.any(Array),\n queue_size: mockItems.length,\n })\n }));\n });\n});\n```\n\n**Implementation update for QueueItem:**\n\n```tsx\n// In QueueList.tsx\nfunction QueueItem({ \n item, \n onSetFocus \n}: { \n item: WorkItem; \n onSetFocus: (id: string) => void;\n}) {\n const [showReasonPrompt, setShowReasonPrompt] = useState(false);\n const { logDecision, setActiveView } = useStore();\n \n const handleClick = () => {\n setShowReasonPrompt(true);\n };\n \n const handleConfirm = ({ reason, tags }) => {\n logDecision({\n action: 'set_focus',\n bead_id: item.id,\n reason,\n tags,\n context: captureContext(),\n });\n onSetFocus(item.id);\n setActiveView('focus');\n setShowReasonPrompt(false);\n };\n \n return (\n <>\n
\n \n {item.title}\n {formatAge(item.createdAt)}\n
\n \n {showReasonPrompt && (\n setShowReasonPrompt(false)}\n />\n )}\n \n );\n}\n```\n\n**Acceptance criteria:**\n- Click on queue item triggers focus flow\n- Reason prompt appears for decision logging\n- Focus is set in store\n- Navigation to Focus View happens\n- Decision logged with full context","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:52:10.692145Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:53:42.118111Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1g0","depends_on_id":"bd-2cl","type":"blocks","created_at":"2026-02-25T20:53:42.087948Z","created_by":"tayloreernisse"},{"issue_id":"bd-1g0","depends_on_id":"bd-2zu","type":"blocks","created_at":"2026-02-25T20:53:42.118092Z","created_by":"tayloreernisse"},{"issue_id":"bd-1g0","depends_on_id":"bd-716","type":"blocks","created_at":"2026-02-25T20:53:42.057835Z","created_by":"tayloreernisse"}]} +{"id":"bd-1ie","title":"Implement staleness color visualization","description":"# Staleness Color Visualization (RED → GREEN)\n\n**Parent epic:** Phase 7: Polish + E2E Tests\n\n**Color scheme:**\n| Age | Color | CSS Class | Visual |\n|-----|-------|-----------|--------|\n| < 24h | Green | text-green-500 | Fresh, bright |\n| 1-2 days | Default | text-foreground | Normal |\n| 3-6 days | Amber | text-amber-500 | Warning |\n| 7+ days | Red + pulse | text-red-500 animate-pulse | Urgent |\n\n**TDD: Staleness tests (RED first):**\n\n```typescript\n// tests/lib/staleness.test.ts\ndescribe('getStaleness', () => {\n it('returns fresh for < 24h', () => {\n const date = new Date(Date.now() - 12 * 60 * 60 * 1000); // 12h ago\n expect(getStaleness(date)).toBe('fresh');\n });\n \n it('returns normal for 1-2 days', () => {\n const date = new Date(Date.now() - 36 * 60 * 60 * 1000); // 36h ago\n expect(getStaleness(date)).toBe('normal');\n });\n \n it('returns stale for 3-6 days', () => {\n const date = new Date(Date.now() - 4 * 24 * 60 * 60 * 1000); // 4d ago\n expect(getStaleness(date)).toBe('stale');\n });\n \n it('returns urgent for 7+ days', () => {\n const date = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); // 10d ago\n expect(getStaleness(date)).toBe('urgent');\n });\n});\n\n// tests/components/StalenessIndicator.test.tsx\ndescribe('StalenessIndicator', () => {\n it('shows green dot for fresh', () => {\n render();\n expect(screen.getByTestId('staleness-indicator')).toHaveClass('bg-green-500');\n });\n \n it('shows pulsing red for urgent', () => {\n render();\n const indicator = screen.getByTestId('staleness-indicator');\n expect(indicator).toHaveClass('bg-red-500', 'animate-pulse');\n });\n});\n```\n\n**Implementation (GREEN):**\n\n```typescript\n// src/lib/staleness.ts\nexport type Staleness = 'fresh' | 'normal' | 'stale' | 'urgent';\n\nexport function getStaleness(date: Date): Staleness {\n const hoursAgo = (Date.now() - date.getTime()) / (1000 * 60 * 60);\n \n if (hoursAgo < 24) return 'fresh';\n if (hoursAgo < 48) return 'normal';\n if (hoursAgo < 168) return 'stale'; // 7 days\n return 'urgent';\n}\n\nexport function getStalenessClasses(staleness: Staleness): string {\n switch (staleness) {\n case 'fresh': return 'text-green-500';\n case 'normal': return 'text-foreground';\n case 'stale': return 'text-amber-500';\n case 'urgent': return 'text-red-500 animate-pulse';\n }\n}\n\n// src/components/StalenessIndicator.tsx\nexport function StalenessIndicator({ staleness }: { staleness: Staleness }) {\n const classes = {\n fresh: 'bg-green-500',\n normal: 'bg-gray-400',\n stale: 'bg-amber-500',\n urgent: 'bg-red-500 animate-pulse',\n };\n \n return (\n \n );\n}\n```\n\n**Acceptance criteria:**\n- All staleness tests pass\n- Colors visible in both light/dark themes\n- Pulse animation smooth\n- Accessible (aria-label for screen readers)","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:35:30.042221Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:35:54.017783Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1ie","depends_on_id":"bd-25l","type":"blocks","created_at":"2026-02-25T20:35:54.017764Z","created_by":"tayloreernisse"}]} +{"id":"bd-1jf","title":"Implement schema migration utilities for state files","description":"Implement schema versioning and migration utilities for MC's local JSON state files. PLAN.md specifies: 'Each JSON file includes a schema_version field. On load, migrate if version < current.'\n\nBACKGROUND:\n- MC stores state in JSON files: gitlab_bead_map.json, state.json, settings.json, decision_log.jsonl\n- Schema will evolve as features are added\n- Need forward migration without data loss\n- Need graceful handling of future versions (warn but don't crash)\n\nIMPLEMENTATION (Rust):\n- Define schema version constants per file type\n- Migration trait: fn migrate(from_version: u32, data: Value) -> Result\n- Register migration functions: v1→v2, v2→v3, etc.\n- On load: check version, run migrations in sequence, save updated file\n- Handle unknown version: log warning, continue with best effort\n\nSCHEMA VERSION TRACKING:\n- gitlab_bead_map.json: schema_version: 1\n- state.json: schema_version: 1 \n- settings.json: schema_version: 1\n- decision_log.jsonl: no versioning (append-only, entries self-describe)\n\nTESTING (TDD):\n- Test loading v1 file with v1 code (no migration)\n- Test loading v1 file with v2 code (migration runs)\n- Test migration chain v1→v2→v3\n- Test unknown version handling\n- Test migration failure rollback\n\nFILE LOCATION:\nsrc-tauri/src/data/migration.rs\n\nINVARIANT:\n- Migrations must be idempotent (re-running doesn't corrupt)","status":"open","priority":1,"issue_type":"feature","created_at":"2026-02-25T21:11:43.872721Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:12:01.675627Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1jf","depends_on_id":"bd-1jy","type":"blocks","created_at":"2026-02-25T21:12:01.675611Z","created_by":"tayloreernisse"}]} +{"id":"bd-1jy","title":"Implement mapping file with atomic writes and schema versioning","description":"# Mapping File Management (RED → GREEN)\n\n**Parent epic:** Phase 2: Bridge + Data Layer\n\n**File location:** ~/.local/share/mc/gitlab_bead_map.json\n\n**Schema:**\n```json\n{\n \"schema_version\": 1,\n \"cursor\": {\n \"last_check_timestamp\": \"2026-02-25T10:30:00Z\",\n \"last_reconciliation\": \"2026-02-25T06:00:00Z\"\n },\n \"mappings\": {\n \"mr_review:gitlab.com:12345:847\": {\n \"bead_id\": \"br-x7f\",\n \"created_at\": \"2026-02-23T14:00:00Z\",\n \"suspect_orphan\": false,\n \"pending\": false\n }\n }\n}\n```\n\n**TDD: File operations tests (RED first):**\n\n```rust\n#[test]\nfn atomic_write_survives_crash() {\n let temp = tempfile::tempdir().unwrap();\n let path = temp.path().join(\"map.json\");\n \n // Write initial state\n write_mapping_atomic(&path, &mapping_v1)?;\n \n // Simulate crash during write (leave .tmp file)\n std::fs::write(path.with_extension(\"json.tmp\"), \"corrupt\")?;\n \n // Recovery should use last good state\n let recovered = load_mapping(&path)?;\n assert_eq!(recovered, mapping_v1);\n}\n\n#[test]\nfn schema_migration_v1_to_v2() {\n let v1_json = r#\"{\"schema_version\": 1, ...}\"#;\n let temp = tempfile::tempdir().unwrap();\n let path = temp.path().join(\"map.json\");\n std::fs::write(&path, v1_json)?;\n \n let mapping = load_mapping(&path)?;\n \n // Should auto-migrate to v2\n assert_eq!(mapping.schema_version, 2);\n}\n\n#[test]\nfn corrupted_file_loads_backup() {\n let temp = tempfile::tempdir().unwrap();\n let path = temp.path().join(\"map.json\");\n let backup = path.with_extension(\"json.bak\");\n \n // Write valid backup\n write_mapping_atomic(&path, &valid_mapping)?;\n std::fs::copy(&path, &backup)?;\n \n // Corrupt main file\n std::fs::write(&path, \"not json\")?;\n \n // Should fall back to backup\n let recovered = load_mapping(&path)?;\n assert_eq!(recovered, valid_mapping);\n}\n```\n\n**Implementation (GREEN):**\n\n```rust\npub fn write_mapping_atomic(path: &Path, mapping: &Mapping) -> Result<()> {\n // 1. Write backup of current file\n if path.exists() {\n let backup = path.with_extension(\"json.bak\");\n std::fs::copy(path, &backup)?;\n }\n \n // 2. Write to temp file\n let tmp = path.with_extension(\"json.tmp\");\n let file = std::fs::File::create(&tmp)?;\n serde_json::to_writer_pretty(file, mapping)?;\n \n // 3. Atomic rename (POSIX guarantees atomicity)\n std::fs::rename(&tmp, path)?;\n \n Ok(())\n}\n\npub fn load_mapping(path: &Path) -> Result {\n // 1. Check for interrupted write (.tmp exists)\n let tmp = path.with_extension(\"json.tmp\");\n if tmp.exists() {\n std::fs::remove_file(&tmp)?; // Discard incomplete write\n }\n \n // 2. Try loading main file\n match std::fs::read_to_string(path) {\n Ok(json) => {\n let mapping: Mapping = serde_json::from_str(&json)\n .or_else(|_| load_backup(path))?;\n migrate_if_needed(mapping)\n }\n Err(_) if path.exists() => load_backup(path),\n Err(_) => Ok(Mapping::default()),\n }\n}\n```\n\n**Key format rationale:**\nWe use numeric `project_id` instead of project path because paths can change (renames, transfers). Numeric IDs are immutable.\n\n**Acceptance criteria:**\n- Atomic writes prevent corruption\n- Crash recovery finds last good state\n- Schema migrations preserve data\n- Backup file exists after every write","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:28:01.851095Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:30:20.637611Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1jy","depends_on_id":"bd-2us","type":"blocks","created_at":"2026-02-25T20:30:20.637591Z","created_by":"tayloreernisse"}]} +{"id":"bd-1kr","title":"Implement FocusCard component","description":"# FocusCard Component (RED → GREEN)\n\n**Parent epic:** Phase 3: Focus View\n\n**Purpose:** The FocusCard is the single most important UI element in Mission Control. It displays THE ONE THING prominently with all relevant context.\n\n**TDD: Component tests (RED first):**\n\n```typescript\n// tests/components/FocusCard.test.tsx\ndescribe('FocusCard', () => {\n const mockItem: WorkItem = {\n id: 'br-x7f',\n title: 'Fix authentication token refresh logic',\n type: 'mr_review',\n source: 'gitlab',\n metadata: {\n iid: 847,\n project: 'platform/core',\n author: '@sarah',\n linesChanged: 47,\n message: \"Can you take a look? I need this for the release tomorrow\"\n },\n createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), // 2 days ago\n };\n \n it('renders item title prominently', () => {\n render();\n \n expect(screen.getByRole('heading')).toHaveTextContent('Fix authentication token refresh logic');\n });\n \n it('shows item type badge', () => {\n render();\n \n expect(screen.getByTestId('type-badge')).toHaveTextContent('MR REVIEW');\n });\n \n it('displays metadata context', () => {\n render();\n \n expect(screen.getByText('!847 in platform/core')).toBeInTheDocument();\n expect(screen.getByText('47 lines changed')).toBeInTheDocument();\n expect(screen.getByText('@sarah requested 2 days ago')).toBeInTheDocument();\n });\n \n it('calls onStart when Start button clicked', async () => {\n const onStart = vi.fn();\n render();\n \n await userEvent.click(screen.getByRole('button', { name: /start/i }));\n \n expect(onStart).toHaveBeenCalledWith(mockItem.id);\n });\n \n it('shows staleness indicator based on age', () => {\n const oldItem = { ...mockItem, createdAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000) };\n render();\n \n expect(screen.getByTestId('staleness-indicator')).toHaveClass('bg-amber-500');\n });\n \n it('shows red pulsing for very stale items', () => {\n const veryOldItem = { ...mockItem, createdAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000) };\n render();\n \n expect(screen.getByTestId('staleness-indicator')).toHaveClass('bg-red-500', 'animate-pulse');\n });\n});\n```\n\n**Implementation (GREEN):**\n\n```tsx\n// src/components/FocusCard.tsx\ninterface FocusCardProps {\n item: WorkItem;\n onStart: (id: string) => void;\n onDefer: (id: string, duration: Duration) => void;\n onSkip: (id: string) => void;\n}\n\nexport function FocusCard({ item, onStart, onDefer, onSkip }: FocusCardProps) {\n const staleness = getStaleness(item.createdAt);\n \n return (\n \n \n \n {item.title}\n \n \n \n \n \n {item.metadata.message && (\n
\n {item.metadata.message}\n
\n )}\n
\n \n \n \n \n \n \n \n
\n );\n}\n```\n\n**Staleness calculation:**\n- Fresh (< 24h): green/bright\n- Aging (1-2 days): normal\n- Stale (3-6 days): amber\n- Very stale (7+ days): red pulsing\n\n**Acceptance criteria:**\n- All tests pass\n- Card renders all item information\n- Staleness colors work correctly\n- Action callbacks fire on button clicks","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:31:01.369593Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:32:00.786976Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1kr","depends_on_id":"bd-1ft","type":"blocks","created_at":"2026-02-25T20:32:00.786958Z","created_by":"tayloreernisse"}]} +{"id":"bd-1ps","title":"Create fixture directory structure and capture CLI outputs","description":"# CLI Fixture Directory and Initial Captures\n\n**Parent epic:** Phase 0: Test Infrastructure\n\n**Why fixtures:**\nMC's correctness depends on correctly parsing lore and br CLI outputs.\nReal outputs change rarely but MUST match our parsing.\nFixtures serve two purposes:\n1. Test data for unit/integration tests\n2. Contract tests to detect CLI schema drift\n\n**Directory structure:**\n\n```\nsrc-tauri/tests/fixtures/\n├── lore/\n│ ├── me_empty.json # Empty since_last_check, no items\n│ ├── me_with_reviews.json # 3 MR reviews in since_last_check\n│ ├── me_with_issues.json # Issues assigned to user\n│ ├── me_mixed.json # Mix of reviews, issues, mentions\n│ └── me_stale_cursor.json # Empty since_last_check but has open items\n└── br/\n ├── create_success.json # Successful bead creation\n ├── create_error.json # Validation error\n ├── close_success.json # Successful close\n ├── list.json # List output with multiple beads\n └── ready.json # Ready (non-blocked) beads\n```\n\n**Fixture capture script:**\n\n```bash\n#!/bin/bash\n# scripts/regenerate-fixtures.sh\nset -e\nmkdir -p src-tauri/tests/fixtures/{lore,br}\n\n# Lore fixtures\nlore --robot me > src-tauri/tests/fixtures/lore/me_current.json\n\n# BR fixtures \nbr create --title \"Test fixture\" --type task --json > src-tauri/tests/fixtures/br/create_success.json\nbr list --json > src-tauri/tests/fixtures/br/list.json\n```\n\n**Initial fixtures to create manually:**\n- me_empty.json: Minimal valid response with empty arrays\n- me_with_reviews.json: 3 sample MR reviews with all required fields\n- me_stale_cursor.json: Simulates cursor recovery scenario\n\n**Acceptance criteria:**\n- All fixture files exist with valid JSON\n- scripts/regenerate-fixtures.sh captures real CLI outputs\n- Rust tests can include_str! fixtures without path issues\n- CI job can compare regenerated fixtures against committed versions","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:25:26.731143Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:25:36.252551Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1ps","depends_on_id":"bd-jyz","type":"blocks","created_at":"2026-02-25T20:25:36.252532Z","created_by":"tayloreernisse"}]} +{"id":"bd-1q7","title":"Implement bridge state machine with lifecycle transitions","description":"# Bridge State Machine (RED → GREEN)\n\n**Parent epic:** Phase 2: Bridge + Data Layer\n\n**State machine overview:**\n\n```\n ┌─────────────────────────────────────┐\n │ │\n ▼ │\n┌────────┐ ┌────────┐ ┌─────────────┐ ┌──────┴───┐\n│ (new) │────▶│ active │────▶│ suspect_ │────▶│ closed │\n│ event │ │ │ │ orphan │ │ │\n└────────┘ └────────┘ └─────────────┘ └──────────┘\n │ ▲\n │ (user closes bead) │\n └─────────────────────────────────────┘\n```\n\n**TDD: State transition tests (RED first):**\n\n```rust\n#[test]\nfn new_event_creates_active_mapping() {\n let mut bridge = BridgeState::new(mock_lore(), mock_beads());\n let event = lore_event(\"mr_review:gitlab.com:12345:847\");\n \n bridge.process_event(event);\n \n let entry = bridge.mapping.get(\"mr_review:gitlab.com:12345:847\").unwrap();\n assert!(!entry.suspect_orphan);\n assert!(!entry.pending);\n assert!(entry.bead_id.is_some());\n}\n\n#[test]\nfn duplicate_event_skips() {\n let mut bridge = setup_with_existing_mapping(\"mr_review:...\", \"br-x7f\");\n let event = lore_event(\"mr_review:...\");\n \n bridge.process_event(event.clone());\n bridge.process_event(event); // Duplicate\n \n // Should still have exactly one mapping\n assert_eq!(bridge.mapping.len(), 1);\n assert_invariants(&bridge);\n}\n\n#[test]\nfn missing_once_sets_suspect_orphan() {\n let mut bridge = setup_with_existing_mapping(\"mr_review:...\", \"br-x7f\");\n \n // Reconciliation with item missing from lore\n bridge.reconcile(&[/* item not present */]);\n \n let entry = bridge.mapping.get(\"mr_review:...\").unwrap();\n assert!(entry.suspect_orphan);\n // Bead should NOT be closed yet (first strike)\n assert!(bridge.beads.exists(\"br-x7f\").unwrap());\n}\n\n#[test]\nfn missing_twice_closes_bead() {\n let mut bridge = setup_with_suspect_orphan(\"mr_review:...\", \"br-x7f\");\n \n // Second reconciliation, still missing\n bridge.reconcile(&[/* item not present */]);\n \n // Entry should be removed, bead should be closed\n assert!(bridge.mapping.get(\"mr_review:...\").is_none());\n assert!(!bridge.beads.exists(\"br-x7f\").unwrap());\n}\n\n#[test]\nfn reappears_clears_suspect_flag() {\n let mut bridge = setup_with_suspect_orphan(\"mr_review:...\", \"br-x7f\");\n \n // Reconciliation with item present again\n bridge.reconcile(&[lore_item(\"mr_review:...\")]);\n \n let entry = bridge.mapping.get(\"mr_review:...\").unwrap();\n assert!(!entry.suspect_orphan);\n}\n\n#[test]\nfn user_close_removes_mapping() {\n let mut bridge = setup_with_existing_mapping(\"mr_review:...\", \"br-x7f\");\n \n bridge.user_closed_bead(\"br-x7f\");\n \n assert!(bridge.mapping.get(\"mr_review:...\").is_none());\n}\n```\n\n**Implementation (GREEN):**\n\n```rust\npub struct BridgeState {\n lore: L,\n beads: B,\n mapping: Mapping,\n}\n\nimpl BridgeState {\n pub fn process_event(&mut self, event: LoreEvent) -> Result<()> {\n let key = event.to_mapping_key();\n \n // Skip if already mapped (idempotent)\n if self.mapping.contains_key(&key) {\n return Ok(());\n }\n \n // Create bead\n let bead_id = self.beads.create(&event.title(), \"gitlab\")?;\n \n // Add to mapping\n self.mapping.insert(key, MappingEntry {\n bead_id: Some(bead_id),\n created_at: Utc::now(),\n suspect_orphan: false,\n pending: false,\n });\n \n Ok(())\n }\n \n pub fn reconcile(&mut self, current_items: &[LoreItem]) -> Result<()> {\n let expected_keys: HashSet<_> = current_items\n .iter()\n .map(|i| i.to_mapping_key())\n .collect();\n \n // ... two-strike logic\n }\n}\n```\n\n**Acceptance criteria:**\n- All 6 state transition tests pass\n- Invariant assertion runs after every operation\n- State machine handles all edge cases","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:27:45.187086Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:30:22.674540Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1q7","depends_on_id":"bd-1jy","type":"blocks","created_at":"2026-02-25T20:30:22.649185Z","created_by":"tayloreernisse"},{"issue_id":"bd-1q7","depends_on_id":"bd-2sj","type":"blocks","created_at":"2026-02-25T20:30:22.674522Z","created_by":"tayloreernisse"},{"issue_id":"bd-1q7","depends_on_id":"bd-2us","type":"blocks","created_at":"2026-02-25T20:30:20.608938Z","created_by":"tayloreernisse"}]} +{"id":"bd-1qi","title":"Implement batch celebration component","description":"# Batch Celebration Component\n\n**Parent epic:** Phase 5: Batch Mode\n\n**Purpose:** Rewarding visual feedback when completing a batch. ADHD-friendly dopamine hit!\n\n**Design:**\n- Confetti animation\n- Large \"All done!\" text\n- Stats (completed count, time taken)\n- Optional sound effect\n- Button to return to Focus View\n\n**TDD: Celebration tests (RED first):**\n\n```typescript\n// tests/components/BatchCelebration.test.tsx\ndescribe('BatchCelebration', () => {\n it('shows confetti animation', () => {\n render();\n \n expect(screen.getByTestId('confetti')).toBeInTheDocument();\n });\n \n it('shows completion stats', () => {\n render();\n \n expect(screen.getByText('All done!')).toBeInTheDocument();\n expect(screen.getByText('4/4 completed')).toBeInTheDocument();\n expect(screen.getByText('20 minutes')).toBeInTheDocument();\n });\n \n it('shows partial completion message when skipped', () => {\n render();\n \n expect(screen.getByText('3/4 completed')).toBeInTheDocument();\n expect(screen.getByText('1 skipped')).toBeInTheDocument();\n });\n \n it('calls onClose when button clicked', async () => {\n const onClose = vi.fn();\n render();\n \n await userEvent.click(screen.getByRole('button', { name: /back to focus/i }));\n \n expect(onClose).toHaveBeenCalled();\n });\n \n it('plays sound effect if enabled', () => {\n const mockPlay = vi.fn();\n vi.spyOn(Audio.prototype, 'play').mockImplementation(mockPlay);\n \n render();\n \n expect(mockPlay).toHaveBeenCalled();\n });\n \n it('respects reduced motion preference', () => {\n vi.spyOn(window, 'matchMedia').mockReturnValue({\n matches: true, // prefers-reduced-motion\n } as MediaQueryList);\n \n render();\n \n expect(screen.queryByTestId('confetti')).not.toBeInTheDocument();\n expect(screen.getByText('All done!')).toBeInTheDocument();\n });\n});\n```\n\n**Implementation:**\n\n```tsx\n// src/components/BatchCelebration.tsx\nimport Confetti from 'react-confetti';\nimport { useWindowSize } from 'react-use';\nimport { motion } from 'framer-motion';\n\ninterface BatchCelebrationProps {\n completed: number;\n total: number;\n skipped?: number;\n timeTaken?: number; // seconds\n soundEnabled?: boolean;\n onClose: () => void;\n}\n\nexport function BatchCelebration({\n completed,\n total,\n skipped = 0,\n timeTaken,\n soundEnabled = true,\n onClose\n}: BatchCelebrationProps) {\n const { width, height } = useWindowSize();\n const prefersReducedMotion = useReducedMotion();\n \n // Play celebration sound\n useEffect(() => {\n if (soundEnabled && !prefersReducedMotion) {\n const audio = new Audio('/sounds/celebration.mp3');\n audio.volume = 0.3;\n audio.play().catch(() => {}); // Ignore autoplay errors\n }\n }, [soundEnabled, prefersReducedMotion]);\n \n return (\n
\n {!prefersReducedMotion && (\n \n )}\n \n \n \n 🎉\n \n \n

All done!

\n \n

\n {completed}/{total} completed\n

\n \n {skipped > 0 && (\n

{skipped} skipped

\n )}\n \n {timeTaken && (\n

\n {formatDuration(timeTaken)}\n

\n )}\n \n \n \n
\n );\n}\n\nfunction formatDuration(seconds: number): string {\n const minutes = Math.round(seconds / 60);\n if (minutes < 60) return `${minutes} minutes`;\n const hours = Math.floor(minutes / 60);\n const remainingMins = minutes % 60;\n return `${hours}h ${remainingMins}m`;\n}\n```\n\n**Assets needed:**\n- `/public/sounds/celebration.mp3` — Short celebration sound\n\n**Acceptance criteria:**\n- Confetti animation plays\n- Stats display correctly\n- Sound plays if enabled\n- Respects prefers-reduced-motion\n- Button navigates back to focus","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:52:49.261808Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:53:42.240617Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1qi","depends_on_id":"bd-3c2","type":"blocks","created_at":"2026-02-25T20:53:42.210271Z","created_by":"tayloreernisse"},{"issue_id":"bd-1qi","depends_on_id":"bd-j76","type":"blocks","created_at":"2026-02-25T20:53:42.240601Z","created_by":"tayloreernisse"}]} +{"id":"bd-1w5","title":"Implement invariant assertion helpers","description":"# Invariant Assertion Helpers\n\n**Parent epic:** Phase 2: Bridge + Data Layer\n\n**Bridge Invariants (must ALWAYS hold):**\n\n| ID | Invariant |\n|----|-----------|\n| INV-1 | **No duplicate beads.** Each mapping key maps to exactly one bead ID. |\n| INV-2 | **No orphan beads.** Every bead ID in the map exists in beads. |\n| INV-3 | **No false closes.** Items only auto-closed after missing in TWO reconciliations. |\n| INV-4 | **Cursor monotonicity.** Cursor only advances forward, never backward. |\n\n**Implementation:**\n\n```rust\n/// Validates all bridge invariants. Call after every operation in tests.\npub fn assert_invariants(\n mapping: &Mapping, \n beads: &B,\n prev_cursor: Option<&Cursor>,\n) -> Result<(), InvariantViolation> {\n // INV-1: No duplicate bead IDs\n let bead_ids: Vec<_> = mapping.values()\n .filter_map(|e| e.bead_id.as_ref())\n .collect();\n let unique: HashSet<_> = bead_ids.iter().collect();\n if bead_ids.len() != unique.len() {\n return Err(InvariantViolation::DuplicateBeads);\n }\n \n // INV-2: No orphan beads\n for entry in mapping.values() {\n if let Some(id) = &entry.bead_id {\n if !beads.exists(id)? {\n return Err(InvariantViolation::OrphanBead(id.clone()));\n }\n }\n }\n \n // INV-4: Cursor monotonicity\n if let Some(prev) = prev_cursor {\n let curr = &mapping.cursor;\n if curr.last_check_timestamp < prev.last_check_timestamp {\n return Err(InvariantViolation::CursorRegression {\n prev: prev.last_check_timestamp,\n curr: curr.last_check_timestamp,\n });\n }\n }\n \n Ok(())\n}\n\n#[derive(Debug)]\npub enum InvariantViolation {\n DuplicateBeads,\n OrphanBead(String),\n FalseClose(String),\n CursorRegression { prev: DateTime, curr: DateTime },\n}\n```\n\n**Test helper macro:**\n\n```rust\nmacro_rules! assert_bridge_invariants {\n ($bridge:expr) => {\n assert_invariants(&$bridge.mapping, &$bridge.beads, None)\n .expect(\"Bridge invariants violated!\");\n };\n ($bridge:expr, $prev_cursor:expr) => {\n assert_invariants(&$bridge.mapping, &$bridge.beads, Some($prev_cursor))\n .expect(\"Bridge invariants violated!\");\n };\n}\n```\n\n**Usage in tests:**\n\n```rust\n#[test]\nfn reconciliation_maintains_invariants() {\n let mut bridge = setup_bridge();\n \n bridge.process_event(event1)?;\n assert_bridge_invariants!(bridge);\n \n bridge.reconcile(&[])?;\n assert_bridge_invariants!(bridge);\n \n bridge.reconcile(&[])?; // Second miss → close\n assert_bridge_invariants!(bridge);\n}\n```\n\n**INV-3 testing note:**\nINV-3 (no false closes) is tested via state machine tests rather than assertion helper, since it requires tracking the sequence of reconciliations.\n\n**Acceptance criteria:**\n- Assertion helper catches all invariant violations\n- Clear error messages identify which invariant failed\n- Macro available for concise test assertions\n- All state machine tests call invariant assertions","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:30:13.586770Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:30:20.812133Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1w5","depends_on_id":"bd-2us","type":"blocks","created_at":"2026-02-25T20:30:20.812116Z","created_by":"tayloreernisse"}]} +{"id":"bd-20b","title":"Implement shared TypeScript type definitions","description":"# TypeScript Type Definitions\n\n**Parent epic:** Phase 1: Foundation\n\n**Purpose:** Shared types used across frontend components. Should match Rust types for IPC.\n\n**Types to define:**\n\n```typescript\n// src/types/index.ts\n\n// Work item types\nexport type ItemSource = 'gitlab' | 'manual';\nexport type ItemType = 'mr_review' | 'issue' | 'mr_authored' | 'mention' | 'feedback' | 'manual';\n\nexport interface WorkItem {\n id: string;\n title: string;\n type: ItemType;\n source: ItemSource;\n createdAt: Date;\n url?: string;\n triaged: boolean;\n snoozedUntil?: Date;\n skippedToday?: boolean;\n metadata?: WorkItemMetadata;\n}\n\nexport interface WorkItemMetadata {\n // GitLab fields\n iid?: number;\n project?: string;\n projectId?: number;\n author?: string;\n linesChanged?: number;\n message?: string;\n \n // Computed fields\n staleness?: Staleness;\n}\n\n// Staleness\nexport type Staleness = 'fresh' | 'normal' | 'stale' | 'urgent';\n\n// Sync status\nexport type SyncState = 'synced' | 'syncing' | 'stale' | 'error' | 'offline';\n\nexport interface SyncStatus {\n state: SyncState;\n lastSync: Date | null;\n error: string | null;\n}\n\n// Decision logging\nexport type ActionType = \n | 'set_focus' \n | 'reorder' \n | 'defer' \n | 'snooze' \n | 'skip' \n | 'complete' \n | 'create_manual'\n | 'triage';\n\nexport interface DecisionEntry {\n timestamp?: Date;\n action: ActionType;\n bead_id: string;\n reason?: string;\n tags?: string[];\n context?: DecisionContext;\n}\n\nexport interface DecisionContext {\n previous_focus?: string;\n queue_size?: number;\n available_items?: string[];\n time_of_day?: 'morning' | 'afternoon' | 'evening';\n day_of_week?: string;\n items_completed_today?: number;\n}\n\n// App state (persisted)\nexport interface AppState {\n focusId: string | null;\n queueOrder: string[];\n activeView: ViewType;\n}\n\nexport type ViewType = 'focus' | 'queue' | 'inbox' | 'settings';\n\n// Filter types\nexport interface Filter {\n type?: ItemType;\n source?: ItemSource;\n minAge?: number;\n text?: string;\n}\n\n// Reorder data\nexport interface ReorderData {\n itemId: string;\n oldIndex: number;\n newIndex: number;\n oldOrder: string[];\n newOrder: string[];\n}\n```\n\n**Rust type matching:**\n\n```rust\n// src-tauri/src/types.rs\n// These must serialize to match TypeScript\n\n#[derive(Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct WorkItem {\n pub id: String,\n pub title: String,\n #[serde(rename = \"type\")]\n pub item_type: ItemType,\n pub source: ItemSource,\n pub created_at: DateTime,\n pub url: Option,\n pub triaged: bool,\n pub snoozed_until: Option>,\n pub skipped_today: Option,\n pub metadata: Option,\n}\n\n#[derive(Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ItemType {\n MrReview,\n Issue,\n MrAuthored,\n Mention,\n Feedback,\n Manual,\n}\n```\n\n**TDD: Type tests:**\n\n```typescript\n// tests/unit/types.test.ts\ndescribe('Type guards', () => {\n it('isWorkItem validates required fields', () => {\n expect(isWorkItem({ id: '1', title: 'Test', type: 'manual', source: 'manual' })).toBe(true);\n expect(isWorkItem({ id: '1' })).toBe(false);\n });\n \n it('parseWorkItem handles date conversion', () => {\n const raw = { ...mockItem, createdAt: '2026-02-25T10:00:00Z' };\n const parsed = parseWorkItem(raw);\n \n expect(parsed.createdAt).toBeInstanceOf(Date);\n });\n});\n```\n\n**Acceptance criteria:**\n- All types match between Rust and TypeScript\n- Date serialization works correctly\n- Type guards available for runtime validation\n- No `any` types in codebase","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T21:06:26.965435Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:08:14.589499Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-20b","depends_on_id":"bd-3fd","type":"blocks","created_at":"2026-02-25T21:08:14.589484Z","created_by":"tayloreernisse"},{"issue_id":"bd-20b","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T21:08:14.560285Z","created_by":"tayloreernisse"}]} +{"id":"bd-22q","title":"Implement QuickCapture overlay component","description":"# QuickCapture Overlay Component (RED → GREEN)\n\n**Parent epic:** Phase 6: Quick Capture\n\n**TDD: QuickCapture tests (RED first):**\n\n```typescript\n// tests/components/QuickCapture.test.tsx\ndescribe('QuickCapture', () => {\n it('renders overlay centered on screen', () => {\n render();\n \n const overlay = screen.getByTestId('quick-capture-overlay');\n expect(overlay).toHaveClass('fixed', 'inset-0');\n expect(screen.getByRole('dialog')).toBeInTheDocument();\n });\n \n it('auto-focuses text input', () => {\n render();\n \n expect(screen.getByRole('textbox')).toHaveFocus();\n });\n \n it('captures text input', async () => {\n render();\n \n await userEvent.type(screen.getByRole('textbox'), 'Check webhook retry logic');\n \n expect(screen.getByRole('textbox')).toHaveValue('Check webhook retry logic');\n });\n \n it('creates bead on Enter', async () => {\n const onSave = vi.fn();\n render();\n \n await userEvent.type(screen.getByRole('textbox'), 'Check webhook retry logic{Enter}');\n \n expect(onSave).toHaveBeenCalledWith('Check webhook retry logic');\n });\n \n it('cancels on Escape', async () => {\n const onCancel = vi.fn();\n render();\n \n await userEvent.type(screen.getByRole('textbox'), 'partial text');\n await userEvent.keyboard('{Escape}');\n \n expect(onCancel).toHaveBeenCalled();\n });\n \n it('does not create bead for empty input', async () => {\n const onSave = vi.fn();\n render();\n \n await userEvent.keyboard('{Enter}');\n \n expect(onSave).not.toHaveBeenCalled();\n });\n \n it('clears input after save', async () => {\n render();\n \n await userEvent.type(screen.getByRole('textbox'), 'Some thought{Enter}');\n \n // On next open, should be empty\n expect(screen.getByRole('textbox')).toHaveValue('');\n });\n});\n```\n\n**Implementation (GREEN):**\n\n```tsx\n// src/components/QuickCapture.tsx\nimport { motion, AnimatePresence } from 'framer-motion';\n\ninterface QuickCaptureProps {\n isOpen: boolean;\n onSave: (text: string) => void;\n onCancel: () => void;\n}\n\nexport function QuickCapture({ isOpen, onSave, onCancel }: QuickCaptureProps) {\n const [text, setText] = useState('');\n const inputRef = useRef(null);\n \n useEffect(() => {\n if (isOpen) {\n inputRef.current?.focus();\n }\n }, [isOpen]);\n \n const handleKeyDown = (e: React.KeyboardEvent) => {\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n if (text.trim()) {\n onSave(text.trim());\n setText('');\n }\n } else if (e.key === 'Escape') {\n onCancel();\n setText('');\n }\n };\n \n return (\n \n {isOpen && (\n \n \n setText(e.target.value)}\n onKeyDown={handleKeyDown}\n placeholder=\"Quick thought...\"\n className=\"min-h-[100px] resize-none\"\n autoFocus\n />\n \n
\n ⏎ Save & close\n ESC Cancel\n
\n \n \n )}\n
\n );\n}\n```\n\n**Integration with Tauri:**\n\n```rust\n// src-tauri/src/commands/capture.rs\n#[tauri::command]\npub async fn quick_capture(\n text: String,\n beads: State<'_, Box>,\n decision_log: State<'_, DecisionLogger>,\n) -> Result {\n let bead_id = beads.create(&text, \"manual\")\n .map_err(|e| e.to_string())?;\n \n decision_log.log(DecisionEntry {\n action: Action::CreateManual,\n bead_id: bead_id.clone(),\n reason: None,\n tags: vec![],\n context: DecisionContext::capture(),\n }).map_err(|e| e.to_string())?;\n \n Ok(bead_id)\n}\n```\n\n**Acceptance criteria:**\n- Overlay renders with animation\n- Auto-focus on open\n- Enter saves and dismisses\n- Escape cancels\n- Bead created via br CLI","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:34:42.154443Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:34:55.546729Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-22q","depends_on_id":"bd-24r","type":"blocks","created_at":"2026-02-25T20:34:55.483868Z","created_by":"tayloreernisse"},{"issue_id":"bd-22q","depends_on_id":"bd-xsp","type":"blocks","created_at":"2026-02-25T20:34:55.546712Z","created_by":"tayloreernisse"}]} +{"id":"bd-239","title":"Implement bridge sync orchestrator","description":"# Bridge Sync Orchestrator\n\n**Parent epic:** Phase 2: Bridge + Data Layer\n\n**Purpose:** The main function that coordinates the sync cycle: file watcher → lore call → event processing → bead creation → state update.\n\n**Sync flow:**\n\n```\nlore.db mtime changes (file watcher)\n │\n ▼\n debounce (500ms)\n │\n ▼\n call lore --robot me\n │\n ▼\n ┌────┴────┐\n │ success │────────────────────────────────────┐\n └────┬────┘ │\n │ │\n ▼ ▼\n process since_last_check check if reconciliation due\n │ │\n ▼ ▼\n for each event: if due: full reconciliation\n - check idempotency \n - create bead if new \n - update mapping \n │\n ▼\n advance cursor (only on full success)\n │\n ▼\n emit 'sync-complete' event to frontend\n```\n\n**TDD: Orchestrator tests (RED first):**\n\n```rust\n// src-tauri/tests/sync_orchestrator_test.rs\n\n#[tokio::test]\nasync fn sync_processes_new_events() {\n let lore = MockLoreCli::with_events(vec![\n event(\"mr_review\", \"gitlab.com\", 123, 847),\n event(\"issue\", \"gitlab.com\", 123, 312),\n ]);\n let beads = MockBeadsCli::new();\n let orchestrator = SyncOrchestrator::new(lore, beads);\n \n orchestrator.sync().await.unwrap();\n \n assert_eq!(beads.create_calls().len(), 2);\n}\n\n#[tokio::test]\nasync fn sync_skips_existing_mappings() {\n let lore = MockLoreCli::with_events(vec![\n event(\"mr_review\", \"gitlab.com\", 123, 847),\n ]);\n let beads = MockBeadsCli::new();\n let mapping = mapping_with(\"mr_review:gitlab.com:123:847\", \"br-existing\");\n let orchestrator = SyncOrchestrator::with_mapping(lore, beads, mapping);\n \n orchestrator.sync().await.unwrap();\n \n assert_eq!(beads.create_calls().len(), 0); // No new beads\n}\n\n#[tokio::test]\nasync fn sync_triggers_reconciliation_when_due() {\n let lore = MockLoreCli::with_events(vec![]);\n let beads = MockBeadsCli::new();\n let mapping = mapping_with_stale_reconciliation(); // 7 hours old\n let orchestrator = SyncOrchestrator::with_mapping(lore, beads, mapping);\n \n orchestrator.sync().await.unwrap();\n \n assert!(lore.was_called(\"me_issues\"));\n assert!(lore.was_called(\"me_mrs\"));\n}\n\n#[tokio::test]\nasync fn sync_emits_status_events() {\n let (tx, mut rx) = tokio::sync::mpsc::channel(10);\n let orchestrator = SyncOrchestrator::with_event_sender(tx);\n \n orchestrator.sync().await.unwrap();\n \n let events: Vec<_> = collect_events(&mut rx);\n assert!(events.contains(&SyncEvent::Started));\n assert!(events.contains(&SyncEvent::Completed { items_processed: 0 }));\n}\n\n#[tokio::test]\nasync fn sync_handles_lore_failure_gracefully() {\n let lore = MockLoreCli::that_fails();\n let orchestrator = SyncOrchestrator::new(lore, beads);\n \n let result = orchestrator.sync().await;\n \n assert!(result.is_err());\n // Cursor should NOT have advanced\n assert_eq!(orchestrator.cursor(), original_cursor);\n}\n```\n\n**Implementation:**\n\n```rust\n// src-tauri/src/bridge/orchestrator.rs\npub struct SyncOrchestrator {\n lore: L,\n beads: B,\n bridge: BridgeState,\n event_tx: Option>,\n}\n\nimpl SyncOrchestrator {\n pub async fn sync(&mut self) -> Result {\n self.emit(SyncEvent::Started);\n \n // Fetch from lore\n let response = match self.lore.me() {\n Ok(r) => r,\n Err(e) => {\n self.emit(SyncEvent::Error(e.to_string()));\n return Err(e);\n }\n };\n \n // Check if reconciliation is due\n if self.bridge.should_run_reconciliation() {\n self.run_full_reconciliation().await?;\n }\n \n // Process incremental events\n let mut processed = 0;\n for event in &response.data.since_last_check {\n match self.bridge.process_event(event.clone()) {\n Ok(created) => {\n if created {\n processed += 1;\n }\n }\n Err(e) => {\n // Log but continue\n tracing::warn!(\"Failed to process event: {}\", e);\n }\n }\n }\n \n // Advance cursor only on success\n self.bridge.advance_cursor();\n self.bridge.save_mapping()?;\n \n self.emit(SyncEvent::Completed { items_processed: processed });\n \n Ok(SyncResult { items_processed: processed })\n }\n \n async fn run_full_reconciliation(&mut self) -> Result<()> {\n self.emit(SyncEvent::ReconciliationStarted);\n \n let issues = self.lore.me_issues()?;\n let mrs = self.lore.me_mrs()?;\n \n let all_items: Vec<_> = issues.into_iter()\n .chain(mrs.into_iter())\n .collect();\n \n self.bridge.reconcile(&all_items)?;\n \n self.emit(SyncEvent::ReconciliationCompleted);\n Ok(())\n }\n \n fn emit(&self, event: SyncEvent) {\n if let Some(tx) = &self.event_tx {\n tx.try_send(event).ok();\n }\n }\n}\n\n#[derive(Debug, Clone)]\npub enum SyncEvent {\n Started,\n Completed { items_processed: usize },\n ReconciliationStarted,\n ReconciliationCompleted,\n Error(String),\n}\n```\n\n**Integration with Tauri:**\n\n```rust\n// In setup, start background sync task\nfn setup_sync_task(app: &tauri::App) {\n let orchestrator = app.state::>>();\n let app_handle = app.handle();\n \n // File watcher triggers sync\n let watcher = LoreDbWatcher::new(lore_db_path, move || {\n let orch = orchestrator.clone();\n let handle = app_handle.clone();\n \n tauri::async_runtime::spawn(async move {\n let mut orch = orch.lock().await;\n match orch.sync().await {\n Ok(result) => {\n handle.emit_all(\"sync-complete\", result).ok();\n }\n Err(e) => {\n handle.emit_all(\"sync-error\", e.to_string()).ok();\n }\n }\n });\n })?;\n}\n```\n\n**Acceptance criteria:**\n- Sync processes all event types\n- Idempotency prevents duplicates\n- Reconciliation runs when due\n- Events emitted for UI updates\n- Errors don't corrupt state","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T21:06:58.893904Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:08:14.710719Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-239","depends_on_id":"bd-1q7","type":"blocks","created_at":"2026-02-25T21:08:14.649680Z","created_by":"tayloreernisse"},{"issue_id":"bd-239","depends_on_id":"bd-2pt","type":"blocks","created_at":"2026-02-25T21:08:14.710701Z","created_by":"tayloreernisse"},{"issue_id":"bd-239","depends_on_id":"bd-2us","type":"blocks","created_at":"2026-02-25T21:08:14.620527Z","created_by":"tayloreernisse"},{"issue_id":"bd-239","depends_on_id":"bd-xvy","type":"blocks","created_at":"2026-02-25T21:08:14.680710Z","created_by":"tayloreernisse"}]} +{"id":"bd-247","title":"Implement Tauri command handlers for frontend IPC","description":"# Tauri Command Handlers\n\n**Parent epic:** Phase 2: Bridge + Data Layer\n\n**Purpose:** Rust functions exposed to frontend via Tauri's invoke system. These are the IPC bridge between React and Rust.\n\n**Commands to implement:**\n\n| Command | Input | Output | Description |\n|---------|-------|--------|-------------|\n| `get_work_items` | — | `Vec` | Fetch all items (lore + manual) |\n| `get_focus` | — | `Option` | Get current focus ID |\n| `set_focus` | `id: String` | `()` | Set focus item |\n| `clear_focus` | — | `()` | Clear focus |\n| `update_item` | `id, updates` | `()` | Update item state |\n| `complete_item` | `id: String` | `()` | Mark item complete |\n| `quick_capture` | `text: String` | `String` | Create manual bead |\n| `log_decision` | `entry: DecisionEntry` | `()` | Log to decision log |\n| `read_state` | — | `AppState` | Read persisted state |\n| `write_state` | `state: AppState` | `()` | Write persisted state |\n| `trigger_sync` | — | `()` | Manual sync trigger |\n| `get_sync_status` | — | `SyncStatus` | Get current sync state |\n\n**TDD: Command tests (RED first):**\n\n```rust\n// src-tauri/tests/commands_test.rs\n\n#[tokio::test]\nasync fn get_work_items_returns_merged_items() {\n let app = setup_test_app();\n let lore = MockLoreCli::with_items(vec![lore_item(\"1\")]);\n let state = MockState::with_manual_items(vec![manual_item(\"2\")]);\n \n let result: Vec = app.invoke(\"get_work_items\", ()).await;\n \n assert_eq!(result.len(), 2);\n assert!(result.iter().any(|i| i.id == \"1\" && i.source == \"gitlab\"));\n assert!(result.iter().any(|i| i.id == \"2\" && i.source == \"manual\"));\n}\n\n#[tokio::test]\nasync fn set_focus_updates_state() {\n let app = setup_test_app();\n \n app.invoke::<()>(\"set_focus\", SetFocusArgs { id: \"br-123\".into() }).await;\n \n let focus = app.invoke::>(\"get_focus\", ()).await;\n assert_eq!(focus, Some(\"br-123\".into()));\n}\n\n#[tokio::test]\nasync fn quick_capture_creates_bead() {\n let app = setup_test_app();\n let beads = app.state::();\n \n let id: String = app.invoke(\"quick_capture\", QuickCaptureArgs { \n text: \"Test thought\".into() \n }).await;\n \n assert!(id.starts_with(\"bd-\") || id.starts_with(\"br-\"));\n assert!(beads.was_called_with(\"create\", \"Test thought\"));\n}\n\n#[tokio::test]\nasync fn log_decision_appends_to_file() {\n let app = setup_test_app();\n let log_path = app.state::().decision_log_path();\n \n app.invoke::<()>(\"log_decision\", LogDecisionArgs {\n entry: DecisionEntry {\n action: Action::SetFocus,\n bead_id: \"br-123\".into(),\n reason: Some(\"Test\".into()),\n ..Default::default()\n }\n }).await;\n \n let content = std::fs::read_to_string(log_path).unwrap();\n assert!(content.contains(\"set_focus\"));\n assert!(content.contains(\"br-123\"));\n}\n```\n\n**Implementation:**\n\n```rust\n// src-tauri/src/commands/mod.rs\npub mod work_items;\npub mod actions;\npub mod capture;\npub mod decisions;\npub mod state;\npub mod sync;\n\n// src-tauri/src/commands/work_items.rs\n#[tauri::command]\npub async fn get_work_items(\n lore: State<'_, Arc>,\n state: State<'_, AppState>,\n) -> Result, String> {\n let lore_items = lore.me()\n .map_err(|e| e.to_string())?\n .to_work_items();\n \n let manual_items = state.get_manual_items();\n \n Ok([lore_items, manual_items].concat())\n}\n\n#[tauri::command]\npub async fn get_focus(\n state: State<'_, AppState>,\n) -> Option {\n state.get_focus_id()\n}\n\n#[tauri::command]\npub async fn set_focus(\n id: String,\n state: State<'_, AppState>,\n) -> Result<(), String> {\n state.set_focus_id(Some(id));\n state.persist().map_err(|e| e.to_string())\n}\n\n// src-tauri/src/commands/capture.rs\n#[tauri::command]\npub async fn quick_capture(\n text: String,\n beads: State<'_, Arc>,\n state: State<'_, AppState>,\n decision_log: State<'_, DecisionLogger>,\n) -> Result {\n let bead_id = beads.create(&text, \"manual\")\n .map_err(|e| e.to_string())?;\n \n // Add to manual items\n state.add_manual_item(WorkItem {\n id: bead_id.clone(),\n title: text,\n source: \"manual\".into(),\n created_at: Utc::now(),\n ..Default::default()\n });\n \n // Log decision\n decision_log.log(DecisionEntry {\n action: Action::CreateManual,\n bead_id: bead_id.clone(),\n ..Default::default()\n }).ok();\n \n Ok(bead_id)\n}\n```\n\n**Registration in main.rs:**\n\n```rust\nfn main() {\n tauri::Builder::default()\n .invoke_handler(tauri::generate_handler![\n commands::work_items::get_work_items,\n commands::work_items::get_focus,\n commands::work_items::set_focus,\n commands::work_items::clear_focus,\n commands::actions::update_item,\n commands::actions::complete_item,\n commands::capture::quick_capture,\n commands::decisions::log_decision,\n commands::state::read_state,\n commands::state::write_state,\n commands::sync::trigger_sync,\n commands::sync::get_sync_status,\n ])\n .run(tauri::generate_context!())\n .expect(\"error while running tauri application\");\n}\n```\n\n**Acceptance criteria:**\n- All commands have tests\n- Error handling returns user-friendly messages\n- State mutations persist correctly\n- Async operations don't block UI","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T21:06:11.039639Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:08:14.527835Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-247","depends_on_id":"bd-1ev","type":"blocks","created_at":"2026-02-25T21:08:14.465428Z","created_by":"tayloreernisse"},{"issue_id":"bd-247","depends_on_id":"bd-2sj","type":"blocks","created_at":"2026-02-25T21:08:14.496588Z","created_by":"tayloreernisse"},{"issue_id":"bd-247","depends_on_id":"bd-2us","type":"blocks","created_at":"2026-02-25T21:08:14.434706Z","created_by":"tayloreernisse"},{"issue_id":"bd-247","depends_on_id":"bd-2zu","type":"blocks","created_at":"2026-02-25T21:08:14.527813Z","created_by":"tayloreernisse"}]} +{"id":"bd-24r","title":"Phase 6: Quick Capture","description":"# Quick Capture — Trust the System\n\n**Context:** Global hotkey (⌘⇧C) summons a minimal overlay for capturing thoughts instantly. Creates a bead immediately, triage later.\n\n**UX principle:** \"One hotkey, type it, gone. System triages later.\"\n\n**Duration estimate:** 1 day\n\n**Visual design:**\n```\n ┌────────────────────────────────────────┐\n │ │\n │ ┌────────────────────────────────┐ │\n │ │ Quick thought... │ │\n │ │ │ │\n │ │ Need to check if webhook │ │\n │ │ retry uses exponential backoff │ │\n │ └────────────────────────────────┘ │\n │ │\n │ ⏎ Save & close ESC Cancel │\n │ │\n └────────────────────────────────────────┘\n```\n\n**Scope includes:**\n1. Global hotkey registration (⌘⇧C)\n2. Floating overlay that appears above other apps\n3. Text input with auto-focus\n4. Instant bead creation via br CLI\n5. Smooth dismiss animation\n\n**Behavior:**\n- ⌘⇧C from anywhere summons overlay\n- Start typing immediately (auto-focused)\n- Enter saves and dismisses\n- ESC cancels\n- Returns to previous context\n\n**Dependencies:**\n- Requires Foundation (hotkey registration)\n- Requires Bridge (bead creation)\n\n**Acceptance criteria:**\n- Hotkey works from any app\n- Overlay appears quickly (<200ms)\n- Text captured and bead created\n- Smooth dismiss animation\n- Previous focus restored","status":"open","priority":2,"issue_type":"epic","created_at":"2026-02-25T20:34:19.470997Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:34:22.924975Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-24r","depends_on_id":"bd-2us","type":"blocks","created_at":"2026-02-25T20:34:22.924954Z","created_by":"tayloreernisse"}]} +{"id":"bd-25l","title":"Phase 7: Polish + E2E Tests","description":"# Polish + E2E Testing — Production Ready\n\n**Context:** Final phase to add visual polish (animations, staleness colors) and comprehensive E2E testing to ensure the app works correctly end-to-end.\n\n**Duration estimate:** Ongoing (1-2 days initial, then continuous)\n\n**Scope includes:**\n1. Framer Motion animations throughout\n2. Staleness visualization (color decay)\n3. Menu bar badge counts\n4. Settings UI\n5. E2E tests for critical flows\n\n**E2E test coverage:**\n| Test | Flow |\n|------|------|\n| focus-flow.spec.ts | Launch → See focus → Start → Verify browser opens |\n| batch-mode.spec.ts | Queue with reviews → Batch → Complete all → Celebration |\n| quick-capture.spec.ts | Hotkey → Type → Enter → Verify bead created |\n| sync-status.spec.ts | Mock failure → Error shown → Recover → Success |\n\n**Coverage requirements:**\n| Layer | Minimum | Focus Areas |\n|-------|---------|-------------|\n| Rust bridge | 90% | State transitions, crash recovery |\n| Frontend hooks | 85% | Data fetching, state management |\n| Frontend components | 70% | User interactions |\n| E2E | N/A | Critical user flows |\n\n**Dependencies:**\n- Requires all previous phases complete\n- Can start some work (animations) earlier\n\n**Acceptance criteria:**\n- Smooth animations throughout\n- Staleness colors work correctly\n- All E2E tests pass\n- Coverage targets met","status":"open","priority":2,"issue_type":"epic","created_at":"2026-02-25T20:35:04.304233Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:35:08.184960Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-25l","depends_on_id":"bd-24r","type":"blocks","created_at":"2026-02-25T20:35:08.155376Z","created_by":"tayloreernisse"},{"issue_id":"bd-25l","depends_on_id":"bd-3c2","type":"blocks","created_at":"2026-02-25T20:35:08.184939Z","created_by":"tayloreernisse"}]} +{"id":"bd-28q","title":"Set up Rust trait-based mocking infrastructure","description":"# Rust Trait-Based Mocking for CLI Integration\n\n**Parent epic:** Phase 0: Test Infrastructure\n\n**Why trait-based mocking:**\nMC shells out to external CLIs (lore, br, bv). Production code runs real commands.\nTests need to inject mock responses without touching actual CLIs.\nRust traits allow compile-time polymorphism with zero runtime cost.\n\n**Traits to define:**\n\n```rust\n// src/data/traits.rs\npub trait LoreCli: Send + Sync {\n fn me(&self) -> Result;\n fn me_issues(&self) -> Result>;\n fn me_mrs(&self) -> Result>;\n}\n\npub trait BeadsCli: Send + Sync {\n fn create(&self, title: &str, bead_type: &str) -> Result;\n fn close(&self, id: &str, reason: &str) -> Result<()>;\n fn exists(&self, id: &str) -> Result;\n}\n```\n\n**Production implementations:**\n- RealLoreCli: Executes `lore --robot` commands, parses JSON output\n- RealBeadsCli: Executes `br` commands, parses JSON output\n\n**Mock implementations:**\n- MockLoreCli: Returns fixture data from HashMap\n- MockBeadsCli: Records calls, returns preconfigured responses\n\n**Test helper module:**\n\n```rust\n// tests/common/mod.rs\npub fn mock_lore(fixture: &str) -> MockLoreCli {\n let json = include_str!(concat!(\"fixtures/lore/\", fixture));\n MockLoreCli { response: serde_json::from_str(json).unwrap() }\n}\n```\n\n**Acceptance criteria:**\n- Bridge code accepts `impl LoreCli` instead of hardcoded calls\n- Tests can inject MockLoreCli with fixture data\n- All CLI interactions are testable without real CLIs installed\n- No conditional compilation needed (pure trait injection)","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:25:12.489547Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:25:36.234795Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-28q","depends_on_id":"bd-jyz","type":"blocks","created_at":"2026-02-25T20:25:36.234777Z","created_by":"tayloreernisse"}]} +{"id":"bd-2at","title":"Implement single-instance lock with flock","description":"# Single-Instance Lock (RED → GREEN)\n\n**Parent epic:** Phase 2: Bridge + Data Layer\n\n**Problem being solved:**\nMultiple MC instances would race to create beads, causing duplicates and corrupted state.\n\n**Solution:** OS advisory lock via `flock(2)`.\n\n**Lock file:** `~/.local/share/mc/mc.lock`\n\n**Why flock over \"file exists\":**\n- Automatically released on crash (no stale lockfiles)\n- No cleanup needed on abnormal exit\n- Race-free (OS handles atomicity)\n\n**TDD: Single-instance tests (RED first):**\n\n```rust\n#[test]\nfn first_instance_acquires_lock() {\n let temp = tempfile::tempdir().unwrap();\n let lock_path = temp.path().join(\"mc.lock\");\n \n let lock = InstanceLock::acquire(&lock_path);\n \n assert!(lock.is_ok());\n}\n\n#[test]\nfn second_instance_blocked() {\n let temp = tempfile::tempdir().unwrap();\n let lock_path = temp.path().join(\"mc.lock\");\n \n let _lock1 = InstanceLock::acquire(&lock_path).unwrap();\n let lock2 = InstanceLock::acquire(&lock_path);\n \n assert!(lock2.is_err());\n assert!(matches!(lock2.unwrap_err(), LockError::AlreadyRunning));\n}\n\n#[test]\nfn lock_released_on_drop() {\n let temp = tempfile::tempdir().unwrap();\n let lock_path = temp.path().join(\"mc.lock\");\n \n {\n let _lock = InstanceLock::acquire(&lock_path).unwrap();\n } // Lock dropped here\n \n // Second acquisition should succeed\n let lock2 = InstanceLock::acquire(&lock_path);\n assert!(lock2.is_ok());\n}\n\n#[test]\nfn lock_released_on_panic() {\n let temp = tempfile::tempdir().unwrap();\n let lock_path = temp.path().join(\"mc.lock\");\n \n std::panic::catch_unwind(|| {\n let _lock = InstanceLock::acquire(&lock_path).unwrap();\n panic!(\"Simulated crash\");\n }).ok();\n \n // Lock should be released after panic\n let lock2 = InstanceLock::acquire(&lock_path);\n assert!(lock2.is_ok());\n}\n```\n\n**Implementation (GREEN):**\n\n```rust\nuse std::fs::{File, OpenOptions};\nuse std::os::unix::io::AsRawFd;\n\npub struct InstanceLock {\n _file: File, // Held to keep lock alive\n}\n\nimpl InstanceLock {\n pub fn acquire(path: &Path) -> Result {\n // Create parent directory if needed\n if let Some(parent) = path.parent() {\n std::fs::create_dir_all(parent)?;\n }\n \n // Open lock file (create if missing)\n let file = OpenOptions::new()\n .write(true)\n .create(true)\n .open(path)?;\n \n // Attempt non-blocking exclusive lock\n let fd = file.as_raw_fd();\n let result = unsafe {\n libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB)\n };\n \n if result != 0 {\n let errno = std::io::Error::last_os_error();\n if errno.raw_os_error() == Some(libc::EWOULDBLOCK) {\n return Err(LockError::AlreadyRunning);\n }\n return Err(LockError::Io(errno));\n }\n \n Ok(Self { _file: file })\n }\n}\n\n#[derive(Debug)]\npub enum LockError {\n AlreadyRunning,\n Io(std::io::Error),\n}\n```\n\n**Startup behavior:**\n1. Open `mc.lock` (create if missing)\n2. Attempt `flock(fd, LOCK_EX | LOCK_NB)` (non-blocking exclusive lock)\n3. If `EWOULDBLOCK` → another instance holds lock → show error dialog, exit\n4. If lock acquired → proceed, OS auto-releases on process exit/crash\n\n**UI for blocked startup:**\n- Show dialog: \"Mission Control is already running\"\n- Option: \"Bring to front\" (if we can signal other instance)\n- Option: \"Force close other\" (dangerous, needs confirmation)\n\n**Acceptance criteria:**\n- Only one MC instance can run at a time\n- Lock released automatically on exit/crash\n- No stale lockfiles after abnormal termination\n- Clear error message when blocked","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:29:36.551427Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:30:20.763167Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2at","depends_on_id":"bd-2us","type":"blocks","created_at":"2026-02-25T20:30:20.763150Z","created_by":"tayloreernisse"}]} +{"id":"bd-2cl","title":"Implement QueueList component with sections","description":"# QueueList Component (RED → GREEN)\n\n**Parent epic:** Phase 4: Queue + Inbox Views\n\n**Purpose:** Display all pending work items organized into collapsible sections by type.\n\n**TDD: QueueList tests (RED first):**\n\n```typescript\n// tests/components/QueueList.test.tsx\ndescribe('QueueList', () => {\n const mockItems: WorkItem[] = [\n { id: '1', type: 'mr_review', title: 'Review MR !847', createdAt: daysAgo(2) },\n { id: '2', type: 'mr_review', title: 'Review MR !902', createdAt: daysAgo(1) },\n { id: '3', type: 'issue', title: 'Issue #312', createdAt: daysAgo(5) },\n { id: '4', type: 'manual', title: 'Write tests', createdAt: daysAgo(0) },\n ];\n \n it('renders sections by item type', () => {\n render();\n \n expect(screen.getByText('REVIEWS (2)')).toBeInTheDocument();\n expect(screen.getByText('ASSIGNED ISSUES (1)')).toBeInTheDocument();\n expect(screen.getByText('MANUAL TASKS (1)')).toBeInTheDocument();\n });\n \n it('shows staleness colors correctly', () => {\n render();\n \n const issue = screen.getByText('Issue #312');\n expect(issue.closest('[data-testid=\"queue-item\"]')).toHaveClass('staleness-amber');\n \n const fresh = screen.getByText('Write tests');\n expect(fresh.closest('[data-testid=\"queue-item\"]')).toHaveClass('staleness-green');\n });\n \n it('shows batch button for sections with multiple items', () => {\n render();\n \n expect(screen.getByText(/Batch All.*25min/)).toBeInTheDocument(); // Reviews section\n expect(screen.queryByText(/Batch All.*ISSUES/)).not.toBeInTheDocument(); // Only 1 issue\n });\n \n it('calls onItemClick when item clicked', async () => {\n const onItemClick = vi.fn();\n render();\n \n await userEvent.click(screen.getByText('Review MR !847'));\n \n expect(onItemClick).toHaveBeenCalledWith('1');\n });\n \n it('sections are collapsible', async () => {\n render();\n \n const reviewsHeader = screen.getByText('REVIEWS (2)');\n await userEvent.click(reviewsHeader);\n \n expect(screen.queryByText('Review MR !847')).not.toBeVisible();\n });\n});\n```\n\n**Implementation (GREEN):**\n\n```tsx\n// src/components/QueueList.tsx\ninterface QueueListProps {\n items: WorkItem[];\n onItemClick: (id: string) => void;\n onBatchStart: (type: ItemType) => void;\n}\n\nexport function QueueList({ items, onItemClick, onBatchStart }: QueueListProps) {\n const sections = useMemo(() => groupByType(items), [items]);\n \n return (\n
\n {sections.map(section => (\n \n \n \n {section.label} ({section.items.length})\n \n {section.items.length > 1 && (\n \n )}\n \n \n \n
\n {section.items.map(item => (\n onItemClick(item.id)}\n />\n ))}\n
\n
\n
\n ))}\n
\n );\n}\n\nfunction QueueItem({ item, onClick }: { item: WorkItem; onClick: () => void }) {\n const staleness = getStaleness(item.createdAt);\n \n return (\n
\n \n {item.title}\n {formatAge(item.createdAt)}\n {item.metadata?.author && (\n {item.metadata.author}\n )}\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- Items grouped by type in collapsible sections\n- Staleness colors applied correctly\n- Batch button shows with time estimate\n- Click handler fires\n- Sections collapse/expand","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:32:35.899740Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:33:34.846002Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2cl","depends_on_id":"bd-716","type":"blocks","created_at":"2026-02-25T20:33:34.845981Z","created_by":"tayloreernisse"}]} +{"id":"bd-2or","title":"Implement sync status indicator","description":"# Sync Status Indicator\n\n**Parent epic:** Phase 1: Foundation\n\n**Purpose:** Show users when data was last synced and surface any errors clearly.\n\n**UX requirement from AC-009:**\n> **Given** lore cron syncs periodically\n> **When** viewing any MC screen\n> **Then** sync status is visible (last sync time, success/failure)\n> **And** errors are surfaced with actionable info\n\n**States:**\n| State | Visual | Action |\n|-------|--------|--------|\n| Synced | Green dot, \"Synced 2m ago\" | None |\n| Syncing | Spinner, \"Syncing...\" | None |\n| Stale | Amber dot, \"Last sync 30m ago\" | \"Refresh\" button |\n| Error | Red dot, error message | \"Retry\" button |\n| Offline | Gray dot, \"lore unavailable\" | \"Check lore\" link |\n\n**TDD: SyncStatus tests (RED first):**\n\n```typescript\n// tests/components/SyncStatus.test.tsx\ndescribe('SyncStatus', () => {\n it('shows synced state with time', () => {\n render();\n \n expect(screen.getByTestId('sync-indicator')).toHaveClass('bg-green-500');\n expect(screen.getByText(/Synced 2m ago/)).toBeInTheDocument();\n });\n \n it('shows syncing spinner', () => {\n render();\n \n expect(screen.getByTestId('sync-spinner')).toBeInTheDocument();\n expect(screen.getByText('Syncing...')).toBeInTheDocument();\n });\n \n it('shows error with retry button', () => {\n const onRetry = vi.fn();\n render();\n \n expect(screen.getByTestId('sync-indicator')).toHaveClass('bg-red-500');\n expect(screen.getByText(/lore command failed/)).toBeInTheDocument();\n \n await userEvent.click(screen.getByRole('button', { name: /retry/i }));\n expect(onRetry).toHaveBeenCalled();\n });\n \n it('shows stale warning after 15 minutes', () => {\n render();\n \n expect(screen.getByTestId('sync-indicator')).toHaveClass('bg-amber-500');\n expect(screen.getByText(/Last sync 20m ago/)).toBeInTheDocument();\n });\n});\n```\n\n**Implementation:**\n\n```tsx\n// src/components/SyncStatus.tsx\ntype SyncState = 'synced' | 'syncing' | 'stale' | 'error' | 'offline';\n\ninterface SyncStatusProps {\n status: SyncState;\n lastSync?: Date;\n error?: string;\n onRetry?: () => void;\n}\n\nexport function SyncStatus({ status, lastSync, error, onRetry }: SyncStatusProps) {\n const isStale = lastSync && (Date.now() - lastSync.getTime()) > 15 * 60 * 1000;\n const effectiveStatus = status === 'synced' && isStale ? 'stale' : status;\n \n const indicators = {\n synced: { color: 'bg-green-500', text: `Synced ${formatRelative(lastSync)}` },\n syncing: { color: 'bg-blue-500 animate-pulse', text: 'Syncing...' },\n stale: { color: 'bg-amber-500', text: `Last sync ${formatRelative(lastSync)}` },\n error: { color: 'bg-red-500', text: error || 'Sync failed' },\n offline: { color: 'bg-gray-400', text: 'lore unavailable' },\n };\n \n const { color, text } = indicators[effectiveStatus];\n \n return (\n
\n {effectiveStatus === 'syncing' ? (\n \n ) : (\n
\n )}\n \n {text}\n \n {(effectiveStatus === 'error' || effectiveStatus === 'stale') && onRetry && (\n \n )}\n
\n );\n}\n```\n\n**Integration:**\n- Show in top-right of every view\n- Update on file watcher events\n- Trigger manual refresh on Retry\n\n**Acceptance criteria:**\n- All sync states display correctly\n- Time updates every minute\n- Retry triggers lore refresh\n- Errors show actionable message","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:51:16.529514Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:12:04.153073Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2or","depends_on_id":"bd-bap","type":"blocks","created_at":"2026-02-25T21:12:04.153059Z","created_by":"tayloreernisse"},{"issue_id":"bd-2or","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:53:41.853507Z","created_by":"tayloreernisse"},{"issue_id":"bd-2or","depends_on_id":"bd-xvy","type":"blocks","created_at":"2026-02-25T20:53:41.881364Z","created_by":"tayloreernisse"}]} +{"id":"bd-2p0","title":"Implement ReasonPrompt component with quick tags","description":"# ReasonPrompt Component (RED → GREEN)\n\n**Parent epic:** Phase 3: Focus View\n\n**Purpose:** Every significant action prompts for an optional reason to learn patterns.\n\n**Visual design:**\n```\n┌─────────────────────────────────────────────────────────────┐\n│ Setting focus to: Review MR !847 │\n│ │\n│ Why? (optional, helps learn your patterns) │\n│ ┌────────────────────────────────────────────────────────┐ │\n│ │ Sarah pinged me, she's blocked on release │ │\n│ └────────────────────────────────────────────────────────┘ │\n│ │\n│ Quick tags: [Blocking] [Urgent] [Context switch] [Energy] │\n│ │\n│ [Confirm] [Skip reason] │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**TDD: ReasonPrompt tests (RED first):**\n\n```typescript\n// tests/components/ReasonPrompt.test.tsx\ndescribe('ReasonPrompt', () => {\n it('renders with action context', () => {\n render();\n \n expect(screen.getByText('Setting focus to: Review MR !847')).toBeInTheDocument();\n });\n \n it('captures text input', async () => {\n const onSubmit = vi.fn();\n render();\n \n await userEvent.type(\n screen.getByRole('textbox'),\n 'Sarah pinged me, she is blocked'\n );\n await userEvent.click(screen.getByRole('button', { name: /confirm/i }));\n \n expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({\n reason: 'Sarah pinged me, she is blocked'\n }));\n });\n \n it('allows selecting quick tags', async () => {\n const onSubmit = vi.fn();\n render();\n \n await userEvent.click(screen.getByRole('button', { name: /blocking/i }));\n await userEvent.click(screen.getByRole('button', { name: /urgent/i }));\n await userEvent.click(screen.getByRole('button', { name: /confirm/i }));\n \n expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({\n tags: ['blocking', 'urgent']\n }));\n });\n \n it('allows skipping reason', async () => {\n const onSubmit = vi.fn();\n render();\n \n await userEvent.click(screen.getByRole('button', { name: /skip reason/i }));\n \n expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({\n reason: null,\n tags: []\n }));\n });\n \n it('submits on Enter in text field', async () => {\n const onSubmit = vi.fn();\n render();\n \n await userEvent.type(screen.getByRole('textbox'), 'Quick note{Enter}');\n \n expect(onSubmit).toHaveBeenCalled();\n });\n \n it('cancels on Escape', async () => {\n const onCancel = vi.fn();\n render();\n \n await userEvent.keyboard('{Escape}');\n \n expect(onCancel).toHaveBeenCalled();\n });\n});\n```\n\n**Implementation (GREEN):**\n\n```tsx\n// src/components/ReasonPrompt.tsx\nconst QUICK_TAGS = [\n { id: 'blocking', label: 'Blocking', description: 'Someone is waiting on this' },\n { id: 'urgent', label: 'Urgent', description: 'Time-sensitive' },\n { id: 'context_switch', label: 'Context switch', description: 'Good mental break point' },\n { id: 'energy', label: 'Energy', description: 'Matches current energy level' },\n { id: 'flow', label: 'Flow', description: 'In the zone for this type of work' },\n];\n\ninterface ReasonPromptProps {\n action: string;\n itemTitle: string;\n onSubmit: (data: { reason: string | null; tags: string[] }) => void;\n onCancel: () => void;\n}\n\nexport function ReasonPrompt({ action, itemTitle, onSubmit, onCancel }: ReasonPromptProps) {\n const [reason, setReason] = useState('');\n const [selectedTags, setSelectedTags] = useState([]);\n const inputRef = useRef(null);\n \n useEffect(() => {\n inputRef.current?.focus();\n }, []);\n \n const handleSubmit = () => {\n onSubmit({ reason: reason.trim() || null, tags: selectedTags });\n };\n \n const handleSkip = () => {\n onSubmit({ reason: null, tags: [] });\n };\n \n const toggleTag = (tagId: string) => {\n setSelectedTags(prev => \n prev.includes(tagId) \n ? prev.filter(t => t !== tagId)\n : [...prev, tagId]\n );\n };\n \n return (\n !open && onCancel()}>\n \n \n \n {formatActionTitle(action)}: {itemTitle}\n \n \n \n
\n
\n \n setReason(e.target.value)}\n onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSubmit()}\n placeholder=\"Helps learn your patterns...\"\n />\n
\n \n
\n {QUICK_TAGS.map(tag => (\n toggleTag(tag.id)}\n >\n {tag.label}\n \n ))}\n
\n
\n \n \n \n \n \n
\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- Text input works with reason capture\n- Quick tags toggle on/off\n- Both reason and tags captured in submission\n- Skip option available\n- Keyboard navigation works (Enter, Escape)","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:31:55.608671Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:32:00.864842Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2p0","depends_on_id":"bd-1ft","type":"blocks","created_at":"2026-02-25T20:32:00.864825Z","created_by":"tayloreernisse"}]} +{"id":"bd-2pt","title":"Implement full reconciliation pass with cursor recovery","description":"# Full Reconciliation with Cursor Recovery (RED → GREEN)\n\n**Parent epic:** Phase 2: Bridge + Data Layer\n\n**Problem being solved:**\n`since_last_check` is incremental. If MC is offline, clock skews, or lore's cursor gets stale, events can be missed.\n\n**Solution:** Periodic full reconciliation pass.\n\n| Trigger | Action |\n|---------|--------|\n| App startup | Full reconciliation |\n| Every 6 hours | Full reconciliation |\n| `since_last_check` empty but items exist | Full reconciliation |\n\n**TDD: Reconciliation tests (RED first):**\n\n```rust\n#[test]\nfn startup_triggers_full_reconciliation() {\n let lore = MockLoreCli::with_fixture(\"me_with_reviews.json\");\n let beads = MockBeadsCli::new();\n let mapping = Mapping::empty();\n \n let bridge = BridgeState::init(lore, beads, mapping)?;\n \n // Should have created beads for all reviews\n assert!(!bridge.mapping.is_empty());\n}\n\n#[test]\nfn periodic_reconciliation_heals_missed_events() {\n let mut bridge = setup_with_stale_mapping();\n \n // Lore has items not in our mapping (missed events)\n let lore_items = vec![\n mr_item(\"mr:gitlab:123:847\"), // Missing from mapping\n mr_item(\"mr:gitlab:123:902\"), // Already in mapping\n ];\n \n bridge.reconcile(&lore_items)?;\n \n // Should have created bead for missed item\n assert!(bridge.mapping.contains_key(\"mr:gitlab:123:847\"));\n}\n\n#[test]\nfn stale_cursor_triggers_reconciliation() {\n let lore = MockLoreCli::with_fixture(\"me_stale_cursor.json\");\n // Fixture has: empty since_last_check BUT has open items\n \n let bridge = BridgeState::init(lore, beads, mapping)?;\n \n // Should have detected stale cursor and run full reconciliation\n assert!(bridge.did_run_full_reconciliation());\n}\n\n#[test]\nfn cursor_advances_only_on_success() {\n let mut bridge = setup_bridge();\n let old_cursor = bridge.cursor().clone();\n \n // Process events\n bridge.process_incremental()?;\n \n let new_cursor = bridge.cursor();\n assert!(new_cursor.last_check_timestamp > old_cursor.last_check_timestamp);\n}\n\n#[test]\nfn cursor_unchanged_on_failure() {\n let lore = MockLoreCli::that_fails();\n let mut bridge = setup_bridge_with(lore);\n let old_cursor = bridge.cursor().clone();\n \n let result = bridge.process_incremental();\n \n assert!(result.is_err());\n assert_eq!(bridge.cursor(), &old_cursor, \"Cursor should not advance on failure\");\n}\n```\n\n**Implementation (GREEN):**\n\n```rust\nimpl BridgeState {\n pub fn process_incremental(&mut self) -> Result<()> {\n let response = self.lore.me()?;\n \n // Check for stale cursor (empty since_last_check but open items exist)\n if response.data.since_last_check.is_empty() \n && (!response.data.issues.is_empty() || !response.data.mrs.reviewing.is_empty()) {\n return self.run_full_reconciliation();\n }\n \n // Process incremental events\n for event in &response.data.since_last_check {\n self.process_event(event.clone())?;\n }\n \n // Advance cursor only after all events processed\n self.cursor.last_check_timestamp = Utc::now();\n \n Ok(())\n }\n \n pub fn run_full_reconciliation(&mut self) -> Result<()> {\n let issues = self.lore.me_issues()?;\n let mrs = self.lore.me_mrs()?;\n \n let all_items: Vec<_> = issues.into_iter()\n .chain(mrs.into_iter())\n .collect();\n \n self.reconcile(&all_items)?;\n self.cursor.last_reconciliation = Utc::now();\n \n Ok(())\n }\n \n pub fn should_run_reconciliation(&self) -> bool {\n // Every 6 hours\n let hours_since = (Utc::now() - self.cursor.last_reconciliation).num_hours();\n hours_since >= 6\n }\n}\n```\n\n**Cursor semantics:**\n| Operation | Cursor Update |\n|-----------|---------------|\n| Successful incremental sync | Advance `last_check_timestamp` |\n| Successful full reconciliation | Advance `last_reconciliation` |\n| Partial/failed sync | **Do not advance** (retry will reprocess) |\n\n**Acceptance criteria:**\n- Startup runs full reconciliation\n- 6-hour timer triggers reconciliation\n- Stale cursor detected and handled\n- Cursor only advances on success","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:28:52.167404Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:30:22.854619Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2pt","depends_on_id":"bd-1q7","type":"blocks","created_at":"2026-02-25T20:30:22.854570Z","created_by":"tayloreernisse"},{"issue_id":"bd-2pt","depends_on_id":"bd-2us","type":"blocks","created_at":"2026-02-25T20:30:20.713786Z","created_by":"tayloreernisse"}]} +{"id":"bd-2sj","title":"Implement br CLI wrapper for bead operations","description":"# Beads CLI Wrapper (RED → GREEN)\n\n**Parent epic:** Phase 2: Bridge + Data Layer\n\n**Why CLI wrapper:**\nSame pattern as lore — shell out to br CLI instead of importing beads as library.\nThis provides clean boundaries and guaranteed compatibility.\n\n**TDD: br CLI tests (RED first):**\n\n```rust\n#[test]\nfn parse_br_create_success() {\n let fixture = include_str!(\"fixtures/br/create_success.json\");\n let result: BrCreateResponse = serde_json::from_str(fixture).unwrap();\n \n assert!(result.id.starts_with(\"br-\"));\n}\n\n#[test]\nfn parse_br_create_error() {\n let fixture = include_str!(\"fixtures/br/create_error.json\");\n let result: Result = serde_json::from_str(fixture);\n \n // Error fixture should have error field\n assert!(result.is_err() || has_error_field(fixture));\n}\n\n#[test]\nfn mock_beads_records_calls() {\n let mut mock = MockBeadsCli::new();\n mock.create(\"Test\", \"gitlab\")?;\n \n assert_eq!(mock.calls().len(), 1);\n assert_eq!(mock.calls()[0], BeadsCall::Create { title: \"Test\", bead_type: \"gitlab\" });\n}\n```\n\n**Implementation (GREEN):**\n\n```rust\npub struct RealBeadsCli;\n\nimpl BeadsCli for RealBeadsCli {\n fn create(&self, title: &str, bead_type: &str) -> Result {\n let output = Command::new(\"br\")\n .args([\"create\", title, \"-t\", bead_type, \"--json\"])\n .output()?;\n \n if !output.status.success() {\n return Err(anyhow!(\"br create failed: {}\", \n String::from_utf8_lossy(&output.stderr)));\n }\n \n let response: BrCreateResponse = serde_json::from_slice(&output.stdout)?;\n Ok(response.id)\n }\n \n fn close(&self, id: &str, reason: &str) -> Result<()> {\n let output = Command::new(\"br\")\n .args([\"close\", id, \"--reason\", reason])\n .output()?;\n \n if !output.status.success() {\n return Err(anyhow!(\"br close failed\"));\n }\n \n Ok(())\n }\n \n fn exists(&self, id: &str) -> Result {\n let output = Command::new(\"br\")\n .args([\"show\", id, \"--json\"])\n .output()?;\n \n Ok(output.status.success())\n }\n}\n```\n\n**Mock implementation for tests:**\n\n```rust\npub struct MockBeadsCli {\n responses: HashMap,\n calls: Vec,\n next_id: u32,\n}\n\nimpl BeadsCli for MockBeadsCli {\n fn create(&mut self, title: &str, bead_type: &str) -> Result {\n self.calls.push(BeadsCall::Create { title: title.into(), bead_type: bead_type.into() });\n let id = format!(\"br-mock{}\", self.next_id);\n self.next_id += 1;\n Ok(id)\n }\n}\n```\n\n**Acceptance criteria:**\n- All fixture files parse successfully\n- RealBeadsCli executes actual br commands\n- MockBeadsCli records calls for verification\n- Error responses handled gracefully","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:28:13.919393Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:30:20.664564Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2sj","depends_on_id":"bd-2us","type":"blocks","created_at":"2026-02-25T20:30:20.664547Z","created_by":"tayloreernisse"}]} +{"id":"bd-2us","title":"Phase 2: Bridge + Data Layer","description":"# Bridge + Data Layer — GitLab → Beads Integration\n\n**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.\n\n**Why this matters:**\nThe 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.\n\n**Duration estimate:** 2-3 days\n\n**Core components:**\n1. **State Machine** — Lifecycle: (new) → active → suspect_orphan → closed\n2. **Mapping File** — Persistent {gitlab_key} → {bead_id} mapping\n3. **Two-Strike Rule** — Items must be missing 2 consecutive reconciliations to auto-close\n4. **Crash Recovery** — Write-ahead pattern with pending flag\n5. **Reconciliation** — Periodic full sync to heal missed events\n6. **Decision Logging** — Append-only log of all user decisions\n\n**Invariants (must ALWAYS hold):**\n- INV-1: No duplicate beads (each key maps to exactly one bead)\n- INV-2: No orphan beads (every bead_id exists in beads)\n- INV-3: No false closes (two-strike rule enforced)\n- INV-4: Cursor monotonicity (never moves backward)\n\n**Dependencies:**\n- Requires Phase 1 (Foundation) complete\n- Blocks all view phases (Focus, Queue, etc.)\n\n**Acceptance criteria:**\n- Bridge creates beads from GitLab events\n- Two-strike auto-close works correctly\n- Crash recovery handles all failure scenarios\n- Reconciliation heals missed events\n- Single-instance lock prevents race conditions\n- Decision log captures all actions","status":"open","priority":2,"issue_type":"epic","created_at":"2026-02-25T20:27:22.704633Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:27:26.006028Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2us","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:27:26.006003Z","created_by":"tayloreernisse"}]} +{"id":"bd-2vw","title":"Display raw lore data in UI","description":"# Basic UI Showing Lore Data (Visual Verification)\n\n**Parent epic:** Phase 1: Foundation\n\n**Why this task:**\nBefore building real views, we need to prove the full data pipeline works:\nFrontend → Tauri IPC → Rust backend → lore CLI → parsed data → back to UI\n\nThis is visual verification (no automated tests) — we're proving plumbing works.\n\n**Implementation:**\n\n1. **Tauri command to fetch lore data:**\n ```rust\n // src-tauri/src/commands/work_items.rs\n #[tauri::command]\n pub async fn get_lore_data(\n lore: State<'_, Box>,\n ) -> Result {\n lore.me().map_err(|e| e.to_string())\n }\n ```\n\n2. **Frontend hook for data fetching:**\n ```typescript\n // src/hooks/useLoreData.ts\n import { invoke } from '@tauri-apps/api/core';\n import { useQuery } from '@tanstack/react-query';\n \n export function useLoreData() {\n return useQuery({\n queryKey: ['lore-data'],\n queryFn: () => invoke('get_lore_data'),\n refetchInterval: false, // Manual refetch on lore-db-changed\n });\n }\n ```\n\n3. **Listen for file watcher events:**\n ```typescript\n // src/hooks/useTauriEvents.ts\n import { listen } from '@tauri-apps/api/event';\n \n export function useLoreRefresh(queryClient: QueryClient) {\n useEffect(() => {\n const unlisten = listen('lore-db-changed', () => {\n queryClient.invalidateQueries({ queryKey: ['lore-data'] });\n });\n \n return () => { unlisten.then(fn => fn()); };\n }, [queryClient]);\n }\n ```\n\n4. **Basic debug UI component:**\n ```tsx\n // src/components/DebugView.tsx\n export function DebugView() {\n const { data, isLoading, error } = useLoreData();\n \n if (isLoading) return
Loading...
;\n if (error) return
Error: {error.message}
;\n \n return (\n
\n

Reviews ({data.mrs.reviewing.length})

\n
{JSON.stringify(data.mrs.reviewing, null, 2)}
\n \n

Issues ({data.issues.length})

\n
{JSON.stringify(data.issues, null, 2)}
\n \n

Since Last Check ({data.since_last_check.length})

\n
{JSON.stringify(data.since_last_check, null, 2)}
\n
\n );\n }\n ```\n\n**Verification checklist:**\n- [ ] App starts without errors\n- [ ] Data appears in UI after ~1 second\n- [ ] Running `lore sync` causes UI to refresh\n- [ ] Error states show meaningful messages\n- [ ] Console has no TypeScript errors\n\n**Acceptance criteria:**\n- Full data pipeline works end-to-end\n- UI shows raw JSON data from lore\n- File watcher triggers refresh\n- Ready to build real views on top","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:27:03.716160Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:27:09.230315Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2vw","depends_on_id":"bd-1ev","type":"blocks","created_at":"2026-02-25T20:27:09.210985Z","created_by":"tayloreernisse"},{"issue_id":"bd-2vw","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:27:07.988696Z","created_by":"tayloreernisse"},{"issue_id":"bd-2vw","depends_on_id":"bd-xvy","type":"blocks","created_at":"2026-02-25T20:27:09.230298Z","created_by":"tayloreernisse"}]} +{"id":"bd-2x6","title":"Implement Zustand store with persistence","description":"# Zustand Store with Persistence\n\n**Parent epic:** Phase 1: Foundation\n\n**Purpose:** Central state management for MC. Zustand provides simple, scalable state with persistence support.\n\n**Store slices:**\n\n```typescript\ninterface MCStore {\n // Focus state\n focusId: string | null;\n setFocus: (id: string) => void;\n clearFocus: () => void;\n \n // Items (cached from lore + manual)\n items: WorkItem[];\n setItems: (items: WorkItem[]) => void;\n updateItem: (id: string, updates: Partial) => void;\n \n // Queue order (manual priority)\n queueOrder: string[];\n reorderQueue: (fromIndex: number, toIndex: number) => void;\n \n // UI state\n activeView: 'focus' | 'queue' | 'inbox' | 'settings';\n setActiveView: (view: string) => void;\n \n // Sync state\n syncStatus: SyncState;\n lastSync: Date | null;\n syncError: string | null;\n setSyncStatus: (status: SyncState, error?: string) => void;\n}\n```\n\n**TDD: Store tests (RED first):**\n\n```typescript\n// tests/unit/store.test.ts\ndescribe('MCStore', () => {\n beforeEach(() => {\n useStore.setState(initialState);\n });\n \n describe('focus', () => {\n it('sets focus id', () => {\n const { setFocus } = useStore.getState();\n setFocus('br-123');\n \n expect(useStore.getState().focusId).toBe('br-123');\n });\n \n it('clears focus', () => {\n useStore.setState({ focusId: 'br-123' });\n const { clearFocus } = useStore.getState();\n \n clearFocus();\n \n expect(useStore.getState().focusId).toBeNull();\n });\n });\n \n describe('queue order', () => {\n it('reorders items', () => {\n useStore.setState({ queueOrder: ['a', 'b', 'c', 'd'] });\n const { reorderQueue } = useStore.getState();\n \n reorderQueue(2, 0); // Move 'c' to front\n \n expect(useStore.getState().queueOrder).toEqual(['c', 'a', 'b', 'd']);\n });\n \n it('persists order to state.json', async () => {\n const { reorderQueue } = useStore.getState();\n reorderQueue(0, 1);\n \n // Wait for persistence\n await new Promise(r => setTimeout(r, 100));\n \n const saved = await invoke('read_state');\n expect(saved.queueOrder).toEqual(useStore.getState().queueOrder);\n });\n });\n \n describe('items', () => {\n it('updates single item', () => {\n useStore.setState({ items: [{ id: '1', title: 'Test' }] });\n const { updateItem } = useStore.getState();\n \n updateItem('1', { snoozedUntil: new Date() });\n \n expect(useStore.getState().items[0].snoozedUntil).toBeDefined();\n });\n });\n \n describe('persistence', () => {\n it('loads state from state.json on init', async () => {\n const savedState = { focusId: 'br-saved', queueOrder: ['a', 'b'] };\n vi.mocked(invoke).mockResolvedValue(savedState);\n \n await initStore();\n \n expect(useStore.getState().focusId).toBe('br-saved');\n });\n });\n});\n```\n\n**Implementation:**\n\n```typescript\n// src/store/index.ts\nimport { create } from 'zustand';\nimport { persist, createJSONStorage } from 'zustand/middleware';\nimport { invoke } from '@tauri-apps/api/core';\n\n// Custom storage that uses Tauri backend\nconst tauriStorage = createJSONStorage(() => ({\n getItem: async (name) => {\n const state = await invoke('read_state');\n return JSON.stringify(state);\n },\n setItem: async (name, value) => {\n await invoke('write_state', { state: JSON.parse(value) });\n },\n removeItem: async (name) => {\n await invoke('clear_state');\n },\n}));\n\nexport const useStore = create()(\n persist(\n (set, get) => ({\n // Focus\n focusId: null,\n setFocus: (id) => set({ focusId: id }),\n clearFocus: () => set({ focusId: null }),\n \n // Items\n items: [],\n setItems: (items) => set({ items }),\n updateItem: (id, updates) => set(state => ({\n items: state.items.map(i => i.id === id ? { ...i, ...updates } : i)\n })),\n \n // Queue order\n queueOrder: [],\n reorderQueue: (from, to) => set(state => {\n const newOrder = [...state.queueOrder];\n const [removed] = newOrder.splice(from, 1);\n newOrder.splice(to, 0, removed);\n return { queueOrder: newOrder };\n }),\n \n // UI\n activeView: 'focus',\n setActiveView: (view) => set({ activeView: view }),\n \n // Sync\n syncStatus: 'synced',\n lastSync: null,\n syncError: null,\n setSyncStatus: (status, error) => set({ \n syncStatus: status, \n syncError: error,\n lastSync: status === 'synced' ? new Date() : get().lastSync\n }),\n }),\n {\n name: 'mc-state',\n storage: tauriStorage,\n partialize: (state) => ({\n focusId: state.focusId,\n queueOrder: state.queueOrder,\n activeView: state.activeView,\n }),\n }\n )\n);\n```\n\n**Acceptance criteria:**\n- All store actions work correctly\n- State persists to ~/.local/share/mc/state.json\n- Loads persisted state on startup\n- Queue order persists across sessions","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:51:57.414784Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:53:42.026033Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2x6","depends_on_id":"bd-3fd","type":"blocks","created_at":"2026-02-25T20:53:42.026016Z","created_by":"tayloreernisse"},{"issue_id":"bd-2x6","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:53:41.996038Z","created_by":"tayloreernisse"}]} +{"id":"bd-2zu","title":"Implement decision log infrastructure","description":"# Decision Logging Infrastructure (RED → GREEN)\n\n**Parent epic:** Phase 2: Bridge + Data Layer\n\n**Philosophy:** Manual-first, learn from data. We don't know the right prioritization algorithm yet. Instead:\n1. User manually sets THE ONE THING\n2. MC logs every decision with context and reasoning\n3. Post-process logs to extract patterns (future)\n4. Eventually codify patterns into suggestions (future)\n\n**Log file:** `~/.local/share/mc/decision_log.jsonl`\n\n**Actions to log:**\n| Action | What to Capture |\n|--------|-----------------|\n| `set_focus` | Which bead, why, what else was available |\n| `reorder` | Old order, new order, why |\n| `defer` | Which bead, duration, why |\n| `snooze` | Which bead, until when, why |\n| `skip` | Which bead, why (explicitly chose not to do it) |\n| `complete` | Which bead, duration if tracked, notes |\n| `create_manual` | New bead from quick capture |\n| `change_priority` | Old priority, new priority, why |\n\n**TDD: Decision log tests (RED first):**\n\n```rust\n#[test]\nfn log_entry_appended() {\n let temp = tempfile::tempdir().unwrap();\n let log_path = temp.path().join(\"decisions.jsonl\");\n let logger = DecisionLogger::new(&log_path)?;\n \n logger.log(DecisionEntry {\n action: Action::SetFocus,\n bead_id: \"br-x7f\".into(),\n reason: Some(\"Sarah is blocked\".into()),\n tags: vec![\"blocking\".into(), \"urgent\".into()],\n context: context_snapshot(),\n })?;\n \n let content = std::fs::read_to_string(&log_path)?;\n let entries: Vec = content.lines()\n .map(|l| serde_json::from_str(l).unwrap())\n .collect();\n \n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].action, Action::SetFocus);\n}\n\n#[test]\nfn multiple_logs_append_not_overwrite() {\n let temp = tempfile::tempdir().unwrap();\n let log_path = temp.path().join(\"decisions.jsonl\");\n let logger = DecisionLogger::new(&log_path)?;\n \n logger.log(entry1)?;\n logger.log(entry2)?;\n logger.log(entry3)?;\n \n let content = std::fs::read_to_string(&log_path)?;\n assert_eq!(content.lines().count(), 3);\n}\n\n#[test]\nfn context_snapshot_captured() {\n let temp = tempfile::tempdir().unwrap();\n let log_path = temp.path().join(\"decisions.jsonl\");\n let logger = DecisionLogger::new(&log_path)?;\n \n let context = DecisionContext {\n queue_snapshot: vec![item1, item2, item3],\n time_of_day: TimeOfDay::Morning,\n day_of_week: Weekday::Tuesday,\n items_completed_today: 3,\n focus_session_duration: Some(Duration::from_secs(2700)), // 45 min\n };\n \n logger.log(DecisionEntry {\n context,\n ..Default::default()\n })?;\n \n let content = std::fs::read_to_string(&log_path)?;\n let entry: DecisionEntry = serde_json::from_str(content.lines().next().unwrap())?;\n \n assert_eq!(entry.context.items_completed_today, 3);\n}\n```\n\n**Implementation (GREEN):**\n\n```rust\n#[derive(Serialize, Deserialize, Debug)]\npub struct DecisionEntry {\n pub timestamp: DateTime,\n pub action: Action,\n pub bead_id: String,\n pub reason: Option,\n pub tags: Vec,\n pub context: DecisionContext,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct DecisionContext {\n pub queue_snapshot: Vec,\n pub time_of_day: TimeOfDay,\n pub day_of_week: Weekday,\n pub focus_session_duration: Option,\n pub items_completed_today: u32,\n pub previous_focus: Option,\n}\n\npub struct DecisionLogger {\n file: std::fs::File,\n}\n\nimpl DecisionLogger {\n pub fn new(path: &Path) -> Result {\n let file = OpenOptions::new()\n .create(true)\n .append(true)\n .open(path)?;\n Ok(Self { file })\n }\n \n pub fn log(&self, entry: DecisionEntry) -> Result<()> {\n let mut entry = entry;\n entry.timestamp = Utc::now();\n \n let json = serde_json::to_string(&entry)?;\n writeln!(&self.file, \"{}\", json)?;\n \n Ok(())\n }\n}\n```\n\n**Example log entry:**\n```json\n{\n \"timestamp\": \"2026-02-25T10:30:00Z\",\n \"action\": \"set_focus\",\n \"bead_id\": \"br-x7f\",\n \"reason\": \"Sarah pinged me on Slack, she's blocked\",\n \"tags\": [\"blocking\", \"urgent\"],\n \"context\": {\n \"previous_focus\": \"br-a3b\",\n \"queue_size\": 12,\n \"time_of_day\": \"morning\",\n \"day_of_week\": \"Tuesday\",\n \"items_completed_today\": 3\n }\n}\n```\n\n**Log retention (v1):**\n- Accept unbounded growth (18 MB/year at 100 decisions/day)\n- Future: rotate at 50 MB, time-based pruning\n\n**Acceptance criteria:**\n- Append-only JSONL format\n- All action types logged with context\n- No data loss on crash (flush after each write)\n- Queryable for pattern extraction (future)","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:29:59.639352Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:30:20.790001Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2zu","depends_on_id":"bd-2us","type":"blocks","created_at":"2026-02-25T20:30:20.789955Z","created_by":"tayloreernisse"}]} +{"id":"bd-318","title":"Implement QueueView container","description":"# QueueView Container\n\n**Parent epic:** Phase 4: Queue + Inbox Views\n\n**Purpose:** Container component that assembles QueueList with filtering, batch trigger, and navigation.\n\n**TDD: QueueView tests (RED first):**\n\n```typescript\n// tests/views/QueueView.test.tsx\ndescribe('QueueView', () => {\n it('renders QueueList with items grouped by type', () => {\n const store = createStore({ items: mockItems });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByText('REVIEWS (2)')).toBeInTheDocument();\n expect(screen.getByText('ASSIGNED ISSUES (1)')).toBeInTheDocument();\n });\n \n it('filters items when filter applied', async () => {\n const store = createStore({ items: mockItems });\n render(, { wrapper: StoreProvider(store) });\n \n await userEvent.keyboard('{Meta>}k{/Meta}');\n await userEvent.type(screen.getByRole('textbox'), 'type:review{Enter}');\n \n expect(screen.getByText('REVIEWS (2)')).toBeInTheDocument();\n expect(screen.queryByText('ASSIGNED ISSUES')).not.toBeInTheDocument();\n });\n \n it('hides snoozed items by default', () => {\n const items = [\n ...mockItems,\n { id: '99', snoozedUntil: new Date(Date.now() + 3600000) }\n ];\n const store = createStore({ items });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.queryByText(items[2].title)).not.toBeInTheDocument();\n });\n \n it('shows empty state when no items', () => {\n const store = createStore({ items: [] });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByText(/Queue is empty/)).toBeInTheDocument();\n });\n \n it('batch button enters batch mode', async () => {\n const setActiveView = vi.fn();\n const store = createStore({ items: mockReviewItems, setActiveView });\n render(, { wrapper: StoreProvider(store) });\n \n await userEvent.click(screen.getByRole('button', { name: /batch all/i }));\n \n // Should show batch mode (or navigate to it)\n expect(screen.getByText('BATCH:')).toBeInTheDocument();\n });\n});\n```\n\n**Implementation:**\n\n```tsx\n// src/views/QueueView.tsx\nexport function QueueView() {\n const { items, queueOrder, setFocus, setActiveView } = useStore();\n const [filter, setFilter] = useState(null);\n const [showCommandPalette, setShowCommandPalette] = useState(false);\n const [batchType, setBatchType] = useState(null);\n \n useKeyboardShortcuts({\n 'mod+k': () => setShowCommandPalette(true),\n });\n \n // Filter and sort items\n const visibleItems = useMemo(() => {\n let filtered = items\n .filter(i => !i.snoozedUntil || new Date(i.snoozedUntil) < new Date())\n .filter(i => !i.skippedToday);\n \n if (filter?.type) {\n filtered = filtered.filter(i => i.type === filter.type);\n }\n \n // Sort by queue order, then by age\n return filtered.sort((a, b) => {\n const aIdx = queueOrder.indexOf(a.id);\n const bIdx = queueOrder.indexOf(b.id);\n if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx;\n if (aIdx !== -1) return -1;\n if (bIdx !== -1) return 1;\n return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();\n });\n }, [items, queueOrder, filter]);\n \n const handleSetFocus = (id: string) => {\n setFocus(id);\n setActiveView('focus');\n };\n \n const handleBatchStart = (type: ItemType) => {\n setBatchType(type);\n };\n \n if (batchType) {\n const batchItems = visibleItems.filter(i => i.type === batchType);\n return (\n setBatchType(null)}\n />\n );\n }\n \n if (visibleItems.length === 0) {\n return (\n
\n \n

Queue is empty

\n

All caught up!

\n
\n );\n }\n \n return (\n
\n
\n

Queue

\n \n
\n \n {filter && (\n
\n {formatFilter(filter)}\n \n
\n )}\n \n \n \n {showCommandPalette && (\n setShowCommandPalette(false)}\n />\n )}\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- Items display grouped by type\n- Filter works via command palette\n- Snoozed items hidden\n- Empty state shows\n- Batch mode triggers correctly\n- Click sets focus and navigates","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:54:23.048976Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:54:48.312782Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-318","depends_on_id":"bd-2cl","type":"blocks","created_at":"2026-02-25T20:54:48.235273Z","created_by":"tayloreernisse"},{"issue_id":"bd-318","depends_on_id":"bd-3ua","type":"blocks","created_at":"2026-02-25T20:54:48.271546Z","created_by":"tayloreernisse"},{"issue_id":"bd-318","depends_on_id":"bd-716","type":"blocks","created_at":"2026-02-25T20:54:48.201245Z","created_by":"tayloreernisse"},{"issue_id":"bd-318","depends_on_id":"bd-j76","type":"blocks","created_at":"2026-02-25T20:54:48.312764Z","created_by":"tayloreernisse"}]} +{"id":"bd-35u","title":"Implement two-strike auto-close for GitLab state changes","description":"# Two-Strike Auto-Close Rule (RED → GREEN)\n\n**Parent epic:** Phase 2: Bridge + Data Layer\n\n**Problem being solved:**\nWhat if GitLab's API hiccups and temporarily says \"you have no reviews\"? Without protection, we'd delete all your tasks.\n\n**Solution:**\nItems must be missing for TWO consecutive reconciliations before auto-close.\n\n| Check #1 | Check #2 | Result |\n|----------|----------|--------|\n| Missing | Missing | Close the task (confirmed gone) |\n| Missing | Found | Keep it (was just a glitch) |\n| Found | — | Keep it (still active) |\n\n**TDD: Two-strike tests (RED first):**\n\n```rust\n#[test]\nfn first_miss_sets_suspect_orphan() {\n let mut bridge = setup_with_mapping(\"mr:gitlab:123:847\", \"br-x\");\n let lore_items = vec![]; // Item missing\n \n bridge.reconcile(&lore_items)?;\n \n let entry = bridge.mapping.get(\"mr:gitlab:123:847\").unwrap();\n assert!(entry.suspect_orphan, \"First miss should set suspect_orphan\");\n assert!(bridge.beads.exists(\"br-x\")?, \"Bead should NOT be closed yet\");\n}\n\n#[test]\nfn second_miss_closes_bead() {\n let mut bridge = setup_with_suspect_orphan(\"mr:gitlab:123:847\", \"br-x\");\n let lore_items = vec![]; // Still missing\n \n bridge.reconcile(&lore_items)?;\n \n assert!(!bridge.mapping.contains_key(\"mr:gitlab:123:847\"), \"Entry should be removed\");\n assert!(!bridge.beads.exists(\"br-x\")?, \"Bead should be closed\");\n}\n\n#[test]\nfn reappearance_clears_suspect() {\n let mut bridge = setup_with_suspect_orphan(\"mr:gitlab:123:847\", \"br-x\");\n let lore_items = vec![mr_item(\"mr:gitlab:123:847\")]; // Item reappears\n \n bridge.reconcile(&lore_items)?;\n \n let entry = bridge.mapping.get(\"mr:gitlab:123:847\").unwrap();\n assert!(!entry.suspect_orphan, \"Reappearance should clear suspect flag\");\n}\n\n#[test]\nfn auto_close_includes_reason() {\n let mut beads = MockBeadsCli::new();\n let mut bridge = setup_with_suspect_orphan_and(&mut beads, \"mr:gitlab:123:847\", \"br-x\");\n \n bridge.reconcile(&[])?;\n \n let close_call = beads.calls().iter().find(|c| matches!(c, BeadsCall::Close { .. }));\n assert!(close_call.is_some());\n if let Some(BeadsCall::Close { reason, .. }) = close_call {\n assert!(reason.contains(\"MR\") || reason.contains(\"GitLab\"));\n }\n}\n\n#[test]\nfn invariant_inv3_no_false_closes() {\n // Fuzz test: random sequences of present/missing should never violate INV-3\n let mut bridge = setup_with_mapping(\"mr:gitlab:123:847\", \"br-x\");\n \n // Single miss should never close\n bridge.reconcile(&[])?;\n assert!(bridge.beads.exists(\"br-x\")?);\n \n // Reappearance after single miss should preserve bead\n bridge.reconcile(&[mr_item(\"mr:gitlab:123:847\")])?;\n assert!(bridge.beads.exists(\"br-x\")?);\n \n // Only double-miss should close\n bridge.reconcile(&[])?; // Miss 1\n bridge.reconcile(&[])?; // Miss 2 → close\n assert!(!bridge.beads.exists(\"br-x\")?);\n}\n```\n\n**Implementation (GREEN):**\n\n```rust\nimpl BridgeState {\n pub fn reconcile(&mut self, current_items: &[LoreItem]) -> Result<()> {\n let expected_keys: HashSet<_> = current_items\n .iter()\n .map(|i| i.to_mapping_key())\n .collect();\n \n let mut to_close = vec![];\n \n for (key, entry) in self.mapping.iter_mut() {\n if expected_keys.contains(key) {\n // Item still exists — clear any suspect flag\n entry.suspect_orphan = false;\n } else if entry.suspect_orphan {\n // Second miss — schedule for closure\n to_close.push((key.clone(), entry.bead_id.clone()));\n } else {\n // First miss — set suspect flag\n entry.suspect_orphan = true;\n }\n }\n \n // Close confirmed orphans\n for (key, bead_id) in to_close {\n if let Some(id) = bead_id {\n self.beads.close(&id, \"Item no longer in GitLab\")?;\n }\n self.mapping.remove(&key);\n }\n \n // Add new items not in mapping\n for item in current_items {\n let key = item.to_mapping_key();\n if !self.mapping.contains_key(&key) {\n // ... create bead\n }\n }\n \n Ok(())\n }\n}\n```\n\n**Acceptance criteria:**\n- First miss only sets flag, never closes\n- Second miss closes bead with descriptive reason\n- Reappearance clears suspect flag\n- Invariant INV-3 never violated","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:28:34.240322Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:30:22.698037Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-35u","depends_on_id":"bd-1q7","type":"blocks","created_at":"2026-02-25T20:30:22.698016Z","created_by":"tayloreernisse"},{"issue_id":"bd-35u","depends_on_id":"bd-2us","type":"blocks","created_at":"2026-02-25T20:30:20.690779Z","created_by":"tayloreernisse"}]} +{"id":"bd-3c2","title":"Phase 5: Batch Mode","description":"# Batch Mode — Flow State for Similar Tasks\n\n**Context:** Batch Mode enables rapid completion of similar items (e.g., all code reviews) by presenting them one at a time with streamlined actions.\n\n**UX principle:** \"You have 4 code reviews. Want to batch them? (~20 min)\"\n\n**Duration estimate:** 1-2 days\n\n**Visual design:**\n```\n┌─────────────────────────────────────────────────────────────┐\n│ BATCH: CODE REVIEWS │\n│ 1 of 4 · 25 min │\n│ ━━━━━━━━━━░░░░░░░░░░ │\n├─────────────────────────────────────────────────────────────┤\n│ │\n│ Fix authentication token refresh logic │\n│ !847 in platform/core │\n│ │\n│ 47 lines changed across 3 files │\n│ │\n│ ┌───────────────┐ ┌───────────────┐ ┌───────────┐ │\n│ │ Open in GL │ │ Done │ │ Skip │ │\n│ │ ⌘O │ │ ⌘D │ │ ⌘S │ │\n│ └───────────────┘ └───────────────┘ └───────────┘ │\n│ │\n│ ESC to exit batch │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**Scope includes:**\n1. Full-screen batch interface\n2. Progress bar tracking\n3. Rapid completion flow (Done → next automatically)\n4. Completion celebration (confetti, sound?)\n\n**Behavior:**\n- \"Open in GL\" opens review in browser\n- \"Done\" marks complete, advances to next\n- \"Skip\" advances without completing\n- ESC exits batch at any point\n- Completion shows celebration\n\n**Dependencies:**\n- Requires Phase 4 (Queue) for batch trigger\n- Reuses FocusCard-like component\n\n**Acceptance criteria:**\n- Batch mode shows items one at a time\n- Progress bar updates correctly\n- Done/Skip advance flow\n- ESC exits cleanly\n- Completion celebration works","status":"open","priority":2,"issue_type":"epic","created_at":"2026-02-25T20:33:46.899135Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:33:50.303340Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3c2","depends_on_id":"bd-716","type":"blocks","created_at":"2026-02-25T20:33:50.303322Z","created_by":"tayloreernisse"}]} +{"id":"bd-3dp","title":"Implement drag-to-reorder with priority logging","description":"# Drag-to-Reorder with Decision Logging (RED → GREEN)\n\n**Parent epic:** Phase 4: Queue + Inbox Views\n\n**Purpose:** Manual priority via drag and drop. Every reorder is logged with context for pattern learning.\n\n**TDD: Drag reorder tests (RED first):**\n\n```typescript\n// tests/components/QueueList.test.tsx\ndescribe('QueueList drag reorder', () => {\n it('reorders items on drag', async () => {\n const onReorder = vi.fn();\n render();\n \n const items = screen.getAllByTestId('queue-item');\n \n // Simulate drag item[2] to position 0\n await userEvent.pointer([\n { keys: '[MouseLeft>]', target: items[2] },\n { target: items[0] },\n { keys: '[/MouseLeft]' },\n ]);\n \n expect(onReorder).toHaveBeenCalledWith({\n itemId: '3',\n oldIndex: 2,\n newIndex: 0,\n oldOrder: ['1', '2', '3', '4'],\n newOrder: ['3', '1', '2', '4'],\n });\n });\n \n it('persists order after reorder', async () => {\n const mockPersist = vi.fn();\n render();\n \n // Perform drag\n // ...\n \n // Verify persistence called\n expect(mockPersist).toHaveBeenCalled();\n \n // Re-render with new order - should match\n rerender();\n const items = screen.getAllByTestId('queue-item');\n expect(items[0]).toHaveTextContent('Issue #312');\n });\n \n it('logs reorder decision', async () => {\n const mockLogDecision = vi.fn();\n vi.mock('../store', () => ({ useStore: () => ({ logDecision: mockLogDecision }) }));\n \n render();\n \n // Perform drag\n // ...\n \n expect(mockLogDecision).toHaveBeenCalledWith(expect.objectContaining({\n action: 'reorder',\n old_order: ['1', '2', '3', '4'],\n new_order: ['3', '1', '2', '4'],\n }));\n });\n \n it('prompts for reason after reorder', async () => {\n render();\n \n // Perform drag\n // ...\n \n // ReasonPrompt should appear\n expect(screen.getByText(/Why did you move/)).toBeInTheDocument();\n });\n});\n```\n\n**Implementation (GREEN):**\n\n```tsx\n// src/components/QueueList.tsx\nimport { DndContext, closestCenter, DragEndEvent } from '@dnd-kit/core';\nimport { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';\n\nexport function QueueList({ items, onItemClick, onReorder }: QueueListProps) {\n const [showReasonPrompt, setShowReasonPrompt] = useState(false);\n const [pendingReorder, setPendingReorder] = useState(null);\n \n const handleDragEnd = (event: DragEndEvent) => {\n const { active, over } = event;\n if (!over || active.id === over.id) return;\n \n const oldIndex = items.findIndex(i => i.id === active.id);\n const newIndex = items.findIndex(i => i.id === over.id);\n \n const reorderData: ReorderData = {\n itemId: active.id as string,\n oldIndex,\n newIndex,\n oldOrder: items.map(i => i.id),\n newOrder: arrayMove(items, oldIndex, newIndex).map(i => i.id),\n };\n \n // Apply reorder immediately for responsiveness\n onReorder(reorderData);\n \n // Prompt for reason\n setPendingReorder(reorderData);\n setShowReasonPrompt(true);\n };\n \n const handleReasonSubmit = ({ reason, tags }) => {\n if (pendingReorder) {\n invoke('log_decision', {\n entry: {\n action: 'reorder',\n ...pendingReorder,\n reason,\n tags,\n context: captureContext(),\n }\n });\n }\n setShowReasonPrompt(false);\n setPendingReorder(null);\n };\n \n return (\n \n i.id)} strategy={verticalListSortingStrategy}>\n {items.map(item => (\n onItemClick(item.id)} />\n ))}\n \n \n {showReasonPrompt && (\n i.id === pendingReorder?.itemId)?.title ?? ''}\n onSubmit={handleReasonSubmit}\n onCancel={() => setShowReasonPrompt(false)}\n />\n )}\n \n );\n}\n```\n\n**Acceptance criteria:**\n- Drag and drop works smoothly\n- Order persists across refreshes\n- Reorder decision logged with context\n- Reason prompt appears after reorder","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:32:56.495815Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:33:34.955612Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3dp","depends_on_id":"bd-2cl","type":"blocks","created_at":"2026-02-25T20:33:34.936288Z","created_by":"tayloreernisse"},{"issue_id":"bd-3dp","depends_on_id":"bd-2zu","type":"blocks","created_at":"2026-02-25T20:33:34.955596Z","created_by":"tayloreernisse"},{"issue_id":"bd-3dp","depends_on_id":"bd-716","type":"blocks","created_at":"2026-02-25T20:33:34.869140Z","created_by":"tayloreernisse"}]} +{"id":"bd-3em","title":"Configure Vitest for frontend testing","description":"# Vitest Configuration for React Frontend\n\n**Parent epic:** Phase 0: Test Infrastructure\n\n**Why Vitest:**\n- Fast, native ESM support\n- Jest-compatible API\n- Great React Testing Library integration\n- Works seamlessly with Vite\n\n**What to configure:**\n1. Install dependencies: vitest, @testing-library/react, @testing-library/user-event, jsdom\n2. Create vitest.config.ts with:\n - jsdom environment for DOM testing\n - Coverage configuration (v8 provider)\n - Test file patterns (*.test.ts, *.test.tsx)\n - Setup file for global test utilities\n3. Create test setup file (tests/setup.ts):\n - Import @testing-library/jest-dom matchers\n - Configure cleanup after each test\n - Set up mock for Tauri IPC\n4. Add test scripts to package.json:\n - \"test\": \"vitest run\"\n - \"test:watch\": \"vitest\"\n - \"test:coverage\": \"vitest run --coverage\"\n\n**Coverage targets:**\n- Frontend hooks: 85%\n- Frontend components: 70%\n\n**Acceptance criteria:**\n- Running `npm run test` executes Vitest\n- Test files in tests/unit/ and tests/components/ are discovered\n- Coverage report generates to coverage/ directory\n- @testing-library matchers work (toBeInTheDocument, etc.)","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:24:57.886903Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:25:36.199136Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3em","depends_on_id":"bd-jyz","type":"blocks","created_at":"2026-02-25T20:25:36.199117Z","created_by":"tayloreernisse"}]} +{"id":"bd-3fd","title":"Scaffold Tauri + Vite + React project","description":"# Project Scaffolding — Tauri 2.0 + React 19 + Vite\n\n**Parent epic:** Phase 1: Foundation\n\n**Why this stack:**\n- Tauri 2.0: Rust backend, tiny bundles (~15MB), native APIs, system tray, global hotkeys\n- React 19: Latest concurrent features, huge ecosystem, AI-friendly for iteration\n- Vite: Fast dev server, instant HMR, native ESM\n\n**Scaffolding steps:**\n\n1. **Initialize Tauri + Vite project:**\n ```bash\n npm create tauri-app@latest mission-control -- --template react-ts\n ```\n\n2. **Install core dependencies:**\n ```bash\n npm install @tauri-apps/api @tauri-apps/plugin-shell\n npm install -D @tauri-apps/cli\n ```\n\n3. **Install frontend dependencies:**\n ```bash\n npm install react@19 react-dom@19 \n npm install @tanstack/react-query zustand\n npm install tailwindcss postcss autoprefixer\n npm install framer-motion\n ```\n\n4. **Install shadcn/ui:**\n ```bash\n npx shadcn-ui@latest init\n ```\n\n5. **Configure Tailwind:**\n - tailwind.config.js with content paths\n - Dark mode: 'class' strategy\n - postcss.config.js\n\n6. **Configure Tauri (tauri.conf.json):**\n - Bundle identifier: com.mission-control.app\n - Window: hiddenTitle=true, decorations=false (custom titlebar)\n - System tray: enabled\n - Permissions for shell plugin, fs plugin\n\n**File structure after scaffold:**\n```\nmission-control/\n├── src-tauri/\n│ ├── src/main.rs\n│ ├── Cargo.toml\n│ └── tauri.conf.json\n├── src/\n│ ├── App.tsx\n│ └── main.tsx\n├── package.json\n└── vite.config.ts\n```\n\n**Acceptance criteria:**\n- `npm run tauri dev` launches working app\n- React renders in Tauri webview\n- Tailwind classes work\n- shadcn components available\n- Basic folder structure matches PLAN.md specification","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:26:02.622371Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:27:07.891347Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3fd","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:27:07.891333Z","created_by":"tayloreernisse"}]} +{"id":"bd-3jh","title":"Implement app initialization and startup sequence","description":"# App Initialization and Startup Sequence\n\n**Parent epic:** Phase 1: Foundation\n\n**Purpose:** Proper initialization order when MC launches, including state loading, CLI verification, and recovery.\n\n**Startup sequence:**\n\n```\n1. Acquire single-instance lock\n └─ If locked → show \"already running\" dialog → exit\n \n2. Create data directories\n └─ ~/.local/share/mc/\n \n3. Load persisted state\n └─ state.json, settings.json, gitlab_bead_map.json\n └─ Handle missing/corrupt files gracefully\n \n4. Verify CLI dependencies\n └─ Check lore --version\n └─ Check br --version\n └─ Show warning if missing\n \n5. Run crash recovery\n └─ Check for pending mapping entries\n └─ Retry incomplete bead creations\n \n6. Run full reconciliation (startup sync)\n └─ Fetch all open items from lore\n └─ Heal any missed events\n \n7. Start file watcher\n └─ Watch lore.db for changes\n \n8. Initialize Tauri app\n └─ Register global shortcuts\n └─ Set up system tray\n └─ Show main window\n```\n\n**TDD: Startup tests (RED first):**\n\n```rust\n// src-tauri/tests/startup_test.rs\n\n#[tokio::test]\nasync fn startup_acquires_lock_first() {\n let temp = tempfile::tempdir().unwrap();\n let config = Config::with_data_dir(temp.path());\n \n let app1 = App::init(&config).await;\n assert!(app1.is_ok());\n \n let app2 = App::init(&config).await;\n assert!(matches!(app2, Err(InitError::AlreadyRunning)));\n}\n\n#[tokio::test]\nasync fn startup_creates_data_directories() {\n let temp = tempfile::tempdir().unwrap();\n let data_dir = temp.path().join(\"mc\");\n let config = Config::with_data_dir(&data_dir);\n \n let _app = App::init(&config).await.unwrap();\n \n assert!(data_dir.exists());\n assert!(data_dir.join(\"gitlab_bead_map.json\").exists() \n || data_dir.join(\".gitkeep\").exists());\n}\n\n#[tokio::test]\nasync fn startup_handles_missing_state_file() {\n let temp = tempfile::tempdir().unwrap();\n // Don't create state.json\n \n let app = App::init(&Config::with_data_dir(temp.path())).await;\n \n assert!(app.is_ok());\n assert_eq!(app.unwrap().state().focus_id, None);\n}\n\n#[tokio::test]\nasync fn startup_handles_corrupt_state_file() {\n let temp = tempfile::tempdir().unwrap();\n std::fs::write(temp.path().join(\"state.json\"), \"not json\").unwrap();\n \n let app = App::init(&Config::with_data_dir(temp.path())).await;\n \n // Should recover gracefully, not crash\n assert!(app.is_ok());\n}\n\n#[tokio::test]\nasync fn startup_warns_on_missing_lore() {\n let app = App::init_with_cli_check(|cmd| {\n if cmd == \"lore\" { Err(\"not found\".into()) }\n else { Ok(()) }\n }).await;\n \n assert!(app.is_ok());\n assert!(app.unwrap().warnings().contains(&Warning::LoreMissing));\n}\n\n#[tokio::test]\nasync fn startup_runs_crash_recovery() {\n let temp = tempfile::tempdir().unwrap();\n \n // Create mapping with pending entry\n let mapping = Mapping {\n mappings: hashmap! {\n \"mr_review:...\".into() => MappingEntry {\n bead_id: None,\n pending: true,\n ..Default::default()\n }\n },\n ..Default::default()\n };\n save_mapping(temp.path(), &mapping).unwrap();\n \n let beads = MockBeadsCli::new();\n let app = App::init_with_beads(beads.clone()).await.unwrap();\n \n // Should have retried the pending bead creation\n assert!(beads.was_called(\"create\"));\n}\n```\n\n**Implementation:**\n\n```rust\n// src-tauri/src/app.rs\npub struct App {\n lock: InstanceLock,\n state: AppState,\n mapping: Arc>,\n orchestrator: Arc>,\n watcher: LoreDbWatcher,\n warnings: Vec,\n}\n\nimpl App {\n pub async fn init(config: &Config) -> Result {\n // 1. Acquire lock\n let lock = InstanceLock::acquire(&config.lock_path())\n .map_err(|_| InitError::AlreadyRunning)?;\n \n // 2. Create directories\n std::fs::create_dir_all(&config.data_dir())?;\n \n // 3. Load state\n let state = AppState::load(&config.state_path())\n .unwrap_or_default();\n let mapping = Mapping::load(&config.mapping_path())\n .unwrap_or_default();\n \n // 4. Verify CLIs\n let mut warnings = vec![];\n if !verify_cli(\"lore\") {\n warnings.push(Warning::LoreMissing);\n }\n if !verify_cli(\"br\") {\n warnings.push(Warning::BrMissing);\n }\n \n // 5. Crash recovery\n let mapping = Arc::new(Mutex::new(mapping));\n recover_pending_entries(&mapping, &beads).await?;\n \n // 6. Full reconciliation\n let orchestrator = SyncOrchestrator::new(lore, beads, mapping.clone());\n orchestrator.run_full_reconciliation().await?;\n \n // 7. Start watcher\n let watcher = LoreDbWatcher::new(&config.lore_db_path(), || {\n // Trigger sync\n })?;\n \n Ok(Self {\n lock,\n state,\n mapping,\n orchestrator: Arc::new(Mutex::new(orchestrator)),\n watcher,\n warnings,\n })\n }\n}\n```\n\n**Acceptance criteria:**\n- Single-instance lock works\n- Directories created\n- Corrupt state handled gracefully\n- CLI warnings shown\n- Crash recovery runs\n- Reconciliation runs on startup","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T21:08:04.745026Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:08:15.006604Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3jh","depends_on_id":"bd-239","type":"blocks","created_at":"2026-02-25T21:08:15.006587Z","created_by":"tayloreernisse"},{"issue_id":"bd-3jh","depends_on_id":"bd-2at","type":"blocks","created_at":"2026-02-25T21:08:14.942401Z","created_by":"tayloreernisse"},{"issue_id":"bd-3jh","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T21:08:14.912298Z","created_by":"tayloreernisse"},{"issue_id":"bd-3jh","depends_on_id":"bd-z4n","type":"blocks","created_at":"2026-02-25T21:08:14.976369Z","created_by":"tayloreernisse"}]} +{"id":"bd-3jk","title":"Write E2E tests for critical user flows","description":"# E2E Tests for Critical Flows\n\n**Parent epic:** Phase 7: Polish + E2E Tests\n\n**Test files to create:**\n\n### focus-flow.spec.ts\n```typescript\nimport { test, expect } from '@playwright/test';\n\ntest.describe('Focus Flow', () => {\n test.beforeEach(async ({ page }) => {\n // Start app with mocked data\n process.env.MC_TEST_MODE = 'true';\n await page.goto('http://localhost:1420');\n });\n \n test('shows focus card on launch', async ({ page }) => {\n await expect(page.getByTestId('focus-card')).toBeVisible();\n await expect(page.getByRole('heading')).toContainText('Fix authentication');\n });\n \n test('Start opens GitLab URL', async ({ page }) => {\n const [newPage] = await Promise.all([\n page.waitForEvent('popup'),\n page.click('button:has-text(\"Start\")'),\n ]);\n \n expect(newPage.url()).toContain('gitlab.com');\n });\n \n test('keyboard shortcut Enter triggers Start', async ({ page }) => {\n const [newPage] = await Promise.all([\n page.waitForEvent('popup'),\n page.keyboard.press('Enter'),\n ]);\n \n expect(newPage.url()).toContain('gitlab.com');\n });\n});\n```\n\n### batch-mode.spec.ts\n```typescript\ntest.describe('Batch Mode', () => {\n test('completing all items shows celebration', async ({ page }) => {\n // Navigate to queue\n await page.click('[data-testid=\"nav-queue\"]');\n \n // Start batch\n await page.click('button:has-text(\"Batch All\")');\n \n // Verify batch mode entered\n await expect(page.getByText('BATCH: CODE REVIEWS')).toBeVisible();\n \n // Complete all 4 items\n for (let i = 0; i < 4; i++) {\n await page.keyboard.press('Meta+d'); // Done\n }\n \n // Verify celebration\n await expect(page.getByText('All done!')).toBeVisible();\n await expect(page.getByTestId('confetti')).toBeVisible();\n });\n \n test('Escape exits batch mode', async ({ page }) => {\n await page.click('[data-testid=\"nav-queue\"]');\n await page.click('button:has-text(\"Batch All\")');\n \n await page.keyboard.press('Escape');\n \n await expect(page.getByText('BATCH:')).not.toBeVisible();\n await expect(page.getByTestId('queue-list')).toBeVisible();\n });\n});\n```\n\n### quick-capture.spec.ts\n```typescript\ntest.describe('Quick Capture', () => {\n test('creates bead from capture', async ({ page }) => {\n // Open capture overlay\n await page.keyboard.press('Meta+Shift+c');\n \n await expect(page.getByTestId('quick-capture-overlay')).toBeVisible();\n \n // Type and save\n await page.fill('textarea', 'Check webhook retry logic');\n await page.keyboard.press('Enter');\n \n // Verify overlay closes\n await expect(page.getByTestId('quick-capture-overlay')).not.toBeVisible();\n \n // Verify bead created (check inbox)\n await page.click('[data-testid=\"nav-inbox\"]');\n await expect(page.getByText('Check webhook retry logic')).toBeVisible();\n });\n \n test('Escape cancels without creating', async ({ page }) => {\n await page.keyboard.press('Meta+Shift+c');\n await page.fill('textarea', 'partial text');\n await page.keyboard.press('Escape');\n \n await expect(page.getByTestId('quick-capture-overlay')).not.toBeVisible();\n \n // Verify no bead created\n await page.click('[data-testid=\"nav-inbox\"]');\n await expect(page.getByText('partial text')).not.toBeVisible();\n });\n});\n```\n\n### sync-status.spec.ts\n```typescript\ntest.describe('Sync Status', () => {\n test('shows error on lore failure', async ({ page }) => {\n // Configure mock to fail\n process.env.MC_MOCK_LORE_FAIL = 'true';\n await page.goto('http://localhost:1420');\n \n await expect(page.getByText(/Sync failed/)).toBeVisible();\n await expect(page.getByText(/lore/)).toBeVisible();\n });\n \n test('recovers after failure', async ({ page }) => {\n process.env.MC_MOCK_LORE_FAIL = 'true';\n await page.goto('http://localhost:1420');\n \n // Clear failure mode\n process.env.MC_MOCK_LORE_FAIL = 'false';\n \n // Trigger retry\n await page.click('button:has-text(\"Retry\")');\n \n await expect(page.getByText(/Sync failed/)).not.toBeVisible();\n await expect(page.getByTestId('focus-card')).toBeVisible();\n });\n});\n```\n\n**Acceptance criteria:**\n- All E2E tests pass in CI\n- Tests cover critical user flows\n- Mock configuration allows testing error states\n- Tests run in < 2 minutes total","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:35:49.478464Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:35:54.087333Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3jk","depends_on_id":"bd-11k","type":"blocks","created_at":"2026-02-25T20:35:54.087317Z","created_by":"tayloreernisse"},{"issue_id":"bd-3jk","depends_on_id":"bd-25l","type":"blocks","created_at":"2026-02-25T20:35:54.053597Z","created_by":"tayloreernisse"}]} +{"id":"bd-3k4","title":"Implement focus actions with decision logging","description":"# Focus Actions with Decision Logging (RED → GREEN)\n\n**Parent epic:** Phase 3: Focus View\n\n**Actions:**\n| Action | What happens |\n|--------|--------------|\n| Start | Opens GitLab URL in browser, logs decision |\n| Defer 1h | Sets snooze until now+1h, logs with reason |\n| Defer Tomorrow | Sets snooze until 9am tomorrow, logs with reason |\n| Skip | Removes from queue for today, logs with reason |\n\n**TDD: Action tests (RED first):**\n\n```typescript\n// tests/hooks/useActions.test.ts\ndescribe('useActions', () => {\n it('start opens URL in browser', async () => {\n const mockOpen = vi.fn();\n vi.mock('@tauri-apps/plugin-shell', () => ({ open: mockOpen }));\n \n const { result } = renderHook(() => useActions());\n \n await result.current.start({\n id: 'br-x7f',\n url: 'https://gitlab.com/platform/core/-/merge_requests/847'\n });\n \n expect(mockOpen).toHaveBeenCalledWith('https://gitlab.com/platform/core/-/merge_requests/847');\n });\n \n it('start logs decision', async () => {\n const mockLogDecision = vi.fn();\n vi.mock('../store', () => ({ useStore: () => ({ logDecision: mockLogDecision }) }));\n \n const { result } = renderHook(() => useActions());\n \n await result.current.start({ id: 'br-x7f' });\n \n expect(mockLogDecision).toHaveBeenCalledWith(expect.objectContaining({\n action: 'start',\n bead_id: 'br-x7f',\n }));\n });\n \n it('defer calculates correct snooze time', async () => {\n vi.useFakeTimers();\n vi.setSystemTime(new Date('2026-02-25T10:00:00Z'));\n \n const mockUpdateBead = vi.fn();\n const { result } = renderHook(() => useActions());\n \n await result.current.defer({ id: 'br-x7f' }, '1h');\n \n expect(mockUpdateBead).toHaveBeenCalledWith('br-x7f', {\n snoozedUntil: new Date('2026-02-25T11:00:00Z')\n });\n \n vi.useRealTimers();\n });\n \n it('defer tomorrow uses 9am', async () => {\n vi.useFakeTimers();\n vi.setSystemTime(new Date('2026-02-25T22:00:00Z')); // 10pm\n \n const mockUpdateBead = vi.fn();\n const { result } = renderHook(() => useActions());\n \n await result.current.defer({ id: 'br-x7f' }, 'tomorrow');\n \n expect(mockUpdateBead).toHaveBeenCalledWith('br-x7f', {\n snoozedUntil: new Date('2026-02-26T09:00:00') // 9am next day\n });\n \n vi.useRealTimers();\n });\n \n it('skip marks item as skipped for today', async () => {\n const mockUpdateBead = vi.fn();\n const { result } = renderHook(() => useActions());\n \n await result.current.skip({ id: 'br-x7f' }, 'Not urgent');\n \n expect(mockUpdateBead).toHaveBeenCalledWith('br-x7f', {\n skippedToday: true\n });\n });\n});\n```\n\n**Implementation (GREEN):**\n\n```typescript\n// src/hooks/useActions.ts\nimport { open } from '@tauri-apps/plugin-shell';\nimport { invoke } from '@tauri-apps/api/core';\n\nexport function useActions() {\n const { logDecision, updateItem } = useStore();\n \n const start = async (item: WorkItem) => {\n // Open in browser\n if (item.url) {\n await open(item.url);\n }\n \n // Log decision\n await invoke('log_decision', {\n entry: {\n action: 'start',\n bead_id: item.id,\n context: captureContext(),\n }\n });\n };\n \n const defer = async (item: WorkItem, duration: '1h' | 'tomorrow' | string) => {\n const snoozedUntil = calculateSnoozeTime(duration);\n \n await invoke('update_item', { \n id: item.id, \n updates: { snoozedUntil } \n });\n \n // Prompt for reason then log\n const reason = await promptForReason('defer');\n await invoke('log_decision', {\n entry: {\n action: 'defer',\n bead_id: item.id,\n reason,\n context: captureContext(),\n }\n });\n };\n \n const skip = async (item: WorkItem) => {\n await invoke('update_item', { \n id: item.id, \n updates: { skippedToday: true } \n });\n \n const reason = await promptForReason('skip');\n await invoke('log_decision', {\n entry: {\n action: 'skip',\n bead_id: item.id,\n reason,\n context: captureContext(),\n }\n });\n };\n \n return { start, defer, skip };\n}\n```\n\n**Acceptance criteria:**\n- Start opens correct URL in default browser\n- Defer calculates correct snooze times\n- Skip hides item for rest of day\n- All actions logged with context\n- Reason prompt appears and captures input","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:31:32.700423Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:32:00.962929Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3k4","depends_on_id":"bd-1ft","type":"blocks","created_at":"2026-02-25T20:32:00.837728Z","created_by":"tayloreernisse"},{"issue_id":"bd-3k4","depends_on_id":"bd-1kr","type":"blocks","created_at":"2026-02-25T20:32:00.912094Z","created_by":"tayloreernisse"},{"issue_id":"bd-3k4","depends_on_id":"bd-2p0","type":"blocks","created_at":"2026-02-25T20:32:00.962907Z","created_by":"tayloreernisse"},{"issue_id":"bd-3k4","depends_on_id":"bd-2zu","type":"blocks","created_at":"2026-02-25T20:32:00.938124Z","created_by":"tayloreernisse"}]} +{"id":"bd-3ke","title":"Implement event-to-bead title and key formatting","description":"# Event-to-Bead Title and Key Formatting\n\n**Parent epic:** Phase 2: Bridge + Data Layer\n\n**Purpose:** Convert lore events into standardized bead titles and stable mapping keys.\n\n**Mapping rules from plan:**\n\n| GitLab Event | Bead Title | Mapping Key Format |\n|--------------|------------|-------------------|\n| MR review requested | `Review MR !{iid}: {title}` | `mr_review:{host}:{project_id}:{iid}` |\n| Issue assigned | `Issue #{iid}: {title}` | `issue:{host}:{project_id}:{iid}` |\n| MR you authored | `Your MR !{iid}: {title}` | `mr_authored:{host}:{project_id}:{iid}` |\n| Mention in discussion | `Mentioned in {type} #{iid}: {snippet}` | `mention:{host}:{project_id}:{iid}:{note_id}` |\n| Comment on your MR | `Respond to @{actor} on MR !{iid}` | `feedback:{host}:{project_id}:{iid}:{note_id}` |\n\n**Key format rationale:** We use numeric `project_id` instead of project path because paths can change (renames, transfers). Numeric IDs are immutable.\n\n**TDD: Formatting tests (RED first):**\n\n```rust\n// src-tauri/tests/event_formatting_test.rs\n\n#[test]\nfn mr_review_title_format() {\n let event = LoreEvent::MrReview {\n iid: 847,\n title: \"Fix authentication token refresh logic\".into(),\n project: \"platform/core\".into(),\n author: \"sarah\".into(),\n };\n \n let title = format_bead_title(&event);\n \n assert_eq!(title, \"Review MR !847: Fix authentication token refresh logic\");\n}\n\n#[test]\nfn mr_review_key_format() {\n let event = LoreEvent::MrReview {\n host: \"gitlab.com\".into(),\n project_id: 12345,\n iid: 847,\n ..\n };\n \n let key = format_mapping_key(&event);\n \n assert_eq!(key, \"mr_review:gitlab.com:12345:847\");\n}\n\n#[test]\nfn issue_assigned_title_format() {\n let event = LoreEvent::IssueAssigned {\n iid: 312,\n title: \"Implement user profile page\".into(),\n };\n \n let title = format_bead_title(&event);\n \n assert_eq!(title, \"Issue #312: Implement user profile page\");\n}\n\n#[test]\nfn mention_title_truncates_snippet() {\n let event = LoreEvent::Mention {\n entity_type: \"issue\".into(),\n iid: 312,\n snippet: \"Hey @taylor can you take a look at this really long comment that goes on and on\".into(),\n };\n \n let title = format_bead_title(&event);\n \n assert!(title.len() <= 80);\n assert!(title.ends_with(\"...\"));\n}\n\n#[test]\nfn feedback_title_format() {\n let event = LoreEvent::Feedback {\n iid: 847,\n actor: \"mike\".into(),\n };\n \n let title = format_bead_title(&event);\n \n assert_eq!(title, \"Respond to @mike on MR !847\");\n}\n\n#[test]\nfn key_uses_project_id_not_path() {\n let event = LoreEvent::MrReview {\n project: \"old-name/repo\".into(), // Path can change\n project_id: 12345, // ID is stable\n ..\n };\n \n let key = format_mapping_key(&event);\n \n assert!(key.contains(\"12345\"));\n assert!(!key.contains(\"old-name\"));\n}\n\n#[test]\nfn key_handles_special_characters() {\n let event = LoreEvent::Mention {\n host: \"gitlab.example.com\".into(),\n project_id: 123,\n iid: 45,\n note_id: 67890,\n };\n \n let key = format_mapping_key(&event);\n \n // Should be safe for JSON keys and filenames\n assert!(!key.contains(' '));\n assert!(!key.contains('/'));\n}\n```\n\n**Implementation:**\n\n```rust\n// src-tauri/src/bridge/formatting.rs\n\npub fn format_bead_title(event: &LoreEvent) -> String {\n match event {\n LoreEvent::MrReview { iid, title, .. } => {\n format!(\"Review MR !{}: {}\", iid, truncate(title, 60))\n }\n \n LoreEvent::IssueAssigned { iid, title, .. } => {\n format!(\"Issue #{}: {}\", iid, truncate(title, 60))\n }\n \n LoreEvent::MrAuthored { iid, title, .. } => {\n format!(\"Your MR !{}: {}\", iid, truncate(title, 60))\n }\n \n LoreEvent::Mention { entity_type, iid, snippet, .. } => {\n format!(\"Mentioned in {} #{}: {}\", entity_type, iid, truncate(snippet, 40))\n }\n \n LoreEvent::Feedback { iid, actor, .. } => {\n format!(\"Respond to @{} on MR !{}\", actor, iid)\n }\n }\n}\n\npub fn format_mapping_key(event: &LoreEvent) -> String {\n match event {\n LoreEvent::MrReview { host, project_id, iid, .. } => {\n format!(\"mr_review:{}:{}:{}\", host, project_id, iid)\n }\n \n LoreEvent::IssueAssigned { host, project_id, iid, .. } => {\n format!(\"issue:{}:{}:{}\", host, project_id, iid)\n }\n \n LoreEvent::MrAuthored { host, project_id, iid, .. } => {\n format!(\"mr_authored:{}:{}:{}\", host, project_id, iid)\n }\n \n LoreEvent::Mention { host, project_id, iid, note_id, .. } => {\n format!(\"mention:{}:{}:{}:{}\", host, project_id, iid, note_id)\n }\n \n LoreEvent::Feedback { host, project_id, iid, note_id, .. } => {\n format!(\"feedback:{}:{}:{}:{}\", host, project_id, iid, note_id)\n }\n }\n}\n\nfn truncate(s: &str, max_len: usize) -> String {\n if s.len() <= max_len {\n s.to_string()\n } else {\n format!(\"{}...\", &s[..max_len - 3])\n }\n}\n```\n\n**Acceptance criteria:**\n- All event types produce correct titles\n- Keys use project_id (stable) not path\n- Long titles truncated with ellipsis\n- Keys are safe for JSON/filesystem\n- Roundtrip: key → parse → same components","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T21:07:19.732579Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:08:14.773249Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3ke","depends_on_id":"bd-1ev","type":"blocks","created_at":"2026-02-25T21:08:14.773232Z","created_by":"tayloreernisse"},{"issue_id":"bd-3ke","depends_on_id":"bd-2us","type":"blocks","created_at":"2026-02-25T21:08:14.740140Z","created_by":"tayloreernisse"}]} +{"id":"bd-3pc","title":"Implement error boundary and error handling UI","description":"# Error Boundary and Error Handling UI\n\n**Parent epic:** Phase 7: Polish + E2E Tests\n\n**Purpose:** Graceful error handling throughout the app. Crashes should be caught and show recovery options.\n\n**Error types:**\n\n| Error Type | Handling | User Message |\n|------------|----------|--------------|\n| CLI not found | Show setup guide | \"lore not found. Install with...\" |\n| CLI failure | Retry option | \"Sync failed. [Retry]\" |\n| Network error | Retry + offline mode | \"GitLab unreachable. Working offline.\" |\n| State corruption | Auto-recover + notify | \"Recovered from error. Some data may need refresh.\" |\n| React crash | Error boundary | \"Something went wrong. [Reload]\" |\n\n**TDD: Error handling tests (RED first):**\n\n```typescript\n// tests/components/ErrorBoundary.test.tsx\ndescribe('ErrorBoundary', () => {\n it('catches errors and shows fallback', () => {\n const ThrowingComponent = () => {\n throw new Error('Test error');\n };\n \n render(\n \n \n \n );\n \n expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();\n expect(screen.getByRole('button', { name: /reload/i })).toBeInTheDocument();\n });\n \n it('logs error details', () => {\n const consoleSpy = vi.spyOn(console, 'error');\n const ThrowingComponent = () => { throw new Error('Test'); };\n \n render(\n \n \n \n );\n \n expect(consoleSpy).toHaveBeenCalled();\n });\n \n it('shows recovery actions', () => {\n const onRecover = vi.fn();\n \n render(\n \n \n \n );\n \n userEvent.click(screen.getByRole('button', { name: /try again/i }));\n \n expect(onRecover).toHaveBeenCalled();\n });\n});\n\n// tests/components/ErrorDisplay.test.tsx\ndescribe('ErrorDisplay', () => {\n it('shows CLI not found error with setup guide', () => {\n render();\n \n expect(screen.getByText(/lore not found/)).toBeInTheDocument();\n expect(screen.getByText(/Install with/)).toBeInTheDocument();\n });\n \n it('shows retry button for transient errors', () => {\n const onRetry = vi.fn();\n render();\n \n expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();\n });\n});\n```\n\n**Implementation:**\n\n```tsx\n// src/components/ErrorBoundary.tsx\ninterface ErrorBoundaryState {\n hasError: boolean;\n error: Error | null;\n}\n\nexport class ErrorBoundary extends React.Component<\n { children: React.ReactNode; onRecover?: () => void },\n ErrorBoundaryState\n> {\n state: ErrorBoundaryState = { hasError: false, error: null };\n \n static getDerivedStateFromError(error: Error) {\n return { hasError: true, error };\n }\n \n componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {\n console.error('React error boundary caught:', error, errorInfo);\n \n // Could send to error tracking service\n }\n \n handleReload = () => {\n window.location.reload();\n };\n \n handleRecover = () => {\n this.setState({ hasError: false, error: null });\n this.props.onRecover?.();\n };\n \n render() {\n if (this.state.hasError) {\n return (\n
\n \n

Something went wrong

\n

\n Mission Control encountered an unexpected error.\n

\n \n {process.env.NODE_ENV === 'development' && (\n
\n              {this.state.error?.stack}\n            
\n )}\n \n
\n \n \n
\n
\n );\n }\n \n return this.props.children;\n }\n}\n\n// src/components/ErrorDisplay.tsx\ninterface ErrorDisplayProps {\n error: AppError;\n onRetry?: () => void;\n onDismiss?: () => void;\n}\n\nexport function ErrorDisplay({ error, onRetry, onDismiss }: ErrorDisplayProps) {\n const messages: Record = {\n cli_not_found: {\n title: `${error.cli} not found`,\n desc: `Install with: cargo install ${error.cli}`,\n },\n sync_failed: {\n title: 'Sync failed',\n desc: 'Could not fetch latest data from GitLab.',\n action: 'retry',\n },\n network_error: {\n title: 'Network error',\n desc: 'GitLab is unreachable. Working offline.',\n },\n };\n \n const msg = messages[error.type] || { title: 'Error', desc: error.message };\n \n return (\n \n {msg.title}\n {msg.desc}\n \n {msg.action === 'retry' && onRetry && (\n \n )}\n \n );\n}\n```\n\n**Acceptance criteria:**\n- React errors caught by boundary\n- Helpful error messages shown\n- Recovery/retry actions available\n- No raw error stacks in production\n- Errors logged for debugging","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T21:09:06.871401Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:09:06.871401Z","compaction_level":0,"original_size":0} +{"id":"bd-3px","title":"Implement tmp file cleanup on startup","description":"Implement startup cleanup of orphaned .tmp files from interrupted atomic writes.\n\nBACKGROUND:\nPLAN.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).'\n\nWHY THIS MATTERS:\n- Atomic write pattern: write to .tmp, then rename to target\n- If crash occurs between write and rename, .tmp file lingers\n- Lingering .tmp files indicate incomplete operation\n- Must clean up before proceeding to avoid confusion\n\nIMPLEMENTATION:\n- On app startup, scan ~/.local/share/mc/ for *.tmp files\n- For each .tmp file found:\n 1. Log warning: 'Found orphaned tmp file, previous write interrupted'\n 2. Delete the .tmp file\n 3. Continue with existing .json file (if any)\n- Run this BEFORE loading state files\n- Run this AFTER acquiring single-instance lock (bd-2at)\n\nTESTING (TDD):\n- Create .tmp file, start app, verify .tmp deleted\n- Verify main .json file unchanged\n- Verify app continues normally after cleanup\n- Test with multiple .tmp files\n- Test with no .tmp files (normal case)\n\nFILE LOCATION:\nPart of app initialization (bd-3jh) or data/state.rs\n\nORDERING:\n1. Acquire lock (bd-2at)\n2. Clean up tmp files (THIS BEAD)\n3. Run pending recovery (bd-z4n)\n4. Load state files","status":"open","priority":1,"issue_type":"feature","created_at":"2026-02-25T21:11:56.718214Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:12:03.102724Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3px","depends_on_id":"bd-2at","type":"blocks","created_at":"2026-02-25T21:12:03.102707Z","created_by":"tayloreernisse"}]} +{"id":"bd-3ta","title":"Add test commands to package.json and Cargo.toml","description":"# Test Command Configuration\n\n**Parent epic:** Phase 0: Test Infrastructure\n\n**package.json scripts to add:**\n\n```json\n{\n \"scripts\": {\n \"test\": \"vitest run\",\n \"test:watch\": \"vitest\",\n \"test:coverage\": \"vitest run --coverage\",\n \"test:e2e\": \"playwright test\",\n \"test:e2e:headed\": \"playwright test --headed\",\n \"test:e2e:debug\": \"playwright test --debug\",\n \"test:all\": \"npm run test && npm run test:e2e\",\n \"test:ci\": \"npm run test:coverage && npm run test:e2e\"\n }\n}\n```\n\n**Cargo.toml test configuration:**\n\n```toml\n[dev-dependencies]\ntempfile = \"3\" # For temp directory fixtures\ntokio-test = \"0.4\" # Async test utilities\nrstest = \"0.18\" # Parameterized tests\n\n[[test]]\nname = \"bridge\"\npath = \"tests/bridge_test.rs\"\n\n[[test]]\nname = \"crash_recovery\"\npath = \"tests/crash_recovery_test.rs\"\n\n[[test]]\nname = \"mapping\"\npath = \"tests/mapping_test.rs\"\n```\n\n**CI workflow integration (.github/workflows/test.yml):**\n\n```yaml\njobs:\n rust-tests:\n runs-on: ubuntu-latest\n steps:\n - run: cargo test --all-features\n \n frontend-tests:\n runs-on: ubuntu-latest\n steps:\n - run: npm run test:coverage\n \n e2e-tests:\n runs-on: macos-latest\n steps:\n - run: npm run test:e2e\n```\n\n**Acceptance criteria:**\n- All test commands work from project root\n- cargo test runs all Rust tests\n- npm run test:all runs frontend + E2E tests\n- CI config ready for GitHub Actions","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:25:31.832976Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:25:37.270305Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3ta","depends_on_id":"bd-11k","type":"blocks","created_at":"2026-02-25T20:25:37.245644Z","created_by":"tayloreernisse"},{"issue_id":"bd-3ta","depends_on_id":"bd-1ps","type":"blocks","created_at":"2026-02-25T20:25:37.270286Z","created_by":"tayloreernisse"},{"issue_id":"bd-3ta","depends_on_id":"bd-3em","type":"blocks","created_at":"2026-02-25T20:25:37.225223Z","created_by":"tayloreernisse"},{"issue_id":"bd-3ta","depends_on_id":"bd-jyz","type":"blocks","created_at":"2026-02-25T20:25:36.269511Z","created_by":"tayloreernisse"}]} +{"id":"bd-3ua","title":"Implement filter/search with command palette","description":"# Filter/Search with Command Palette (RED → GREEN)\n\n**Parent epic:** Phase 4: Queue + Inbox Views\n\n**Purpose:** ⌘K opens a command palette for quick filtering and searching across all items.\n\n**TDD: Filter tests (RED first):**\n\n```typescript\n// tests/components/CommandPalette.test.tsx\ndescribe('CommandPalette', () => {\n it('opens on ⌘K', async () => {\n render();\n \n await userEvent.keyboard('{Meta>}k{/Meta}');\n \n expect(screen.getByRole('dialog')).toBeInTheDocument();\n expect(screen.getByPlaceholderText(/Search or filter/)).toBeInTheDocument();\n });\n \n it('closes on Escape', async () => {\n render();\n await userEvent.keyboard('{Meta>}k{/Meta}');\n \n await userEvent.keyboard('{Escape}');\n \n expect(screen.queryByRole('dialog')).not.toBeInTheDocument();\n });\n \n it('filters items by text', async () => {\n render();\n await userEvent.keyboard('{Meta>}k{/Meta}');\n \n await userEvent.type(screen.getByRole('textbox'), 'auth');\n \n expect(screen.getByText('Fix auth token refresh')).toBeInTheDocument();\n expect(screen.queryByText('Update README')).not.toBeInTheDocument();\n });\n \n it('shows filter commands', async () => {\n render();\n await userEvent.keyboard('{Meta>}k{/Meta}');\n \n await userEvent.type(screen.getByRole('textbox'), 'type:');\n \n expect(screen.getByText('type:review')).toBeInTheDocument();\n expect(screen.getByText('type:issue')).toBeInTheDocument();\n expect(screen.getByText('type:manual')).toBeInTheDocument();\n });\n \n it('filters by type when type: command used', async () => {\n const onFilter = vi.fn();\n render();\n \n await userEvent.type(screen.getByRole('textbox'), 'type:review');\n await userEvent.keyboard('{Enter}');\n \n expect(onFilter).toHaveBeenCalledWith({ type: 'review' });\n });\n \n it('filters by staleness', async () => {\n const onFilter = vi.fn();\n render();\n \n await userEvent.type(screen.getByRole('textbox'), 'stale:7d');\n await userEvent.keyboard('{Enter}');\n \n expect(onFilter).toHaveBeenCalledWith({ minAge: 7 });\n });\n});\n```\n\n**Implementation (GREEN):**\n\n```tsx\n// src/components/CommandPalette.tsx\nimport { Command, CommandInput, CommandList, CommandItem, CommandGroup } from '@/components/ui/command';\n\nconst FILTER_COMMANDS = [\n { prefix: 'type:', options: ['review', 'issue', 'manual', 'mention'] },\n { prefix: 'from:', options: ['gitlab', 'manual'] },\n { prefix: 'stale:', options: ['1d', '3d', '7d'] },\n { prefix: 'status:', options: ['active', 'snoozed', 'all'] },\n];\n\nexport function CommandPalette({ items, onFilter, onSelect, onClose }: CommandPaletteProps) {\n const [search, setSearch] = useState('');\n \n const { filteredItems, activeCommand } = useMemo(() => {\n // Check if search matches a command prefix\n for (const cmd of FILTER_COMMANDS) {\n if (search.startsWith(cmd.prefix)) {\n return {\n filteredItems: [],\n activeCommand: cmd,\n };\n }\n }\n \n // Text search\n return {\n filteredItems: items.filter(item => \n item.title.toLowerCase().includes(search.toLowerCase())\n ),\n activeCommand: null,\n };\n }, [search, items]);\n \n useKeyboardShortcuts({\n 'mod+k': () => {}, // Already open\n 'escape': onClose,\n });\n \n return (\n \n \n \n \n {activeCommand ? (\n \n {activeCommand.options.map(opt => (\n {\n onFilter(parseFilter(activeCommand.prefix + opt));\n onClose();\n }}\n >\n {activeCommand.prefix}{opt}\n \n ))}\n \n ) : (\n <>\n {filteredItems.length > 0 && (\n \n {filteredItems.slice(0, 10).map(item => (\n {\n onSelect(item.id);\n onClose();\n }}\n >\n \n {item.title}\n \n ))}\n \n )}\n \n \n {FILTER_COMMANDS.map(cmd => (\n setSearch(cmd.prefix)}>\n {cmd.prefix}...\n \n ))}\n \n \n )}\n \n \n );\n}\n```\n\n**Acceptance criteria:**\n- ⌘K opens palette\n- Text search filters items\n- Command prefixes work (type:, from:, stale:)\n- Selection navigates to item\n- Filter applied to queue view","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:33:29.559528Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:33:34.913664Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3ua","depends_on_id":"bd-716","type":"blocks","created_at":"2026-02-25T20:33:34.913648Z","created_by":"tayloreernisse"}]} +{"id":"bd-4s6","title":"Implement bv integration for triage recommendations","description":"# BV Integration for Triage Recommendations\n\n**Parent epic:** Phase 4: Queue + Inbox Views\n\n**Purpose:** Show bv's triage recommendations as hints to help prioritize work. This is OPTIONAL advisory data, not mandatory.\n\n**From plan:** \"bv recommendations — Show as separate 'suggestions' section or inline hints?\"\n\n**Design decision:** Show as subtle hints, not separate section. User is always in control.\n\n**TDD: BV integration tests (RED first):**\n\n```rust\n// src-tauri/tests/bv_integration_test.rs\n\n#[test]\nfn parse_bv_triage_output() {\n let fixture = include_str!(\"fixtures/bv/triage.json\");\n let result: BvTriageResponse = serde_json::from_str(fixture).unwrap();\n \n assert!(result.recommendations.is_some());\n}\n\n#[tokio::test]\nasync fn get_recommendations_returns_top_picks() {\n let app = setup_test_app();\n \n let result: Vec = app.invoke(\"get_recommendations\", ()).await;\n \n assert!(result.len() <= 5); // Top 5 max\n}\n\n#[tokio::test]\nasync fn recommendations_gracefully_handle_bv_failure() {\n let bv = MockBvCli::that_fails();\n let app = setup_test_app_with(bv);\n \n let result: Vec = app.invoke(\"get_recommendations\", ()).await;\n \n // Should return empty, not error\n assert!(result.is_empty());\n}\n```\n\n**Frontend tests:**\n\n```typescript\n// tests/hooks/useRecommendations.test.ts\ndescribe('useRecommendations', () => {\n it('fetches recommendations from bv', async () => {\n vi.mocked(invoke).mockResolvedValue([\n { id: 'br-123', score: 0.95, reason: 'High PageRank' }\n ]);\n \n const { result } = renderHook(() => useRecommendations());\n \n await waitFor(() => {\n expect(result.current.data).toHaveLength(1);\n });\n });\n \n it('shows loading state', () => {\n const { result } = renderHook(() => useRecommendations());\n \n expect(result.current.isLoading).toBe(true);\n });\n \n it('returns empty on error (no crash)', async () => {\n vi.mocked(invoke).mockRejectedValue(new Error('bv failed'));\n \n const { result } = renderHook(() => useRecommendations());\n \n await waitFor(() => {\n expect(result.current.data).toEqual([]);\n expect(result.current.error).toBeNull(); // Swallowed\n });\n });\n});\n```\n\n**Implementation:**\n\n```rust\n// src-tauri/src/data/bv.rs\npub trait BvCli: Send + Sync {\n fn triage(&self) -> Result;\n}\n\npub struct RealBvCli;\n\nimpl BvCli for RealBvCli {\n fn triage(&self) -> Result {\n let output = Command::new(\"bv\")\n .args([\"--robot-triage\"])\n .output()?;\n \n if !output.status.success() {\n return Ok(BvTriageResponse::default()); // Graceful degradation\n }\n \n serde_json::from_slice(&output.stdout)\n .context(\"Failed to parse bv output\")\n }\n}\n\n// src-tauri/src/commands/recommendations.rs\n#[tauri::command]\npub async fn get_recommendations(\n bv: State<'_, Arc>,\n) -> Vec {\n bv.triage()\n .map(|r| r.recommendations.unwrap_or_default())\n .map(|recs| recs.into_iter().take(5).collect())\n .unwrap_or_default()\n}\n```\n\n**Frontend hook:**\n\n```typescript\n// src/hooks/useRecommendations.ts\nexport function useRecommendations() {\n return useQuery({\n queryKey: ['recommendations'],\n queryFn: async () => {\n try {\n return await invoke('get_recommendations');\n } catch {\n return []; // Graceful degradation\n }\n },\n staleTime: 5 * 60 * 1000, // Cache 5 minutes\n });\n}\n```\n\n**UI integration:**\n\n```tsx\n// In QueueList, show hint for recommended items\nfunction QueueItem({ item, recommendation }) {\n return (\n
\n {/* ... item content ... */}\n \n {recommendation && (\n \n \n \n \n \n Suggested: {recommendation.reason}\n \n \n )}\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- BV output parses correctly\n- Recommendations show as subtle hints\n- Graceful degradation on bv failure\n- User never forced to follow recommendations","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T21:08:46.435837Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:12:02.387394Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-4s6","depends_on_id":"bd-gil","type":"blocks","created_at":"2026-02-25T21:12:02.387379Z","created_by":"tayloreernisse"}]} +{"id":"bd-5l0","title":"Implement complete action for finished work","description":"# Complete Action for Finished Work\n\n**Parent epic:** Phase 3: Focus View\n\n**Purpose:** When user finishes a task, mark it complete. This closes the bead and logs the completion.\n\n**Flow:**\n1. User clicks \"Done\" or presses hotkey\n2. Prompt for optional reason/notes\n3. Close bead via `br close`\n4. Log completion decision\n5. Auto-advance to next item\n\n**TDD: Complete action tests (RED first):**\n\n```typescript\n// tests/hooks/useActions.test.ts\ndescribe('complete action', () => {\n it('closes bead via br CLI', async () => {\n const mockInvoke = vi.fn().mockResolvedValue({});\n vi.mocked(invoke).mockImplementation(mockInvoke);\n \n const { result } = renderHook(() => useActions());\n \n await result.current.complete({ id: 'br-123', title: 'Test' });\n \n expect(mockInvoke).toHaveBeenCalledWith('complete_item', {\n id: 'br-123',\n reason: undefined\n });\n });\n \n it('includes reason when provided', async () => {\n const mockInvoke = vi.fn().mockResolvedValue({});\n vi.mocked(invoke).mockImplementation(mockInvoke);\n \n const { result } = renderHook(() => useActions());\n \n await result.current.complete(\n { id: 'br-123' },\n { reason: 'Approved the MR', tags: ['done'] }\n );\n \n expect(mockInvoke).toHaveBeenCalledWith('complete_item', {\n id: 'br-123',\n reason: 'Approved the MR'\n });\n });\n \n it('logs completion decision', async () => {\n const mockLogDecision = vi.fn();\n \n const { result } = renderHook(() => useActions({ logDecision: mockLogDecision }));\n \n await result.current.complete({ id: 'br-123' });\n \n expect(mockLogDecision).toHaveBeenCalledWith(expect.objectContaining({\n action: 'complete',\n bead_id: 'br-123',\n }));\n });\n \n it('removes item from local state', async () => {\n const updateItem = vi.fn();\n const store = createStore({ updateItem });\n \n const { result } = renderHook(() => useActions(), {\n wrapper: StoreProvider(store)\n });\n \n await result.current.complete({ id: 'br-123' });\n \n expect(updateItem).toHaveBeenCalledWith('br-123', { completed: true });\n });\n});\n```\n\n**Rust command:**\n\n```rust\n// src-tauri/src/commands/actions.rs\n#[tauri::command]\npub async fn complete_item(\n id: String,\n reason: Option,\n beads: State<'_, Arc>,\n mapping: State<'_, Arc>>,\n decision_log: State<'_, DecisionLogger>,\n) -> Result<(), String> {\n // Close the bead\n let close_reason = reason.clone().unwrap_or_else(|| \"Completed\".into());\n beads.close(&id, &close_reason)\n .map_err(|e| e.to_string())?;\n \n // Remove from mapping if it's a gitlab item\n {\n let mut map = mapping.lock().await;\n map.remove_by_bead_id(&id);\n map.save()?;\n }\n \n // Log decision\n decision_log.log(DecisionEntry {\n action: Action::Complete,\n bead_id: id,\n reason,\n context: DecisionContext::capture(),\n ..Default::default()\n }).ok();\n \n Ok(())\n}\n```\n\n**Frontend hook:**\n\n```typescript\n// src/hooks/useActions.ts\nexport function useActions() {\n const { updateItem, items, focusId, setFocus } = useStore();\n const queryClient = useQueryClient();\n \n const complete = async (\n item: WorkItem,\n options?: { reason?: string; tags?: string[] }\n ) => {\n // Close bead via backend\n await invoke('complete_item', {\n id: item.id,\n reason: options?.reason,\n });\n \n // Update local state\n updateItem(item.id, { completed: true });\n \n // Invalidate queries\n queryClient.invalidateQueries({ queryKey: ['work-items'] });\n \n // Auto-advance to next item if this was focused\n if (focusId === item.id) {\n const nextItem = items.find(i => i.id !== item.id && !i.completed);\n setFocus(nextItem?.id ?? null);\n }\n };\n \n return { complete, ... };\n}\n```\n\n**Acceptance criteria:**\n- Bead closed via br CLI\n- Mapping updated (removes gitlab items)\n- Decision logged with context\n- Local state updated\n- Auto-advances focus to next item","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T21:07:42.262208Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:08:14.879535Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-5l0","depends_on_id":"bd-1ft","type":"blocks","created_at":"2026-02-25T21:08:14.813316Z","created_by":"tayloreernisse"},{"issue_id":"bd-5l0","depends_on_id":"bd-2sj","type":"blocks","created_at":"2026-02-25T21:08:14.848065Z","created_by":"tayloreernisse"},{"issue_id":"bd-5l0","depends_on_id":"bd-2zu","type":"blocks","created_at":"2026-02-25T21:08:14.879519Z","created_by":"tayloreernisse"}]} +{"id":"bd-716","title":"Phase 4: Queue + Inbox Views","description":"# Queue + Inbox Views\n\n**Context:** Queue View shows all pending work organized by type. Inbox View shows new items requiring triage. Both support the \"achievable inbox zero\" principle.\n\n**Duration estimate:** 2-3 days\n\n**Queue View visual:**\n```\n┌─────────────────────────────────────────────────────────────┐\n│ Queue ⌘K filter │\n├─────────────────────────────────────────────────────────────┤\n│ │\n│ REVIEWS (4) [Batch All · 25min] │\n│ ┌─────────────────────────────────────────────────────┐ │\n│ │ 🔴 !847 Fix auth token refresh 2d @sarah │ │\n│ │ 🟡 !902 Add rate limiting middleware 1d @mike │ │\n│ │ 🟢 !915 Update README badges 4h @alex │ │\n│ │ 🟢 !918 Typo fix in error messages 2h @bot │ │\n│ └─────────────────────────────────────────────────────┘ │\n│ │\n│ ASSIGNED ISSUES (2) │\n│ BEADS (3) │\n│ MANUAL TASKS (1) │\n│ │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**Scope includes:**\n1. QueueList component with sections by type\n2. Staleness color coding (fresh=green, aging=amber, stale=red)\n3. Drag to reorder (manual priority)\n4. Click to set as focus\n5. Inbox with triage actions\n6. Filter/search (⌘K)\n\n**Behavior:**\n- Items colored by staleness\n- Click to make it THE ONE THING\n- Drag to reorder (manual priority, logged)\n- \"Batch All\" enters batch mode\n\n**Dependencies:**\n- Requires Phase 3 (Focus View) for focus-setting integration\n- Blocks Phase 5 (Batch Mode) which is triggered from here\n\n**Acceptance criteria:**\n- Queue shows all items grouped by type\n- Staleness colors visible\n- Drag reorder persists and logs\n- Click sets focus\n- Inbox has triage actions\n- Filter/search works","status":"open","priority":2,"issue_type":"epic","created_at":"2026-02-25T20:32:16.422391Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:32:19.948842Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-716","depends_on_id":"bd-1ft","type":"blocks","created_at":"2026-02-25T20:32:19.948812Z","created_by":"tayloreernisse"}]} +{"id":"bd-ah2","title":"Implement InboxView container","description":"# InboxView Container\n\n**Parent epic:** Phase 4: Queue + Inbox Views\n\n**Purpose:** Container for Inbox component with triage flow and keyboard navigation.\n\n**TDD: InboxView tests (RED first):**\n\n```typescript\n// tests/views/InboxView.test.tsx\ndescribe('InboxView', () => {\n const mockNewItems = [\n { id: '1', triaged: false, title: 'Mention in #312' },\n { id: '2', triaged: false, title: 'Comment on MR !847' },\n ];\n \n it('shows only untriaged items', () => {\n const items = [...mockNewItems, { id: '3', triaged: true, title: 'Already done' }];\n const store = createStore({ items });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getAllByTestId('inbox-item')).toHaveLength(2);\n expect(screen.queryByText('Already done')).not.toBeInTheDocument();\n });\n \n it('shows inbox zero celebration when empty', () => {\n const store = createStore({ items: [] });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByText('Inbox Zero')).toBeInTheDocument();\n expect(screen.getByText(/All caught up/)).toBeInTheDocument();\n });\n \n it('keyboard navigation between items', async () => {\n const store = createStore({ items: mockNewItems });\n render(, { wrapper: StoreProvider(store) });\n \n await userEvent.keyboard('{ArrowDown}');\n \n expect(screen.getAllByTestId('inbox-item')[1]).toHaveFocus();\n });\n \n it('accept triages item', async () => {\n const updateItem = vi.fn();\n const store = createStore({ items: mockNewItems, updateItem });\n render(, { wrapper: StoreProvider(store) });\n \n await userEvent.click(screen.getAllByRole('button', { name: /accept/i })[0]);\n \n expect(updateItem).toHaveBeenCalledWith('1', { triaged: true });\n });\n \n it('logs triage decision', async () => {\n const logDecision = vi.fn();\n const store = createStore({ items: mockNewItems, logDecision });\n render(, { wrapper: StoreProvider(store) });\n \n await userEvent.click(screen.getAllByRole('button', { name: /accept/i })[0]);\n \n expect(logDecision).toHaveBeenCalledWith(expect.objectContaining({\n action: 'triage',\n bead_id: '1',\n }));\n });\n \n it('updates count in real-time after triage', async () => {\n const store = createStore({ items: mockNewItems });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByText('Inbox (2)')).toBeInTheDocument();\n \n await userEvent.click(screen.getAllByRole('button', { name: /accept/i })[0]);\n \n expect(screen.getByText('Inbox (1)')).toBeInTheDocument();\n });\n});\n```\n\n**Implementation:**\n\n```tsx\n// src/views/InboxView.tsx\nexport function InboxView() {\n const { items, updateItem } = useStore();\n const { logDecision } = useDecisionLog();\n const [focusIndex, setFocusIndex] = useState(0);\n \n const untriagedItems = useMemo(() => \n items.filter(i => !i.triaged),\n [items]\n );\n \n useKeyboardShortcuts({\n 'arrowdown': () => setFocusIndex(i => Math.min(i + 1, untriagedItems.length - 1)),\n 'arrowup': () => setFocusIndex(i => Math.max(i - 1, 0)),\n 'a': () => handleTriage(untriagedItems[focusIndex]?.id, 'accept'),\n 'd': () => handleTriage(untriagedItems[focusIndex]?.id, 'defer'),\n 'x': () => handleTriage(untriagedItems[focusIndex]?.id, 'archive'),\n }, { enabled: untriagedItems.length > 0 });\n \n const handleTriage = async (id: string, action: 'accept' | 'defer' | 'archive', duration?: string) => {\n if (!id) return;\n \n // Update item state\n if (action === 'accept') {\n updateItem(id, { triaged: true });\n } else if (action === 'defer') {\n updateItem(id, { snoozedUntil: calculateSnoozeTime(duration || '1h') });\n } else if (action === 'archive') {\n updateItem(id, { triaged: true, archived: true });\n }\n \n // Log decision\n logDecision({\n action: 'triage',\n bead_id: id,\n context: {\n triage_action: action,\n inbox_size: untriagedItems.length,\n }\n });\n };\n \n if (untriagedItems.length === 0) {\n return (\n
\n \n \n

Inbox Zero

\n

All caught up!

\n \n
\n );\n }\n \n return (\n
\n

Inbox ({untriagedItems.length})

\n \n \n \n

\n Keyboard: ↑↓ navigate · A accept · D defer · X archive\n

\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- Only untriaged items shown\n- Inbox zero state with animation\n- Keyboard navigation works\n- Triage actions update state\n- Decisions logged\n- Count updates in real-time","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:54:42.382429Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:54:48.411862Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-ah2","depends_on_id":"bd-2zu","type":"blocks","created_at":"2026-02-25T20:54:48.411841Z","created_by":"tayloreernisse"},{"issue_id":"bd-ah2","depends_on_id":"bd-716","type":"blocks","created_at":"2026-02-25T20:54:48.346260Z","created_by":"tayloreernisse"},{"issue_id":"bd-ah2","depends_on_id":"bd-qvc","type":"blocks","created_at":"2026-02-25T20:54:48.382139Z","created_by":"tayloreernisse"}]} +{"id":"bd-bap","title":"Implement useTauriEvents React hook for event communication","description":"Create the useTauriEvents React hook that handles Tauri→React event communication. This hook is critical for the reactive UI - it receives events from the Rust backend (file watcher triggers, sync status changes, error notifications) and propagates them to React state.\n\nBACKGROUND:\n- Tauri supports bidirectional IPC: invoke (React→Rust) AND events (Rust→React)\n- File watcher in Rust emits events when lore.db changes\n- Sync orchestrator emits status updates\n- These events need to trigger React re-renders\n\nIMPLEMENTATION:\n- Use @tauri-apps/api/event for listening\n- Subscribe on mount, cleanup on unmount\n- Type-safe event payloads matching Rust structs\n- Events: 'lore-db-changed', 'sync-started', 'sync-completed', 'sync-error'\n\nTESTING (TDD):\n- Mock @tauri-apps/api/event\n- Test subscription on mount\n- Test cleanup on unmount\n- Test event handler invocation\n- Test payload parsing\n\nFILE LOCATION:\nsrc/hooks/useTauriEvents.ts\n\nDEPENDENCIES:\n- Needs TypeScript type definitions (bd-20b)\n- Used by TanStack Query layer (bd-1fy) for cache invalidation\n- Used by sync status indicator (bd-2or)","status":"open","priority":2,"issue_type":"feature","created_at":"2026-02-25T21:11:36.089240Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:12:01.044345Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-bap","depends_on_id":"bd-20b","type":"blocks","created_at":"2026-02-25T21:12:01.006532Z","created_by":"tayloreernisse"},{"issue_id":"bd-bap","depends_on_id":"bd-247","type":"blocks","created_at":"2026-02-25T21:12:01.044329Z","created_by":"tayloreernisse"}]} +{"id":"bd-gil","title":"Implement BvCli trait for triage recommendation mocking","description":"Add BvCli trait to the Rust trait-based mocking infrastructure for bv (beads graph triage) CLI operations.\n\nBACKGROUND:\n- bd-28q sets up LoreCli and BeadsCli traits for mocking\n- BV integration (bd-4s6) needs BvCli trait for testability\n- bv --robot-* commands return triage recommendations, insights, etc.\n\nIMPLEMENTATION:\n- Define BvCli trait with methods matching bv robot commands\n- RealBvCli: shells out to actual bv CLI\n- MockBvCli: returns fixture data for testing\n\nTRAIT METHODS:\n- fn robot_triage(&self) -> Result\n- fn robot_next(&self) -> Result\n- fn robot_insights(&self) -> Result\n\nTESTING:\n- Test MockBvCli returns fixture data\n- Test RealBvCli parses actual bv output\n- Test error handling when bv unavailable\n\nFILE LOCATION:\nsrc-tauri/src/data/bv.rs\n\nNOTE: This trait completes the CLI wrapper trio (LoreCli, BeadsCli, BvCli) enabling full backend testability.","status":"open","priority":2,"issue_type":"feature","created_at":"2026-02-25T21:11:49.400225Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:12:02.355183Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-gil","depends_on_id":"bd-28q","type":"blocks","created_at":"2026-02-25T21:12:02.355167Z","created_by":"tayloreernisse"}]} +{"id":"bd-grs","title":"Implement app navigation and routing","description":"# App Navigation and Routing\n\n**Parent epic:** Phase 1: Foundation\n\n**Purpose:** Navigate between views (Focus, Queue, Inbox, Settings) with keyboard shortcuts and visual nav.\n\n**Navigation structure:**\n```\n┌─────────────────────────────────────────────────────────┐\n│ [Focus ⌘1] [Queue ⌘2] [Inbox ⌘3] [···] [⚙️ ⌘,] │\n├─────────────────────────────────────────────────────────┤\n│ │\n│ < Active View > │\n│ │\n└─────────────────────────────────────────────────────────┘\n```\n\n**Keyboard shortcuts:**\n| Key | Action |\n|-----|--------|\n| ⌘1 | Focus View |\n| ⌘2 | Queue View |\n| ⌘3 | Inbox View |\n| ⌘, | Settings |\n\n**TDD: Navigation tests (RED first):**\n\n```typescript\n// tests/components/Navigation.test.tsx\ndescribe('Navigation', () => {\n it('renders nav items', () => {\n render();\n \n expect(screen.getByRole('button', { name: /focus/i })).toBeInTheDocument();\n expect(screen.getByRole('button', { name: /queue/i })).toBeInTheDocument();\n expect(screen.getByRole('button', { name: /inbox/i })).toBeInTheDocument();\n });\n \n it('highlights active view', () => {\n const store = createStore({ activeView: 'queue' });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByRole('button', { name: /queue/i })).toHaveAttribute('data-active', 'true');\n });\n \n it('shows inbox badge count', () => {\n const store = createStore({ items: mockItems.filter(i => !i.triaged) });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByTestId('inbox-badge')).toHaveTextContent('3');\n });\n \n it('navigates on click', async () => {\n const setActiveView = vi.fn();\n const store = createStore({ setActiveView });\n render(, { wrapper: StoreProvider(store) });\n \n await userEvent.click(screen.getByRole('button', { name: /queue/i }));\n \n expect(setActiveView).toHaveBeenCalledWith('queue');\n });\n \n it('navigates on keyboard shortcut', async () => {\n const setActiveView = vi.fn();\n const store = createStore({ setActiveView });\n render(, { wrapper: StoreProvider(store) });\n \n await userEvent.keyboard('{Meta>}2{/Meta}');\n \n expect(setActiveView).toHaveBeenCalledWith('queue');\n });\n});\n\n// tests/App.test.tsx\ndescribe('App routing', () => {\n it('renders FocusView when activeView is focus', () => {\n const store = createStore({ activeView: 'focus' });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByTestId('focus-view')).toBeInTheDocument();\n });\n \n it('renders QueueView when activeView is queue', () => {\n const store = createStore({ activeView: 'queue' });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByTestId('queue-view')).toBeInTheDocument();\n });\n});\n```\n\n**Implementation:**\n\n```tsx\n// src/components/Navigation.tsx\nconst NAV_ITEMS = [\n { id: 'focus', label: 'Focus', shortcut: '⌘1' },\n { id: 'queue', label: 'Queue', shortcut: '⌘2' },\n { id: 'inbox', label: 'Inbox', shortcut: '⌘3', badge: true },\n];\n\nexport function Navigation() {\n const { activeView, setActiveView, items } = useStore();\n const inboxCount = items.filter(i => !i.triaged).length;\n \n useKeyboardShortcuts({\n 'mod+1': () => setActiveView('focus'),\n 'mod+2': () => setActiveView('queue'),\n 'mod+3': () => setActiveView('inbox'),\n 'mod+,': () => setActiveView('settings'),\n });\n \n return (\n \n );\n}\n\n// src/App.tsx\nexport function App() {\n const { activeView } = useStore();\n \n const views = {\n focus: ,\n queue: ,\n inbox: ,\n settings: ,\n };\n \n return (\n
\n \n
\n {views[activeView]}\n
\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- All nav items render\n- Active view highlighted\n- Inbox badge shows count\n- Click navigates\n- Keyboard shortcuts work\n- Correct view renders for each state","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:52:31.852445Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:53:42.180510Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-grs","depends_on_id":"bd-2x6","type":"blocks","created_at":"2026-02-25T20:53:42.180494Z","created_by":"tayloreernisse"},{"issue_id":"bd-grs","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:53:42.149875Z","created_by":"tayloreernisse"}]} +{"id":"bd-hee","title":"Phase 1: Foundation","description":"# Foundation — Tauri + React + Lore Integration\n\n**Context:** This phase creates the basic shell of Mission Control — a working Tauri app with system tray, global hotkey, and the ability to read data from lore CLI.\n\n**Why this matters:**\n- Foundation must be solid before building features on top\n- System tray + hotkey is core to the \"ambient awareness\" principle\n- Lore integration proves the CLI-shelling architecture works\n\n**Duration estimate:** 2-3 days\n\n**Scope includes:**\n1. Scaffold Tauri 2.0 + Vite + React project\n2. Basic window with system tray icon\n3. Global hotkey to toggle window (⌘⇧M)\n4. Lore CLI wrapper with JSON parsing (RED → GREEN)\n5. File watcher on lore.db for change detection (RED → GREEN)\n6. Display raw lore data in UI (visual verification)\n\n**Architecture decisions embedded:**\n- Tauri 2.0 for Rust backend + tiny bundle (~15MB vs Electron 150MB)\n- React 19 + Vite for fast iteration\n- CLI shelling (not library imports) for clean API boundaries\n\n**Dependencies:**\n- Requires Phase 0 (Test Infrastructure) complete\n- Blocks Phase 2 (Bridge) and all view phases\n\n**Acceptance criteria:**\n- Tauri app launches and shows in system tray\n- ⌘⇧M toggles window visibility\n- App can call `lore --robot me` and parse response\n- lore.db changes trigger refresh callback\n- Basic UI shows parsed lore data","status":"open","priority":2,"issue_type":"epic","created_at":"2026-02-25T20:25:49.298937Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:25:52.591883Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-hee","depends_on_id":"bd-jyz","type":"blocks","created_at":"2026-02-25T20:25:52.591859Z","created_by":"tayloreernisse"}]} +{"id":"bd-j76","title":"Implement BatchMode component","description":"# BatchMode Component (RED → GREEN)\n\n**Parent epic:** Phase 5: Batch Mode\n\n**TDD: BatchMode tests (RED first):**\n\n```typescript\n// tests/components/BatchMode.test.tsx\ndescribe('BatchMode', () => {\n const mockItems = [\n { id: '1', title: 'Review MR !847' },\n { id: '2', title: 'Review MR !902' },\n { id: '3', title: 'Review MR !915' },\n { id: '4', title: 'Review MR !918' },\n ];\n \n it('shows current item and progress', () => {\n render();\n \n expect(screen.getByText('BATCH: CODE REVIEWS')).toBeInTheDocument();\n expect(screen.getByText('1 of 4')).toBeInTheDocument();\n expect(screen.getByText('Review MR !847')).toBeInTheDocument();\n });\n \n it('shows progress bar', () => {\n render();\n \n const progress = screen.getByRole('progressbar');\n expect(progress).toHaveAttribute('aria-valuenow', '0');\n expect(progress).toHaveAttribute('aria-valuemax', '4');\n });\n \n it('Done advances to next item', async () => {\n render();\n \n await userEvent.click(screen.getByRole('button', { name: /done/i }));\n \n expect(screen.getByText('2 of 4')).toBeInTheDocument();\n expect(screen.getByText('Review MR !902')).toBeInTheDocument();\n expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '1');\n });\n \n it('Skip advances without marking complete', async () => {\n const onComplete = vi.fn();\n render();\n \n await userEvent.click(screen.getByRole('button', { name: /skip/i }));\n \n expect(screen.getByText('2 of 4')).toBeInTheDocument();\n expect(onComplete).not.toHaveBeenCalled();\n });\n \n it('Escape exits batch mode', async () => {\n const onExit = vi.fn();\n render();\n \n await userEvent.keyboard('{Escape}');\n \n expect(onExit).toHaveBeenCalled();\n });\n \n it('shows celebration on completion', async () => {\n render();\n \n // Complete all 4\n for (let i = 0; i < 4; i++) {\n await userEvent.click(screen.getByRole('button', { name: /done/i }));\n }\n \n expect(screen.getByText(/All done/)).toBeInTheDocument();\n expect(screen.getByTestId('confetti')).toBeInTheDocument();\n });\n \n it('keyboard shortcuts work', async () => {\n const onComplete = vi.fn();\n render();\n \n await userEvent.keyboard('{Meta>}d{/Meta}'); // ⌘D for Done\n \n expect(onComplete).toHaveBeenCalledWith('1');\n });\n});\n```\n\n**Implementation (GREEN):**\n\n```tsx\n// src/components/BatchMode.tsx\ninterface BatchModeProps {\n items: WorkItem[];\n type: ItemType;\n onComplete: (id: string) => void;\n onSkip: (id: string) => void;\n onExit: () => void;\n}\n\nexport function BatchMode({ items, type, onComplete, onSkip, onExit }: BatchModeProps) {\n const [currentIndex, setCurrentIndex] = useState(0);\n const [completedIds, setCompletedIds] = useState>(new Set());\n const [showCelebration, setShowCelebration] = useState(false);\n \n const currentItem = items[currentIndex];\n const progress = completedIds.size;\n \n useKeyboardShortcuts({\n 'mod+o': () => currentItem && open(currentItem.url),\n 'mod+d': handleDone,\n 'mod+s': handleSkip,\n 'escape': onExit,\n });\n \n const handleDone = () => {\n onComplete(currentItem.id);\n setCompletedIds(prev => new Set(prev).add(currentItem.id));\n advanceOrComplete();\n };\n \n const handleSkip = () => {\n onSkip(currentItem.id);\n advanceOrComplete();\n };\n \n const advanceOrComplete = () => {\n if (currentIndex === items.length - 1) {\n setShowCelebration(true);\n } else {\n setCurrentIndex(prev => prev + 1);\n }\n };\n \n if (showCelebration) {\n return ;\n }\n \n return (\n
\n
\n

BATCH: {getTypeLabel(type)}

\n

{currentIndex + 1} of {items.length}

\n \n
\n \n
\n \n
\n \n
\n \n \n \n
\n \n

ESC to exit batch

\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- All tests pass\n- Progress tracking works\n- Done/Skip advance correctly\n- Keyboard shortcuts work\n- Celebration shown on completion","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:34:07.281651Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:34:12.889457Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-j76","depends_on_id":"bd-3c2","type":"blocks","created_at":"2026-02-25T20:34:12.889428Z","created_by":"tayloreernisse"}]} +{"id":"bd-jri","title":"Implement contract tests for CLI outputs","description":"# Contract Tests for CLI Outputs\n\n**Parent epic:** Phase 0: Test Infrastructure\n\n**Purpose:** Detect schema drift in lore/br/bv CLI outputs before it breaks MC at runtime.\n\n**Strategy:**\n1. Fixtures contain real CLI outputs\n2. Rust types with `#[serde(deny_unknown_fields)]` fail on unexpected fields\n3. CI job regenerates fixtures and diffs against committed versions\n\n**Contract test structure:**\n\n```rust\n// src-tauri/tests/contract_test.rs\n\n#[test]\nfn lore_me_response_matches_fixture() {\n let fixture = include_str!(\"fixtures/lore/me_with_reviews.json\");\n \n // This will fail if:\n // 1. Required field is missing\n // 2. Field type changed\n // 3. Unknown field added (deny_unknown_fields)\n let result: Result = serde_json::from_str(fixture);\n \n assert!(result.is_ok(), \"Fixture parse failed: {:?}\", result.err());\n}\n\n#[test]\nfn lore_me_empty_response_matches_fixture() {\n let fixture = include_str!(\"fixtures/lore/me_empty.json\");\n let result: LoreMeResponse = serde_json::from_str(fixture)\n .expect(\"Empty response fixture should parse\");\n \n assert!(result.data.since_last_check.is_empty());\n}\n\n#[test]\nfn br_create_response_matches_fixture() {\n let fixture = include_str!(\"fixtures/br/create_success.json\");\n let result: BrCreateResponse = serde_json::from_str(fixture)\n .expect(\"BR create fixture should parse\");\n \n assert!(result.id.starts_with(\"bd-\") || result.id.starts_with(\"br-\"));\n}\n\n#[test]\nfn br_list_response_matches_fixture() {\n let fixture = include_str!(\"fixtures/br/list.json\");\n let result: Vec = serde_json::from_str(fixture)\n .expect(\"BR list fixture should parse\");\n \n assert!(!result.is_empty());\n}\n\n#[test]\nfn bv_triage_response_matches_fixture() {\n let fixture = include_str!(\"fixtures/bv/triage.json\");\n let result: BvTriageResponse = serde_json::from_str(fixture)\n .expect(\"BV triage fixture should parse\");\n \n // Validate expected structure\n assert!(result.recommendations.is_some() || result.quick_ref.is_some());\n}\n```\n\n**Type definitions with strict parsing:**\n\n```rust\n// src-tauri/src/data/types.rs\n\n#[derive(Deserialize, Debug)]\n#[serde(deny_unknown_fields)] // CRITICAL: Fails on new fields\npub struct LoreMeResponse {\n pub ok: bool,\n pub data: LoreMeData,\n #[serde(default)]\n pub meta: Option,\n}\n\n#[derive(Deserialize, Debug)]\n#[serde(deny_unknown_fields)]\npub struct LoreMeData {\n pub issues: Vec,\n pub mrs: LoreMrData,\n pub since_last_check: Vec,\n}\n```\n\n**Fixture regeneration script:**\n\n```bash\n#!/bin/bash\n# scripts/regenerate-fixtures.sh\nset -e\n\nFIXTURE_DIR=\"src-tauri/tests/fixtures\"\n\necho \"Regenerating CLI fixtures...\"\n\n# Lore fixtures\nlore --robot me > \"$FIXTURE_DIR/lore/me_current.json\"\nlore --robot me --issues > \"$FIXTURE_DIR/lore/issues.json\"\nlore --robot me --mrs > \"$FIXTURE_DIR/lore/mrs.json\"\n\n# BR fixtures\nbr list --json > \"$FIXTURE_DIR/br/list.json\"\n\n# BV fixtures\nbv --robot-triage > \"$FIXTURE_DIR/bv/triage.json\" 2>/dev/null || echo '{}' > \"$FIXTURE_DIR/bv/triage.json\"\n\necho \"Fixtures regenerated. Run 'git diff' to see changes.\"\n```\n\n**CI job:**\n\n```yaml\ncontract-tests:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n \n - name: Install CLIs\n run: |\n cargo install lore br bv\n \n - name: Regenerate fixtures\n run: ./scripts/regenerate-fixtures.sh\n \n - name: Check for drift\n run: |\n if ! git diff --exit-code src-tauri/tests/fixtures/; then\n echo \"::error::CLI output schema has changed! Update types and fixtures.\"\n exit 1\n fi\n \n - name: Run contract tests\n run: cargo test contract\n```\n\n**Acceptance criteria:**\n- All fixture files parse with deny_unknown_fields\n- Regeneration script works\n- CI fails on schema drift\n- Clear error messages for parsing failures","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:53:29.013746Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:53:42.360627Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-jri","depends_on_id":"bd-1ps","type":"blocks","created_at":"2026-02-25T20:53:42.360610Z","created_by":"tayloreernisse"},{"issue_id":"bd-jri","depends_on_id":"bd-jyz","type":"blocks","created_at":"2026-02-25T20:53:42.329014Z","created_by":"tayloreernisse"}]} +{"id":"bd-jsi","title":"Implement keyboard shortcuts for Focus View","description":"# Keyboard Shortcuts for Focus View (RED → GREEN)\n\n**Parent epic:** Phase 3: Focus View\n\n**Shortcuts:**\n| Key | Action |\n|-----|--------|\n| Enter | Start (open in browser) |\n| ⌘1 | Defer 1 hour |\n| ⌘2 | Defer tomorrow |\n| ⌘S | Skip |\n| ⌘⇧C | Quick capture (global, but also here) |\n\n**TDD: Keyboard tests (RED first):**\n\n```typescript\n// tests/components/FocusCard.test.tsx\ndescribe('FocusCard keyboard shortcuts', () => {\n it('calls onStart when Enter pressed', async () => {\n const onStart = vi.fn();\n render();\n \n await userEvent.keyboard('{Enter}');\n \n expect(onStart).toHaveBeenCalledWith(mockItem.id);\n });\n \n it('calls onDefer with 1h when ⌘1 pressed', async () => {\n const onDefer = vi.fn();\n render();\n \n await userEvent.keyboard('{Meta>}1{/Meta}');\n \n expect(onDefer).toHaveBeenCalledWith(mockItem.id, '1h');\n });\n \n it('calls onDefer with tomorrow when ⌘2 pressed', async () => {\n const onDefer = vi.fn();\n render();\n \n await userEvent.keyboard('{Meta>}2{/Meta}');\n \n expect(onDefer).toHaveBeenCalledWith(mockItem.id, 'tomorrow');\n });\n \n it('calls onSkip when ⌘S pressed', async () => {\n const onSkip = vi.fn();\n render();\n \n await userEvent.keyboard('{Meta>}s{/Meta}');\n \n expect(onSkip).toHaveBeenCalledWith(mockItem.id);\n });\n \n it('does not trigger shortcuts when input focused', async () => {\n const onStart = vi.fn();\n render(\n <>\n \n \n \n );\n \n screen.getByTestId('other-input').focus();\n await userEvent.keyboard('{Enter}');\n \n expect(onStart).not.toHaveBeenCalled();\n });\n});\n```\n\n**Implementation (GREEN):**\n\n```typescript\n// src/hooks/useKeyboard.ts\nexport function useKeyboardShortcuts(\n shortcuts: Record void>,\n options: { enabled?: boolean } = {}\n) {\n const { enabled = true } = options;\n \n useEffect(() => {\n if (!enabled) return;\n \n const handler = (e: KeyboardEvent) => {\n // Don't trigger in input/textarea\n if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {\n return;\n }\n \n const key = buildKeyString(e);\n const action = shortcuts[key];\n if (action) {\n e.preventDefault();\n action();\n }\n };\n \n window.addEventListener('keydown', handler);\n return () => window.removeEventListener('keydown', handler);\n }, [shortcuts, enabled]);\n}\n\nfunction buildKeyString(e: KeyboardEvent): string {\n const parts = [];\n if (e.metaKey || e.ctrlKey) parts.push('mod');\n if (e.shiftKey) parts.push('shift');\n if (e.altKey) parts.push('alt');\n parts.push(e.key.toLowerCase());\n return parts.join('+');\n}\n```\n\n**Usage in FocusCard:**\n\n```tsx\nexport function FocusCard({ item, onStart, onDefer, onSkip }) {\n useKeyboardShortcuts({\n 'enter': () => onStart(item.id),\n 'mod+1': () => onDefer(item.id, '1h'),\n 'mod+2': () => onDefer(item.id, 'tomorrow'),\n 'mod+s': () => onSkip(item.id),\n });\n \n // ... render\n}\n```\n\n**Acceptance criteria:**\n- All shortcut tests pass\n- Shortcuts don't fire when typing in inputs\n- Visual feedback shows shortcut hints on buttons\n- Works on both Mac (⌘) and potential future Windows (Ctrl)","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:31:17.260266Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:32:00.887015Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-jsi","depends_on_id":"bd-1ft","type":"blocks","created_at":"2026-02-25T20:32:00.812957Z","created_by":"tayloreernisse"},{"issue_id":"bd-jsi","depends_on_id":"bd-1kr","type":"blocks","created_at":"2026-02-25T20:32:00.886999Z","created_by":"tayloreernisse"}]} +{"id":"bd-jyz","title":"Phase 0: Test Infrastructure","description":"# Test Infrastructure Foundation\n\n**Context:** Mission Control follows TDD (RED → GREEN → REFACTOR). Before any feature work, we need a complete testing foundation that supports both Rust backend tests and React frontend tests.\n\n**Why this matters:**\n- Every feature will be implemented RED → GREEN (failing test first, then implementation)\n- CLI integration (lore, br, bv) requires fixture-based contract testing\n- Crash recovery and state machine tests need trait-based mocking in Rust\n- E2E tests need Playwright + Tauri integration\n\n**Scope includes:**\n1. Configure Vitest for frontend unit/component tests\n2. Configure Playwright for E2E tests \n3. Set up Rust test harness with trait-based mocking\n4. Create fixture directory structure (tests/fixtures/lore/, tests/fixtures/br/)\n5. Capture initial CLI fixtures from real lore/br outputs\n6. Add test commands to package.json and Cargo.toml\n\n**Duration estimate:** 0.5 day\n\n**Success criteria:**\n- `npm run test` runs Vitest with React Testing Library\n- `npm run test:e2e` runs Playwright\n- `cargo test` runs Rust tests with mocking infrastructure\n- Fixture files exist with real CLI output samples\n- Scripts exist to regenerate fixtures\n\n**Architecture notes:**\n- Rust uses trait-based mocking (LoreCli, BeadsCli traits)\n- Frontend uses @tauri-apps/api/mocks for IPC mocking\n- Fixtures capture real CLI outputs for contract testing\n- CI will compare fresh fixture regeneration against committed fixtures","status":"open","priority":2,"issue_type":"epic","created_at":"2026-02-25T20:24:48.843252Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:24:48.843252Z","compaction_level":0,"original_size":0} +{"id":"bd-qvc","title":"Implement Inbox view with triage actions","description":"# Inbox View with Triage Actions (RED → GREEN)\n\n**Parent epic:** Phase 4: Queue + Inbox Views\n\n**Purpose:** New items land in Inbox first for triage. Achievable inbox zero is the goal.\n\n**Triage actions:**\n| Action | Result |\n|--------|--------|\n| Accept | Move to Queue |\n| Defer | Snooze for later |\n| Archive | Mark as \"not actionable for me\" |\n\n**TDD: Inbox tests (RED first):**\n\n```typescript\n// tests/components/Inbox.test.tsx\ndescribe('Inbox', () => {\n const mockNewItems: WorkItem[] = [\n { id: '1', type: 'mention', title: 'You were mentioned in #312', triaged: false },\n { id: '2', type: 'mr_feedback', title: 'Comment on MR !847', triaged: false },\n ];\n \n it('shows only untriaged items', () => {\n const items = [...mockNewItems, { id: '3', triaged: true, ... }];\n render();\n \n expect(screen.getAllByTestId('inbox-item')).toHaveLength(2);\n });\n \n it('shows inbox zero state when empty', () => {\n render();\n \n expect(screen.getByText(/Inbox Zero/)).toBeInTheDocument();\n expect(screen.getByText(/All caught up/)).toBeInTheDocument();\n });\n \n it('accept moves item to queue', async () => {\n const onTriage = vi.fn();\n render();\n \n await userEvent.click(screen.getAllByRole('button', { name: /accept/i })[0]);\n \n expect(onTriage).toHaveBeenCalledWith('1', 'accept');\n });\n \n it('defer shows duration picker', async () => {\n render();\n \n await userEvent.click(screen.getAllByRole('button', { name: /defer/i })[0]);\n \n expect(screen.getByRole('dialog')).toBeInTheDocument();\n expect(screen.getByText('1 hour')).toBeInTheDocument();\n expect(screen.getByText('Tomorrow')).toBeInTheDocument();\n });\n \n it('archive removes item from queue', async () => {\n const onTriage = vi.fn();\n render();\n \n await userEvent.click(screen.getAllByRole('button', { name: /archive/i })[0]);\n \n expect(onTriage).toHaveBeenCalledWith('1', 'archive');\n });\n \n it('keyboard shortcuts work for triage', async () => {\n const onTriage = vi.fn();\n render();\n \n // Focus first item\n await userEvent.tab();\n // Press A for Accept\n await userEvent.keyboard('a');\n \n expect(onTriage).toHaveBeenCalledWith('1', 'accept');\n });\n});\n```\n\n**Implementation (GREEN):**\n\n```tsx\n// src/components/Inbox.tsx\nexport function Inbox({ items, onTriage }: InboxProps) {\n const untriagedItems = items.filter(i => !i.triaged);\n \n if (untriagedItems.length === 0) {\n return (\n
\n \n

Inbox Zero

\n

All caught up!

\n
\n );\n }\n \n return (\n
\n

Inbox ({untriagedItems.length})

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

{item.title}

\n

{item.metadata?.snippet}

\n
\n \n
\n \n \n \n
\n
\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- Only untriaged items shown\n- Inbox zero celebration state\n- Accept/Defer/Archive actions work\n- Keyboard shortcuts for fast triage\n- Triage decisions logged","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:33:12.523788Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:33:34.890847Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-qvc","depends_on_id":"bd-716","type":"blocks","created_at":"2026-02-25T20:33:34.890832Z","created_by":"tayloreernisse"}]} +{"id":"bd-sec","title":"Implement Settings UI","description":"# Settings UI\n\n**Parent epic:** Phase 7: Polish + E2E Tests\n\n**Purpose:** Allow users to customize Mission Control behavior.\n\n**Settings to include:**\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| Toggle hotkey | Keybinding | ⌘⇧M | Customize window toggle |\n| Capture hotkey | Keybinding | ⌘⇧C | Customize quick capture |\n| Lore DB path | File path | ~/.local/share/lore/lore.db | Custom lore location |\n| Reconciliation interval | Number | 6 | Hours between full reconciliation |\n| Show floating widget | Boolean | false | Enable persistent mini-view |\n| Default defer duration | Select | 1h | Default snooze time |\n| Sound effects | Boolean | true | Celebration sounds |\n\n**File location:** `~/.local/share/mc/settings.json`\n\n**Schema:**\n```json\n{\n \"schema_version\": 1,\n \"hotkeys\": {\n \"toggle\": \"Meta+Shift+M\",\n \"capture\": \"Meta+Shift+C\"\n },\n \"lore_db_path\": null,\n \"reconciliation_hours\": 6,\n \"floating_widget\": false,\n \"default_defer\": \"1h\",\n \"sounds\": true\n}\n```\n\n**TDD: Settings tests (RED first):**\n\n```typescript\n// tests/components/Settings.test.tsx\ndescribe('Settings', () => {\n it('loads current settings on mount', async () => {\n render();\n \n await waitFor(() => {\n expect(screen.getByDisplayValue('⌘⇧M')).toBeInTheDocument();\n });\n });\n \n it('saves settings on change', async () => {\n const mockSave = vi.fn();\n render();\n \n await userEvent.click(screen.getByLabelText('Sound effects'));\n \n expect(mockSave).toHaveBeenCalledWith(expect.objectContaining({\n sounds: false\n }));\n });\n \n it('validates hotkey format', async () => {\n render();\n \n await userEvent.clear(screen.getByLabelText('Toggle hotkey'));\n await userEvent.type(screen.getByLabelText('Toggle hotkey'), 'invalid');\n \n expect(screen.getByText(/Invalid hotkey format/)).toBeInTheDocument();\n });\n});\n```\n\n**Implementation:**\n\n```tsx\n// src/views/SettingsView.tsx\nexport function SettingsView() {\n const { data: settings, mutate } = useSettings();\n \n return (\n
\n

Settings

\n \n
\n
\n

Hotkeys

\n mutate({ ...settings, hotkeys: { ...settings.hotkeys, toggle: v }})}\n />\n mutate({ ...settings, hotkeys: { ...settings.hotkeys, capture: v }})}\n />\n
\n \n
\n

Behavior

\n mutate({ ...settings, sounds: v })}\n />\n mutate({ ...settings, floating_widget: v })}\n />\n
\n
\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- All settings load and save correctly\n- Hotkey changes take effect immediately\n- Invalid inputs show validation errors\n- Settings persist across app restarts","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:50:39.809809Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:53:41.765926Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-sec","depends_on_id":"bd-25l","type":"blocks","created_at":"2026-02-25T20:53:41.731118Z","created_by":"tayloreernisse"},{"issue_id":"bd-sec","depends_on_id":"bd-2x6","type":"blocks","created_at":"2026-02-25T20:53:41.765906Z","created_by":"tayloreernisse"}]} +{"id":"bd-uh6","title":"System tray icon with badge counts","description":"# System Tray Icon Implementation\n\n**Parent epic:** Phase 1: Foundation\n\n**UX principle:** Ambient awareness, not interruption. The tray icon shows at-a-glance status without being modal.\n\n**Implementation:**\n\n1. **Tauri tray setup (src-tauri/src/tray.rs):**\n ```rust\n use tauri::{\n tray::{TrayIconBuilder, TrayIconEvent},\n Manager, Runtime,\n };\n\n pub fn setup_tray(app: &tauri::App) -> Result<()> {\n let tray = TrayIconBuilder::new()\n .icon(app.default_window_icon().unwrap().clone())\n .tooltip(\"Mission Control\")\n .on_tray_icon_event(|tray, event| {\n match event {\n TrayIconEvent::Click { button: LeftButton, .. } => {\n // Toggle popover\n }\n _ => {}\n }\n })\n .build(app)?;\n Ok(())\n }\n ```\n\n2. **Badge count updates:**\n - Tray icon can show numeric badge (macOS native)\n - Update badge when work item count changes\n - Badge reflects: pending items + inbox items\n\n3. **Icon assets:**\n - Create icons at multiple sizes (16x16, 32x32, 64x64)\n - Template image for macOS (works with light/dark menu bar)\n - Colored variants for badge states\n\n4. **Menu on right-click:**\n - \"Show Mission Control\" (opens full window)\n - \"Quick Capture\" (opens capture overlay)\n - Separator\n - \"Quit\"\n\n**Integration with frontend:**\n- Tauri event to update badge count\n- Frontend sends count updates via invoke\n\n**Acceptance criteria:**\n- Tray icon appears when app runs\n- Left-click shows popover (or toggles window)\n- Right-click shows context menu\n- Badge count updates reflect actual pending items\n- Works on macOS (primary target)","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:26:14.428110Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:27:09.133672Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-uh6","depends_on_id":"bd-3fd","type":"blocks","created_at":"2026-02-25T20:27:09.133655Z","created_by":"tayloreernisse"},{"issue_id":"bd-uh6","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:27:07.913228Z","created_by":"tayloreernisse"}]} +{"id":"bd-wlg","title":"Implement menu bar popover","description":"# Menu Bar Popover\n\n**Parent epic:** Phase 1: Foundation\n\n**Purpose:** Quick glance at THE ONE THING without opening full window. Click tray icon → popover appears.\n\n**Visual design:**\n```\n ┌─────────────────────────────┐\n │ THE ONE THING │\n │ Review MR !847 │\n │ 2d waiting · @sarah │\n │ │\n │ [Start] [Defer] [Skip] │\n ├─────────────────────────────┤\n │ Queue: 4 Inbox: 3 │\n │ ⌘⇧F Full window │\n └─────────────────────────────┘\n```\n\n**TDD: Popover tests (RED first):**\n\n```typescript\n// tests/components/Popover.test.tsx\ndescribe('TrayPopover', () => {\n it('shows current focus item', () => {\n render();\n \n expect(screen.getByText('THE ONE THING')).toBeInTheDocument();\n expect(screen.getByText('Review MR !847')).toBeInTheDocument();\n });\n \n it('shows queue and inbox counts', () => {\n render();\n \n expect(screen.getByText('Queue: 4')).toBeInTheDocument();\n expect(screen.getByText('Inbox: 3')).toBeInTheDocument();\n });\n \n it('shows empty state when no focus', () => {\n render();\n \n expect(screen.getByText(/Nothing focused/)).toBeInTheDocument();\n });\n \n it('Start action opens browser', async () => {\n const mockOpen = vi.fn();\n render();\n \n await userEvent.click(screen.getByRole('button', { name: /start/i }));\n \n expect(mockOpen).toHaveBeenCalled();\n });\n});\n```\n\n**Implementation:**\n\n```tsx\n// src/components/TrayPopover.tsx\ninterface TrayPopoverProps {\n focusItem: WorkItem | null;\n queueCount: number;\n inboxCount: number;\n onStart: () => void;\n onDefer: (duration: string) => void;\n onSkip: () => void;\n onOpenFull: () => void;\n}\n\nexport function TrayPopover({\n focusItem,\n queueCount,\n inboxCount,\n onStart,\n onDefer,\n onSkip,\n onOpenFull\n}: TrayPopoverProps) {\n return (\n
\n {focusItem ? (\n <>\n

THE ONE THING

\n

{focusItem.title}

\n

\n {formatAge(focusItem.createdAt)} · {focusItem.metadata?.author}\n

\n \n
\n \n \n \n
\n \n ) : (\n

Nothing focused. Pick something from the queue!

\n )}\n \n \n \n
\n Queue: {queueCount}\n Inbox: {inboxCount}\n
\n \n \n
\n );\n}\n```\n\n**Tauri integration:**\n\n```rust\n// Popover window configuration in tauri.conf.json\n{\n \"windows\": [\n {\n \"label\": \"popover\",\n \"width\": 288,\n \"height\": 200,\n \"decorations\": false,\n \"alwaysOnTop\": true,\n \"visible\": false,\n \"skipTaskbar\": true\n }\n ]\n}\n```\n\n**Acceptance criteria:**\n- Popover appears on tray icon click\n- Shows THE ONE THING or empty state\n- Actions work (Start/Defer/Skip)\n- Shows queue and inbox counts\n- Can open full window from popover","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:50:55.640948Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:53:41.824670Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-wlg","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:53:41.794862Z","created_by":"tayloreernisse"},{"issue_id":"bd-wlg","depends_on_id":"bd-uh6","type":"blocks","created_at":"2026-02-25T20:53:41.824653Z","created_by":"tayloreernisse"}]} +{"id":"bd-xsp","title":"Register global hotkey for Quick Capture (⌘⇧C)","description":"# Global Hotkey for Quick Capture\n\n**Parent epic:** Phase 6: Quick Capture\n\n**Requirement:** ⌘⇧C (Cmd+Shift+C) must work from ANY app, even when Mission Control is not focused.\n\n**Implementation:**\n\n```rust\n// src-tauri/src/shortcuts.rs\npub fn setup_shortcuts(app: &tauri::App) -> Result<()> {\n use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut};\n \n // Toggle window (⌘⇧M)\n let toggle_shortcut = Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyM);\n \n // Quick capture (⌘⇧C)\n let capture_shortcut = Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyC);\n \n app.global_shortcut().on_shortcut(capture_shortcut, |app, _| {\n // Show capture overlay\n if let Some(window) = app.get_webview_window(\"main\") {\n // Make window visible if hidden\n if !window.is_visible().unwrap_or(false) {\n window.show().ok();\n }\n window.set_focus().ok();\n \n // Emit event to show capture overlay\n app.emit_all(\"show-quick-capture\", ()).ok();\n }\n })?;\n \n Ok(())\n}\n```\n\n**Frontend listener:**\n\n```typescript\n// src/App.tsx\nfunction App() {\n const [showCapture, setShowCapture] = useState(false);\n \n useEffect(() => {\n const unlisten = listen('show-quick-capture', () => {\n setShowCapture(true);\n });\n \n return () => { unlisten.then(fn => fn()); };\n }, []);\n \n return (\n <>\n {/* ... other views */}\n {\n await invoke('quick_capture', { text });\n setShowCapture(false);\n }}\n onCancel={() => setShowCapture(false)}\n />\n \n );\n}\n```\n\n**Window behavior:**\n1. If MC is hidden → show window + show overlay\n2. If MC is visible but overlay closed → show overlay\n3. If overlay is open → do nothing (or close it?)\n\n**Testing considerations:**\n- Global shortcuts are hard to test automatically\n- Manual testing required for cross-app behavior\n- E2E test can verify overlay behavior after trigger\n\n**Acceptance criteria:**\n- ⌘⇧C works from any app\n- Overlay appears within 200ms\n- Window comes to front if needed\n- No conflict with other common shortcuts","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:34:51.502940Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:34:55.514976Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-xsp","depends_on_id":"bd-24r","type":"blocks","created_at":"2026-02-25T20:34:55.514960Z","created_by":"tayloreernisse"}]} +{"id":"bd-xvy","title":"File watcher on lore.db for change detection","description":"# File Watcher for Lore Database Changes (RED → GREEN)\n\n**Parent epic:** Phase 1: Foundation\n\n**Why file watching:**\nLore runs on a cron schedule (not triggered by MC). When lore syncs new data, MC should refresh automatically. We watch lore.db's mtime for changes rather than polling the CLI.\n\n**TDD approach:**\n\n1. **RED: Write file watcher tests:**\n ```rust\n // src-tauri/tests/watcher_test.rs\n #[tokio::test]\n async fn watcher_detects_mtime_change() {\n let temp = tempfile::tempdir().unwrap();\n let db_path = temp.path().join(\"lore.db\");\n std::fs::write(&db_path, \"initial\").unwrap();\n \n let (tx, mut rx) = tokio::sync::mpsc::channel(1);\n let _watcher = FileWatcher::new(&db_path, tx);\n \n // Modify file\n std::fs::write(&db_path, \"modified\").unwrap();\n \n // Should receive change notification\n let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())\n .await\n .expect(\"Should receive event\")\n .expect(\"Channel shouldn't close\");\n \n assert!(matches!(event, WatchEvent::Modified));\n }\n \n #[tokio::test]\n async fn watcher_debounces_rapid_changes() {\n // Multiple rapid writes should coalesce to single event\n }\n ```\n\n2. **GREEN: Implement watcher:**\n ```rust\n // src-tauri/src/watcher.rs\n use notify::{Watcher, RecursiveMode, Config, RecommendedWatcher};\n \n pub struct LoreDbWatcher {\n _watcher: RecommendedWatcher,\n }\n \n impl LoreDbWatcher {\n pub fn new(db_path: &Path, on_change: F) -> Result\n where\n F: Fn() + Send + 'static\n {\n let debounce_duration = Duration::from_millis(500);\n let config = Config::default()\n .with_poll_interval(Duration::from_secs(2));\n \n let mut watcher = RecommendedWatcher::new(\n move |res: Result| {\n if let Ok(event) = res {\n if event.kind.is_modify() {\n on_change();\n }\n }\n },\n config,\n )?;\n \n watcher.watch(db_path, RecursiveMode::NonRecursive)?;\n \n Ok(Self { _watcher: watcher })\n }\n }\n ```\n\n3. **Integration with Tauri:**\n ```rust\n // When lore.db changes, emit event to frontend\n let watcher = LoreDbWatcher::new(&lore_db_path, move || {\n app_handle.emit_all(\"lore-db-changed\", ()).ok();\n })?;\n ```\n\n**Lore.db location:**\n- Default: `~/.local/share/lore/lore.db`\n- Should be configurable via settings\n\n**Debouncing:**\n- Lore writes may trigger multiple filesystem events\n- Debounce to single refresh per 500ms window\n\n**Acceptance criteria:**\n- File changes detected within 2 seconds\n- Rapid changes debounced to single event\n- Frontend receives Tauri event on change\n- Works with both native and poll-based watchers\n- Graceful handling of missing/moved database file","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:26:51.136440Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:27:09.192343Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-xvy","depends_on_id":"bd-1ev","type":"blocks","created_at":"2026-02-25T20:27:09.192329Z","created_by":"tayloreernisse"},{"issue_id":"bd-xvy","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:27:07.970138Z","created_by":"tayloreernisse"}]} +{"id":"bd-z4n","title":"Implement crash-safe write-ahead pattern with pending flag","description":"# Crash Recovery with Write-Ahead Pattern (RED → GREEN)\n\n**Parent epic:** Phase 2: Bridge + Data Layer\n\n**Problem being solved:**\nIf MC crashes mid-sync, we risk:\n- Duplicates: bead created but not mapped\n- Lost events: cursor advanced but bead not created\n\n**Solution:** Write-ahead pattern with idempotent operations.\n\n**For each new event:**\n```\n1. Check if key exists in mapping → if yes, skip (idempotent)\n2. Write mapping entry FIRST: {key} → {bead_id: null, pending: true}\n3. Create bead via `br create`\n4. Update mapping: {bead_id: actual_id, pending: false}\n5. On success of all events: advance cursor\n```\n\n**Crash recovery (on startup):**\n```\n1. Scan mapping for entries with pending: true\n2. For each pending entry:\n - If bead_id is null → retry `br create`, update mapping\n - If bead_id exists but pending → verify bead exists, clear pending flag\n3. Do NOT advance cursor until all pending entries resolved\n```\n\n**TDD: Crash recovery tests (RED first):**\n\n```rust\n#[test]\nfn crash_before_br_create_retries_on_startup() {\n // Simulate state: mapping written but br create never ran\n let mapping = mapping_with_pending(\"mr:gitlab:123:847\", None); // null bead_id\n let beads = MockBeadsCli::new();\n \n let bridge = BridgeState::recover(lore, beads, mapping)?;\n \n // Should have retried create\n assert!(beads.calls().contains(&BeadsCall::Create { .. }));\n \n // Should have updated mapping with real bead_id\n let entry = bridge.mapping.get(\"mr:gitlab:123:847\").unwrap();\n assert!(entry.bead_id.is_some());\n assert!(!entry.pending);\n}\n\n#[test]\nfn crash_after_br_create_verifies_bead_exists() {\n // Simulate state: bead created but pending flag not cleared\n let mapping = mapping_with_pending(\"mr:gitlab:123:847\", Some(\"br-x7f\"));\n let beads = MockBeadsCli::with_existing(\"br-x7f\");\n \n let bridge = BridgeState::recover(lore, beads, mapping)?;\n \n // Should have verified bead exists\n assert!(beads.calls().contains(&BeadsCall::Exists { id: \"br-x7f\" }));\n \n // Should have cleared pending flag\n let entry = bridge.mapping.get(\"mr:gitlab:123:847\").unwrap();\n assert!(!entry.pending);\n}\n\n#[test]\nfn crash_after_br_create_but_bead_missing_retries() {\n // Edge case: br create succeeded but bead was somehow deleted\n let mapping = mapping_with_pending(\"mr:gitlab:123:847\", Some(\"br-x7f\"));\n let beads = MockBeadsCli::empty(); // bead doesn't exist\n \n let bridge = BridgeState::recover(lore, beads, mapping)?;\n \n // Should have created a new bead\n let entry = bridge.mapping.get(\"mr:gitlab:123:847\").unwrap();\n assert!(entry.bead_id.is_some());\n assert_ne!(entry.bead_id.as_deref(), Some(\"br-x7f\")); // New ID\n}\n\n#[test]\nfn cursor_not_advanced_until_pending_resolved() {\n let mapping = mapping_with_pending(\"mr:gitlab:123:847\", None);\n let old_cursor = mapping.cursor.clone();\n let beads = MockBeadsCli::new();\n \n let bridge = BridgeState::recover(lore, beads, mapping)?;\n \n // Even if recovery succeeds, cursor should be what it was\n // (because we don't know what events were missed)\n assert_eq!(bridge.cursor.last_check_timestamp, old_cursor.last_check_timestamp);\n}\n```\n\n**Implementation (GREEN):**\n\n```rust\nimpl BridgeState {\n pub fn recover(lore: L, beads: B, mapping: Mapping) -> Result {\n let mut bridge = Self { lore, beads, mapping };\n \n // Find all pending entries\n let pending: Vec<_> = bridge.mapping.iter()\n .filter(|(_, e)| e.pending)\n .map(|(k, e)| (k.clone(), e.clone()))\n .collect();\n \n for (key, entry) in pending {\n if let Some(bead_id) = &entry.bead_id {\n // bead_id exists — verify it's real\n if bridge.beads.exists(bead_id)? {\n // Just clear pending flag\n bridge.mapping.get_mut(&key).unwrap().pending = false;\n } else {\n // Bead was lost — recreate\n let new_id = bridge.beads.create(&key, \"gitlab\")?;\n let e = bridge.mapping.get_mut(&key).unwrap();\n e.bead_id = Some(new_id);\n e.pending = false;\n }\n } else {\n // bead_id is null — create was never attempted\n let bead_id = bridge.beads.create(&key, \"gitlab\")?;\n let e = bridge.mapping.get_mut(&key).unwrap();\n e.bead_id = Some(bead_id);\n e.pending = false;\n }\n }\n \n // Save recovered mapping\n bridge.save_mapping()?;\n \n Ok(bridge)\n }\n \n pub fn create_bead_for_event(&mut self, event: &LoreEvent) -> Result<()> {\n let key = event.to_mapping_key();\n \n // Step 1: Idempotency check\n if self.mapping.contains_key(&key) {\n return Ok(());\n }\n \n // Step 2: Write pending entry FIRST\n self.mapping.insert(key.clone(), MappingEntry {\n bead_id: None,\n pending: true,\n ..Default::default()\n });\n self.save_mapping()?;\n \n // Step 3: Create bead\n let bead_id = self.beads.create(&event.title(), \"gitlab\")?;\n \n // Step 4: Update entry with bead_id, clear pending\n let entry = self.mapping.get_mut(&key).unwrap();\n entry.bead_id = Some(bead_id);\n entry.pending = false;\n self.save_mapping()?;\n \n Ok(())\n }\n}\n```\n\n**Why this works:**\n- Step 1 is idempotent (duplicate events skip)\n- Step 2 happens before bead creation (we know we intend to create)\n- Step 5 only advances cursor after ALL events processed\n- Recovery finds incomplete work and finishes it\n\n**Acceptance criteria:**\n- All 4 crash recovery tests pass\n- Pending flag correctly tracks in-flight operations\n- Recovery handles all failure scenarios\n- No duplicates or lost events possible","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:29:21.002857Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:12:03.136841Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-z4n","depends_on_id":"bd-1jy","type":"blocks","created_at":"2026-02-25T20:30:23.168596Z","created_by":"tayloreernisse"},{"issue_id":"bd-z4n","depends_on_id":"bd-1q7","type":"blocks","created_at":"2026-02-25T20:30:23.339217Z","created_by":"tayloreernisse"},{"issue_id":"bd-z4n","depends_on_id":"bd-2us","type":"blocks","created_at":"2026-02-25T20:30:20.739901Z","created_by":"tayloreernisse"},{"issue_id":"bd-z4n","depends_on_id":"bd-3px","type":"blocks","created_at":"2026-02-25T21:12:03.136825Z","created_by":"tayloreernisse"}]} diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 0000000..c787975 --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,4 @@ +{ + "database": "beads.db", + "jsonl_export": "issues.jsonl" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e663b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# bv (beads viewer) local config and caches +.bv/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2fba0e8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,63 @@ +# CLAUDE.md + +## Project Overview + +Mission Control (MC) is an ADHD-centric personal productivity hub built with Tauri + React. It unifies GitLab activity (via gitlore) and beads task tracking into a single native interface. + +**Core principle:** Surface THE ONE THING you should be doing right now. + +## Key Documents + +- `PLAN.md` — Complete implementation plan with architecture, ACs, and reasoning + +## Tech Stack + +- **Shell:** Tauri 2.0 (Rust backend) +- **Frontend:** React 19 + Vite + Tailwind + shadcn/ui +- **State:** Zustand + TanStack Query +- **Animations:** Framer Motion + +## External Dependencies + +MC shells out to these CLIs (does NOT import them as libraries): + +- `lore --robot` — GitLab data (issues, MRs, activity) +- `br` — Beads task management (create, close, list) +- `bv --robot-*` — Beads triage recommendations + +## Local State + +All MC-specific state lives in `~/.local/share/mc/`: + +| File | Purpose | +|------|---------| +| `gitlab_bead_map.json` | Maps GitLab events to bead IDs (deduplication) | +| `decision_log.jsonl` | Append-only log of all user decisions with context | +| `state.json` | Current focus, queue order, UI state | +| `settings.json` | User preferences | + +## Key Architectural Decisions + +1. **Beads are the universal task graph** — GitLab items auto-create beads via MC's bridge +2. **Manual priority first** — User sets THE ONE THING, MC logs decisions to learn patterns +3. **CLI integration over library imports** — Clean boundaries, no schema coupling +4. **Rich decision logging** — Every action logged with context and optional reasoning + +## Development Commands + +```bash +# Frontend dev +npm run dev + +# Tauri dev (frontend + backend) +npm run tauri dev + +# Build +npm run tauri build +``` + +## Code Quality + +- Run `cargo clippy -- -D warnings` before committing Rust changes +- Run `npm run lint` before committing frontend changes +- Follow existing patterns in the codebase diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..6db8811 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,1441 @@ +# Mission Control — Implementation Plan + +> **Version:** 1.0 +> **Created:** 2026-02-25 +> **Status:** Planning Complete, Ready for Implementation + +## Executive Summary + +Mission Control (MC) is an ADHD-centric personal productivity hub that unifies GitLab activity, beads task tracking, and manual task management into a single, beautiful native interface. The core UX principle: **surface THE ONE THING you should be doing right now**. + +This is NOT a dashboard of everything. It's a trusted advisor that understands your work and helps you decide what matters. + +--- + +## Table of Contents + +1. [Vision & Principles](#vision--principles) +2. [Architecture Overview](#architecture-overview) +3. [Tech Stack](#tech-stack) +4. [Data Model](#data-model) +5. [GitLab → Beads Bridge](#gitlab--beads-bridge) +6. [Views & UX](#views--ux) +7. [Priority & Decision System](#priority--decision-system) +8. [Implementation Phases](#implementation-phases) +9. [Acceptance Criteria](#acceptance-criteria) +10. [Open Questions](#open-questions) + +--- + +## Vision & Principles + +### The Core Question + +> "What should I actually be doing right now?" + +MC answers this question. Not with 47 items. With ONE. + +### ADHD-Centric Design Principles + +| Principle | Implementation | +|-----------|----------------| +| **The One Thing** | UI's primary job is surfacing THE single most important thing. Everything else is peripheral. | +| **Achievable Inbox Zero** | Every view must have a clearable state. The psychological win of "inbox zero" everywhere. | +| **Time Decay Visibility** | Age is visceral, not hidden in timestamps. Fresh=bright, 3d=amber, 7d+=red pulse. | +| **Batch Mode for Flow** | "You have 4 code reviews. Want to batch them? (~20 min)" | +| **Quick Capture, Trust the System** | One hotkey, type it, gone. System triages later. | +| **Context Bookmarking** | When you switch away, system bookmarks exactly where you were. | +| **Ambient Awareness, Not Interruption** | Notifications are visible (badge, color) but never modal. | + +### What MC Is NOT + +- Not a GitLab replacement (use GitLab for actual work) +- Not a beads replacement (beads is the task graph, MC is the interface) +- Not an automated prioritization system (you decide, MC learns) + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ MISSION CONTROL │ +│ Tauri Desktop App │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ React Frontend │ │ +│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ +│ │ │ Focus View │ │ Queue View │ │ Timeline │ │ │ +│ │ │ (THE ONE) │ │ (all) │ │ (stream) │ │ │ +│ │ └────────────┘ └────────────┘ └────────────┘ │ │ +│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ +│ │ │ Batch Mode │ │ Inbox │ │ Capture │ │ │ +│ │ │ (flow) │ │ (triage) │ │ (overlay) │ │ │ +│ │ └────────────┘ └────────────┘ └────────────┘ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ │ +│ │ Tauri IPC │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Rust Backend │ │ +│ │ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ │ +│ │ │ Bridge │ │ Data Layer │ │ Decision Logger │ │ │ +│ │ │ ────────── │ │ ────────── │ │ ─────────────── │ │ │ +│ │ │ Watch lore │ │ Call lore │ │ Log all actions │ │ │ +│ │ │ Create beads│ │ Call br/bv │ │ Capture context │ │ │ +│ │ │ Sync state │ │ Read mapping│ │ Store reasons │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ lore CLI │ │ br/bv CLI │ │ MC Local State │ +│ (--robot) │ │ (beads) │ │ │ +│ │ │ │ │ mapping.json │ +│ lore.db │ │ .beads/ │ │ decisions.jsonl│ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### Key Architectural Decisions + +| Decision | Choice | Reasoning | +|----------|--------|-----------| +| Desktop framework | Tauri 2.0 | Rust backend, tiny bundle (~15MB vs Electron 150MB), native APIs | +| Frontend | React 19 + Vite | Fast iteration, huge ecosystem, AI-friendly | +| lore integration | CLI (`lore --robot`) | Clean API boundary, no schema coupling | +| beads integration | CLI (`br` commands) | Battle-tested, guaranteed compatibility | +| Local state | JSON files | Simple, portable, easy to inspect/debug | + +--- + +## Tech Stack + +| Layer | Choice | Why | +|-------|--------|-----| +| **Shell** | Tauri 2.0 | Rust backend, tiny bundle, native APIs, system tray, global hotkeys | +| **Frontend** | React 19 + Vite | Fast iteration, huge ecosystem | +| **Styling** | Tailwind + shadcn/ui | Beautiful defaults, easy customization | +| **Animations** | Framer Motion | Smooth, spring-based, ADHD-friendly micro-interactions | +| **State** | Zustand + TanStack Query | Simple global state + async data fetching | +| **Backend DB** | JSON files | mc_state.json, mapping.json, decisions.jsonl | +| **IPC** | Tauri commands + events | Type-safe, bidirectional | +| **Color scheme** | Dark only | User preference, nail down details later | + +--- + +## Data Model + +### Beads as Universal Work Graph + +Everything is a bead. GitLab items, personal tasks, quick captures — all flow into the beads task graph. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ BEADS │ +│ (Universal Task Graph) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ MR Review │────▶│ My Feature │────▶│ Deploy PR │ │ +│ │ !847 │ │ (manual) │ │ (manual) │ │ +│ │ [from gitlab]│ │ │ │ │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ Source types: │ +│ • gitlab (auto-created from lore sync via MC bridge) │ +│ • manual (your tasks, quick captures) │ +│ │ +│ Dependencies span everything. bv's graph algos work on all. │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ MISSION CONTROL │ +│ (The interface to your work graph) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ • Shows THE ONE THING (manually curated by you) │ +│ • Suggests what to work on (bv recommendations as hints) │ +│ • You decide. You drag. You defer. You're in control. │ +│ • Quick capture → creates bead → you triage │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### MC Does NOT Modify Beads Schema + +Beads stays clean. MC layers its own tracking on top via mapping files. + +--- + +## GitLab → Beads Bridge + +### Event → Bead Mapping + +| GitLab Event | Bead Created | Mapping Key Format | +|--------------|--------------|-------------------| +| MR review requested to you | `Review MR !{iid}: {title}` | `mr_review:{host}:{project_id}:{iid}` | +| Issue assigned to you | `Issue #{iid}: {title}` | `issue:{host}:{project_id}:{iid}` | +| MR you authored (opened) | `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 authored 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. + +### Data Source + +All events come from `lore --robot me`, specifically the `since_last_check` section which tracks new activity since the cursor was last advanced. + +**This is key:** We don't track resolution status or complex thread state. We use `since_last_check` — "someone said something to you" = create bead. You close it when handled. + +### Item Lifecycle States + +Each mapped item has a lifecycle state: + +``` + ┌─────────────────────────────────────┐ + │ │ + ▼ │ +┌────────┐ ┌────────┐ ┌─────────────┐ ┌──────┴───┐ +│ (new) │────▶│ active │────▶│ suspect_ │────▶│ closed │ +│ event │ │ │ │ orphan │ │ │ +└────────┘ └────────┘ └─────────────┘ └──────────┘ + │ ▲ + │ (user closes bead) │ + └─────────────────────────────────────┘ +``` + +| State | Meaning | +|-------|---------| +| `active` | Bead exists, GitLab item is open | +| `suspect_orphan` | Missing from lore for 1 reconciliation cycle | +| `closed` | Bead closed (by user OR auto-close), entry removed from map | + +### Two-Strike Close Rule + +**Problem:** 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) | + +This prevents false closes from transient API failures or partial fetches. + +### Mapping File Schema + +``` +~/.local/share/mc/gitlab_bead_map.json +``` + +```json +{ + "schema_version": 1, + "cursor": { + "last_check_timestamp": "2026-02-25T10:30:00Z", + "last_reconciliation": "2026-02-25T06:00:00Z" + }, + "mappings": { + "mr_review:gitlab.com:12345:847": { + "bead_id": "br-x7f", + "created_at": "2026-02-23T14:00:00Z", + "suspect_orphan": false, + "pending": false + }, + "issue:gitlab.com:12345:312": { + "bead_id": "br-c9d", + "created_at": "2026-02-24T09:00:00Z", + "suspect_orphan": true, + "pending": false + }, + "mr_authored:gitlab.com:12345:903": { + "bead_id": null, + "created_at": "2026-02-25T10:29:00Z", + "suspect_orphan": false, + "pending": true + } + } +} +``` + +**Field meanings:** +- `bead_id`: The beads task ID, or `null` if creation was interrupted +- `suspect_orphan`: `true` if item was missing in last reconciliation (first strike) +- `pending`: `true` if bead creation is in-flight (crash recovery will retry) + +### Bridge Flow + +``` +lore.db changes (file watcher on mtime) + │ + ▼ +MC calls: lore --robot me + │ + ▼ +MC iterates: since_last_check events + │ + ├── Event key in mapping? → Skip (already have bead) + │ + └── Event key NOT in mapping? → Create bead via br CLI + │ + ▼ + Store mapping: {key} → {bead_id, created_at, suspect_orphan: false} +``` + +### Reconciliation + +**Problem:** `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 | + +**Full Reconciliation Algorithm:** +1. Fetch all open items from `lore --robot me --issues` and `lore --robot me --mrs` +2. Build set of expected keys from lore response +3. For each key in map: + - If key in expected AND `suspect_orphan=true` → clear flag + - If key NOT in expected AND `suspect_orphan=false` → set `suspect_orphan=true` + - If key NOT in expected AND `suspect_orphan=true` → close bead, remove from map +4. For each key in expected: + - If key NOT in map → create bead, add to map + +### 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) | + +**Recovery:** If cursor appears stale (`since_last_check` empty but open items exist), trigger full reconciliation and reset cursor. + +### Crash-Safe Operation Ordering + +**Problem:** If MC crashes mid-sync, we risk duplicates (bead created but not mapped) or lost events (cursor advanced but bead not created). + +**Solution:** Write-ahead pattern with idempotent operations. + +**For each new event:** +``` +1. Check if key exists in mapping → if yes, skip (idempotent) +2. Write mapping entry FIRST: {key} → {bead_id: null, pending: true} +3. Create bead via `br create` +4. Update mapping: {bead_id: actual_id, pending: false} +5. On success of all events: advance cursor +``` + +**Crash recovery (on startup):** +``` +1. Scan mapping for entries with pending: true +2. For each pending entry: + - If bead_id is null → retry `br create`, update mapping + - If bead_id exists but pending → verify bead exists, clear pending flag +3. Do NOT advance cursor until all pending entries resolved +``` + +**Why this works:** +- Step 1 is idempotent (duplicate events skip) +- Step 2 happens before bead creation (we know we intend to create) +- Step 5 only advances cursor after ALL events processed +- Recovery finds incomplete work and finishes it + +### Bridge Invariants + +These must ALWAYS hold. Violations are bugs. + +| ID | Invariant | +|----|-----------| +| INV-1 | **No duplicate beads.** Each mapping key maps to exactly one bead ID. | +| INV-2 | **No orphan beads.** Every bead ID in the map exists in beads. | +| INV-3 | **No false closes.** Items only auto-closed after missing in TWO reconciliations. | +| INV-4 | **Cursor monotonicity.** Cursor only advances forward, never backward. | + +### Single-Instance Lock + +MC enforces single-instance operation via an OS advisory lock: + +``` +~/.local/share/mc/mc.lock +``` + +**Implementation:** Use `flock(2)` (Unix) or equivalent OS advisory lock — NOT "file exists" semantics. + +**Startup behavior:** +1. Open `mc.lock` (create if missing) +2. Attempt `flock(fd, LOCK_EX | LOCK_NB)` (non-blocking exclusive lock) +3. If `EWOULDBLOCK` → another instance holds lock → show error dialog, exit +4. If lock acquired → proceed, OS auto-releases on process exit/crash + +**Why advisory lock over "file exists":** +- Automatically released on crash (no stale lockfiles) +- No cleanup needed on abnormal exit +- Race-free (OS handles atomicity) + +**Rationale:** Atomic file writes protect against mid-write crashes, but not concurrent writers. Lock file prevents race conditions in bead creation. + +### CLI Contract Testing + +**Risk:** `lore`/`br`/`bv` robot output schema can drift, silently breaking MC. + +**Mitigation:** +- Store fixture files of expected CLI outputs in `tests/fixtures/` +- Contract tests validate MC's parsing against real CLI outputs +- On CLI version bump, regenerate fixtures and verify parsing still works +- Rust types with `#[serde(deny_unknown_fields)]` to catch unexpected fields + +### Bead Creation via br CLI + +```rust +// MC shells out to br for all bead operations +fn create_bead(title: &str, bead_type: &str) -> Result { + let output = Command::new("br") + .args(["create", "--title", title, "--type", bead_type, "--json"]) + .output()?; + let result: BrCreateResult = serde_json::from_slice(&output.stdout)?; + Ok(result.id) +} + +fn close_bead(id: &str, reason: &str) -> Result<()> { + Command::new("br") + .args(["close", id, "--reason", reason]) + .output()?; + Ok(()) +} +``` + +### Auto-Close on GitLab State Change + +When GitLab state changes (MR merged, issue closed), the corresponding bead closes via the two-strike rule: + +| GitLab State Change | Detection | Bead Action | +|---------------------|-----------|-------------| +| MR merged | Item disappears from open list | Two-strike close with reason "MR merged in GitLab" | +| MR closed | Item disappears from open list | Two-strike close with reason "MR closed in GitLab" | +| Issue closed | Item disappears from open list | Two-strike close with reason "Issue closed in GitLab" | + +### Error Handling + +| Error | Handling | +|-------|----------| +| `lore` CLI unavailable | Log error, skip sync, retry next cycle | +| `br create` fails | Log error, do NOT add to map (will retry next sync) | +| `br close` fails | Log error, keep in map as suspect_orphan (will retry) | +| JSON parse error | Log error, skip that event, continue processing others | +| Map file corrupted | Load backup, log warning, trigger full reconciliation | + +**Backup Strategy:** Before each write to `gitlab_bead_map.json`, copy current file to `.bak`, write to `.tmp`, then atomic rename. + +--- + +## Views & UX + +### Window Behavior: B+D Hybrid + +1. **Menu bar icon** with badge count (always present) +2. **Popover** for quick glance (click menu bar icon) +3. **Full window** for deep work (hotkey or button in popover) +4. **Optional floating widget** (toggle in settings) for constant visibility + +``` +Menu Bar: [other items] 🔴3 MC │ 🔋 ... + │ + click ───────┘ + ▼ + ┌─────────────────────────────┐ + │ THE ONE THING │ + │ Review MR !847 │ + │ 2d waiting · @sarah │ + │ │ + │ [Start] [Defer] [Skip] │ + ├─────────────────────────────┤ + │ Queue: 4 Inbox: 3 │ + │ ⌘⇧F Full window │ + └─────────────────────────────┘ +``` + +### View 1: Focus View (Home) + +The default. Shows THE ONE THING. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ │ +│ ┌───────────────────┐ │ +│ │ 🔴 MR REVIEW │ │ +│ └───────────────────┘ │ +│ │ +│ Fix authentication token refresh logic │ +│ ───────────────────────────────────── │ +│ │ +│ !847 in platform/core • 47 lines changed │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ @sarah requested 2 days ago │ │ +│ │ "Can you take a look? I need this for the │ │ +│ │ release tomorrow" │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────┐ │ +│ │ Start │ │ 1 hour │ │ Tomorrow │ │ Skip │ │ +│ │ ↵ │ │ ⌘1 │ │ ⌘2 │ │ ⌘S │ │ +│ └──────────┘ └──────────┘ └──────────┘ └───────┘ │ +│ │ +├─────────────────────────────────────────────────────────────┤ +│ Queue: 3 more reviews • 2 assigned issues • 5 mentions │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Behavior:** +- "Start" opens GitLab in browser +- Defer options: 1 hour, tomorrow, custom +- Skip removes from today's list (logged with reason) +- Keyboard-driven: Enter to start, numbers for defer, S to skip + +### View 2: Queue View + +All pending work, organized by type. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Queue ⌘K filter │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ REVIEWS (4) [Batch All · 25min] │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ 🔴 !847 Fix auth token refresh 2d @sarah │ │ +│ │ 🟡 !902 Add rate limiting middleware 1d @mike │ │ +│ │ 🟢 !915 Update README badges 4h @alex │ │ +│ │ 🟢 !918 Typo fix in error messages 2h @bot │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ASSIGNED ISSUES (2) │ +│ BEADS (3) │ +│ MANUAL TASKS (1) │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Behavior:** +- Items colored by staleness (fresh=green, aging=amber, stale=red) +- Click to make it THE ONE THING +- Drag to reorder (manual priority) +- "Batch All" enters batch mode + +### View 3: Timeline View + +Chronological activity stream. + +### View 4: Batch Mode + +Full-screen focus for clearing similar items rapidly. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 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 │ +└─────────────────────────────────────────────────────────────┘ +``` + +### View 5: Inbox View + +New stuff requiring triage. + +### View 6: Quick Capture (Overlay) + +Global hotkey (⌘⇧C) summons this from anywhere: + +``` + ┌────────────────────────────────────────┐ + │ │ + │ ┌────────────────────────────────┐ │ + │ │ Quick thought... │ │ + │ │ │ │ + │ │ Need to check if webhook │ │ + │ │ retry uses exponential backoff │ │ + │ └────────────────────────────────┘ │ + │ │ + │ ⏎ Save & close ESC Cancel │ + │ │ + └────────────────────────────────────────┘ +``` + +Creates a bead immediately. You triage later. + +--- + +## Priority & Decision System + +### Philosophy: Manual-First, Learn from Data + +We do NOT know the right prioritization algorithm yet. Instead: + +1. **You manually set THE ONE THING** +2. **MC logs every decision with context and reasoning** +3. **Post-process logs to extract patterns** +4. **Eventually codify patterns into suggestions** + +### Decision Log + +``` +~/.local/share/mc/decision_log.jsonl +``` + +Every action gets logged: + +```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", + "available_items": ["br-x7f", "br-a3b", "br-c9d"], + "item_ages_days": {"br-x7f": 2, "br-a3b": 5, "br-c9d": 1}, + "items_completed_today": 3, + "focus_session_duration_min": 45 + } +} +``` + +### 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 | + +### "Why" Capture UX + +Every significant action prompts for optional reason: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 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] │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Quick tags** = one-click common reasons +**Skip reason** = don't force it, but make it easy + +### Context Snapshot + +Each log entry captures decision context: + +```rust +struct DecisionContext { + queue_snapshot: Vec, // All items at decision time + time_of_day: TimeOfDay, // morning/afternoon/evening + day_of_week: Weekday, + focus_session_duration: Option, + items_completed_today: u32, + last_sync_age: Duration, +} +``` + +### Log Retention & Privacy (Post-v1) + +The decision log can grow indefinitely. For v1, we accept unbounded growth since: +- Single user, local storage only +- Log entries are small (~500 bytes each) +- 100 decisions/day × 365 days × 500 bytes = ~18 MB/year + +**Post-v1 retention policy (when needed):** + +| Policy | Implementation | +|--------|----------------| +| Size cap | Rotate when log exceeds 50 MB (keep last N entries) | +| Time-based | Optional: prune entries older than 1 year | +| Export before prune | Always export to dated backup before any deletion | + +**Privacy considerations (post-v1):** +- Reasons may contain sensitive context (names, project details) +- Add optional redaction: strip free-text reasons, keep only tags +- Add export-with-redaction for sharing anonymized patterns + +### Future: Pattern Extraction + +Once we have enough data: +1. Cluster reasons → discover priority categories +2. Correlate context → time-of-day patterns, staleness thresholds +3. Train simple model → suggest priority, you confirm/override +4. Eventually: auto-prioritize with confidence scores + +--- + +## Implementation Phases + +**TDD Rule:** Every task below follows RED → GREEN. Write failing test first, then implement. + +### Phase 0: Test Infrastructure (0.5 day) + +- [ ] Configure Vitest for frontend unit/component tests +- [ ] Configure Playwright for E2E tests +- [ ] Set up Rust test harness with trait-based mocking +- [ ] Create fixture directory structure +- [ ] Capture initial CLI fixtures from real `lore`/`br` outputs +- [ ] Add test commands to `package.json` and `Cargo.toml` + +### Phase 1: Foundation (2-3 days) + +- [ ] Scaffold Tauri + Vite + React project +- [ ] Basic window with system tray icon +- [ ] Global hotkey to toggle window (⌘⇧M) +- [ ] **RED:** Write `lore.rs` parsing tests against fixtures +- [ ] **GREEN:** Implement lore CLI wrapper, parse `lore --robot me` +- [ ] **RED:** Write file watcher tests (mock filesystem events) +- [ ] **GREEN:** File watcher on lore.db to trigger refresh +- [ ] Display raw data in UI (no test needed — visual verification) + +### Phase 2: Bridge + Data Layer (2-3 days) + +- [ ] **RED:** Write state machine transition tests (all 6 scenarios from Testing Architecture) +- [ ] **RED:** Write invariant assertion helper +- [ ] **GREEN:** Implement GitLab → Beads bridge state machine +- [ ] **RED:** Write mapping file read/write tests (atomic writes, schema validation) +- [ ] **GREEN:** Create mapping file structure with atomic writes +- [ ] **RED:** Write `br create`/`br close` wrapper tests against fixtures +- [ ] **GREEN:** Bead creation via `br` CLI +- [ ] **RED:** Write two-strike auto-close tests +- [ ] **GREEN:** Auto-close beads on GitLab state change +- [ ] **RED:** Write reconciliation tests (startup, periodic, cursor recovery) +- [ ] **GREEN:** Full reconciliation pass (startup + periodic) +- [ ] **RED:** Write crash recovery tests (all 3 scenarios) +- [ ] **GREEN:** Implement write-ahead pattern with pending flag +- [ ] **RED:** Write single-instance lock tests +- [ ] **GREEN:** Implement flock-based single-instance lock +- [ ] **RED:** Write decision log append/read tests +- [ ] **GREEN:** Decision log infrastructure +- [ ] Verify all invariants pass after each test + +### Phase 3: Focus View (1-2 days) + +- [ ] **RED:** Write FocusCard component tests (render, Start/Defer/Skip, keyboard) +- [ ] **GREEN:** THE ONE THING card component +- [ ] **RED:** Write focus selection state tests +- [ ] **GREEN:** Manual focus selection +- [ ] **RED:** Write action tests (open URL, defer timing, skip logging) +- [ ] **GREEN:** Basic actions (open in browser, defer, skip) +- [ ] **RED:** Write ReasonPrompt tests (text capture, tags, submit/cancel) +- [ ] **GREEN:** Decision logging with reason capture +- [ ] **GREEN:** Quick tags for common reasons + +### Phase 4: Queue + Inbox (2-3 days) + +- [ ] **RED:** Write QueueList tests (render sections, staleness colors) +- [ ] **GREEN:** Queue list with sections +- [ ] **RED:** Write drag-reorder tests (order persisted, decision logged) +- [ ] **GREEN:** Drag to reorder (manual priority) +- [ ] **RED:** Write click-to-focus tests +- [ ] **GREEN:** Click to set as focus +- [ ] **RED:** Write Inbox triage tests +- [ ] **GREEN:** Inbox with triage actions +- [ ] **RED:** Write filter/search tests +- [ ] **GREEN:** Filter/search (⌘K) + +### Phase 5: Batch Mode (1-2 days) + +- [ ] **RED:** Write BatchMode tests (progress, cycling, completion) +- [ ] **GREEN:** Full-screen batch interface +- [ ] **GREEN:** Progress tracking +- [ ] **GREEN:** Rapid completion flow +- [ ] **GREEN:** Completion celebration + +### Phase 6: Quick Capture (1 day) + +- [ ] **RED:** Write QuickCapture tests (overlay, text input, bead creation) +- [ ] **GREEN:** Global hotkey overlay (⌘⇧C) +- [ ] **GREEN:** Instant bead creation +- [ ] **GREEN:** Appears over other apps +- [ ] **E2E:** Write quick-capture E2E test + +### Phase 7: Polish + E2E (ongoing) + +- [ ] Animations (Framer Motion) — visual verification +- [ ] **RED:** Write staleness color tests +- [ ] **GREEN:** Staleness visualization (color decay) +- [ ] Badge counts in menu bar — visual verification +- [ ] Settings UI — visual verification +- [ ] Floating widget (optional) +- [ ] **E2E:** Write focus-flow E2E test +- [ ] **E2E:** Write batch-mode E2E test +- [ ] **E2E:** Write sync-status E2E test +- [ ] Verify 90% Rust bridge coverage, 85% hooks coverage, 70% component coverage + +--- + +## Post-v1: Deferred Features + +### Timeline View (deferred) + +- [ ] Chronological activity view +- [ ] Smart grouping (today, yesterday, older) +- [ ] Expand/collapse + +**Rationale:** Timeline is "nice to have" but not core to the "ONE THING" thesis. Defer until trust metrics on bridge reliability are solid. + +### Future Intelligence (deferred) + +Once decision log has sufficient data: +- [ ] Cluster reasons → discover priority categories +- [ ] Correlate context → time-of-day patterns, staleness thresholds +- [ ] Train simple model → suggest priority, user confirms/overrides + +--- + +## Acceptance Criteria + +### AC-001: Bridge Creates Beads from GitLab Events + +**Given** lore has synced new GitLab activity +**When** MC detects lore.db change and processes `since_last_check` +**Then** beads are created for: MR reviews requested, issues assigned, mentions, comments on authored MRs +**And** mapping file is updated with `{event_key} → {bead_id}` +**And** duplicate events do not create duplicate beads + +### AC-002: Bridge Auto-Closes Beads (Two-Strike Rule) + +**Given** a bead exists for a GitLab MR or issue +**When** that item is missing from lore for TWO consecutive reconciliations +**Then** the corresponding bead is closed via `br close` +**And** reason includes GitLab state change (e.g., "MR merged in GitLab") + +**Note:** The two-strike rule prevents false closes from transient API failures. First miss sets `suspect_orphan=true`, second miss triggers close. + +### AC-002b: Reconciliation Heals Missed Events + +**Given** MC was offline or `since_last_check` cursor is stale +**When** MC starts up or 6-hour reconciliation timer fires +**Then** full reconciliation runs against all open items from lore +**And** missing beads are created for items not in mapping +**And** items missing from lore are marked `suspect_orphan` (first strike) +**And** items already marked `suspect_orphan` that are still missing are closed (second strike) + +### AC-003: Focus View Shows THE ONE THING + +**Given** user has set a focus item +**When** viewing Focus View +**Then** that single item is prominently displayed +**And** actions (Start, Defer, Skip) are available +**And** keyboard shortcuts work + +### AC-004: Manual Priority via Drag Reorder + +**Given** user is in Queue View +**When** user drags an item to new position +**Then** order is persisted +**And** decision is logged with context +**And** user is prompted for optional reason + +### AC-005: Decision Logging Captures Context + +**Given** user performs any significant action (set_focus, reorder, defer, skip, complete) +**When** action is executed +**Then** decision_log.jsonl is appended with: timestamp, action, bead_id, reason (if provided), tags, full context snapshot + +### AC-006: Quick Capture Creates Bead + +**Given** user presses global hotkey (⌘⇧C) from any app +**When** user types text and presses Enter +**Then** bead is created via `br create` +**And** overlay dismisses +**And** user returns to previous context + +### AC-007: Menu Bar Badge Shows Counts + +**Given** MC is running +**When** there are pending items +**Then** menu bar icon shows badge with count +**And** clicking icon opens popover +**And** popover shows THE ONE THING and queue summary + +### AC-008: Batch Mode Enables Flow State + +**Given** user has multiple items of same type (e.g., reviews) +**When** user enters Batch Mode +**Then** items are presented one at a time +**And** progress bar shows completion +**And** Done/Skip advances to next +**And** ESC exits batch + +### AC-009: Sync Status Visible + +**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 + +### AC-010: Staleness Visualization + +**Given** items have different ages +**When** viewing Queue or Focus +**Then** fresh items appear bright/green +**And** 1-2 day items appear normal +**And** 3-6 day items appear amber +**And** 7+ day items appear red/pulsing + +--- + +## File Structure + +``` +mission-control/ +├── src-tauri/ # Rust backend +│ ├── src/ +│ │ ├── main.rs +│ │ ├── commands/ # Tauri command handlers +│ │ │ ├── mod.rs +│ │ │ ├── work_items.rs +│ │ │ ├── actions.rs +│ │ │ ├── capture.rs +│ │ │ └── decisions.rs +│ │ ├── bridge/ # GitLab → Beads bridge +│ │ │ ├── mod.rs +│ │ │ ├── sync.rs +│ │ │ └── mapping.rs +│ │ ├── data/ # Data layer +│ │ │ ├── mod.rs +│ │ │ ├── lore.rs # Shell to lore CLI +│ │ │ ├── beads.rs # Shell to br CLI +│ │ │ └── state.rs # MC-local state +│ │ ├── logging/ # Decision logging +│ │ │ ├── mod.rs +│ │ │ └── decision_log.rs +│ │ └── lib.rs +│ ├── tests/ # Rust integration tests +│ │ ├── bridge_test.rs +│ │ ├── mapping_test.rs +│ │ ├── crash_recovery_test.rs +│ │ └── fixtures/ # CLI output fixtures +│ │ ├── lore/ +│ │ │ ├── me_empty.json +│ │ │ ├── me_with_reviews.json +│ │ │ └── me_stale_cursor.json +│ │ └── br/ +│ │ ├── create_success.json +│ │ └── create_error.json +│ ├── Cargo.toml +│ └── tauri.conf.json +│ +├── src/ # React frontend +│ ├── components/ +│ │ ├── ui/ # shadcn components +│ │ ├── FocusCard.tsx +│ │ ├── QueueList.tsx +│ │ ├── Timeline.tsx +│ │ ├── BatchMode.tsx +│ │ ├── Inbox.tsx +│ │ ├── QuickCapture.tsx +│ │ └── ReasonPrompt.tsx +│ ├── views/ +│ │ ├── FocusView.tsx +│ │ ├── QueueView.tsx +│ │ ├── TimelineView.tsx +│ │ └── InboxView.tsx +│ ├── hooks/ +│ │ ├── useWorkItems.ts +│ │ ├── useTauriEvents.ts +│ │ ├── useKeyboard.ts +│ │ └── useDecisionLog.ts +│ ├── store/ +│ │ └── index.ts # Zustand store +│ ├── lib/ +│ │ ├── tauri.ts # Tauri invoke wrappers +│ │ └── utils.ts +│ ├── App.tsx +│ └── main.tsx +│ +├── tests/ # Frontend tests +│ ├── unit/ # Vitest unit tests +│ │ ├── store.test.ts +│ │ └── utils.test.ts +│ ├── components/ # Component tests +│ │ ├── FocusCard.test.tsx +│ │ ├── QueueList.test.tsx +│ │ └── ReasonPrompt.test.tsx +│ └── e2e/ # Playwright E2E +│ ├── focus-flow.spec.ts +│ ├── batch-mode.spec.ts +│ └── quick-capture.spec.ts +│ +├── scripts/ +│ └── regenerate-fixtures.sh # Capture real CLI outputs +│ +├── package.json +├── tailwind.config.js +├── vite.config.ts +├── vitest.config.ts # Vitest configuration +├── playwright.config.ts # Playwright configuration +├── PLAN.md # This document +└── README.md +``` + +--- + +## Local State Files + +``` +~/.local/share/mc/ +├── gitlab_bead_map.json # {event_key} → {bead_id} +├── decision_log.jsonl # Append-only decision log +├── state.json # Current focus, queue order, UI state +└── settings.json # User preferences +``` + +### State File Reliability + +**Atomic Writes:** All JSON state files use write-to-temp + rename pattern to prevent corruption on crash. + +```rust +fn write_state_atomic(path: &Path, data: &impl Serialize) -> Result<()> { + let tmp = path.with_extension("json.tmp"); + let file = File::create(&tmp)?; + serde_json::to_writer_pretty(file, data)?; + fs::rename(&tmp, path)?; // Atomic on POSIX + Ok(()) +} +``` + +**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). + +**Schema Versioning:** Each JSON file includes a `"schema_version": 1` field. On load, migrate if version < current. + +--- + +## Testing Architecture + +### TDD Philosophy + +Every feature is implemented RED → GREEN: +1. **RED:** Write failing test that specifies behavior +2. **GREEN:** Write minimal code to pass +3. **REFACTOR:** Clean up while tests stay green + +Tests are written BEFORE implementation. If you can't write the test first, the spec isn't clear enough. + +### Test Directory Structure + +``` +mission-control/ +├── src-tauri/ +│ ├── src/ +│ │ └── ... +│ └── tests/ # Rust integration tests +│ ├── bridge_test.rs # Bridge state machine tests +│ ├── mapping_test.rs # Mapping file operations +│ ├── crash_recovery_test.rs +│ └── fixtures/ # CLI output fixtures +│ ├── lore/ +│ │ ├── me_empty.json +│ │ ├── me_with_reviews.json +│ │ ├── me_with_issues.json +│ │ └── me_mixed.json +│ └── br/ +│ ├── create_success.json +│ ├── create_error.json +│ ├── close_success.json +│ └── list.json +│ +├── src/ +│ └── ... +└── tests/ # Frontend tests + ├── unit/ # Vitest unit tests + │ ├── store.test.ts + │ ├── utils.test.ts + │ └── hooks/ + │ ├── useWorkItems.test.ts + │ └── useDecisionLog.test.ts + ├── components/ # React component tests + │ ├── FocusCard.test.tsx + │ ├── QueueList.test.tsx + │ └── ReasonPrompt.test.tsx + └── e2e/ # Playwright E2E tests + ├── focus-flow.spec.ts + ├── batch-mode.spec.ts + └── quick-capture.spec.ts +``` + +### Mocking Strategy + +**Rust backend — trait-based mocking:** + +```rust +// Define traits for external dependencies +trait LoreCli { + fn me(&self) -> Result; + fn me_issues(&self) -> Result>; + fn me_mrs(&self) -> Result>; +} + +trait BeadsCli { + fn create(&self, title: &str, bead_type: &str) -> Result; + fn close(&self, id: &str, reason: &str) -> Result<()>; + fn exists(&self, id: &str) -> Result; +} + +// Production implementation shells out to CLI +struct RealLoreCli; +impl LoreCli for RealLoreCli { + fn me(&self) -> Result { + let output = Command::new("lore").args(["--robot", "me"]).output()?; + // ... + } +} + +// Test implementation returns fixtures +struct MockLoreCli { + responses: HashMap<&'static str, String>, +} +impl LoreCli for MockLoreCli { + fn me(&self) -> Result { + let json = self.responses.get("me").unwrap(); + Ok(serde_json::from_str(json)?) + } +} +``` + +**Frontend — MSW for Tauri IPC mocking:** + +```typescript +// Mock Tauri invoke calls +import { mockIPC } from '@tauri-apps/api/mocks'; + +beforeEach(() => { + mockIPC((cmd, args) => { + if (cmd === 'get_work_items') { + return fixtures.workItems; + } + if (cmd === 'set_focus') { + return { ok: true }; + } + }); +}); +``` + +### Fixture Strategy + +**CLI Output Fixtures** — Real outputs captured from actual CLIs, stored as JSON files: + +| Fixture | Contents | Used By | +|---------|----------|---------| +| `lore/me_empty.json` | Empty `since_last_check`, no open items | Cursor recovery test | +| `lore/me_with_reviews.json` | 3 MR reviews in `since_last_check` | New event processing test | +| `lore/me_stale_cursor.json` | Empty `since_last_check` but has open items | Reconciliation trigger test | +| `br/create_success.json` | Successful bead creation response | Happy path tests | +| `br/create_error.json` | Error response (e.g., validation failure) | Error handling tests | + +**Fixture Regeneration:** +```bash +# Capture real CLI outputs for fixtures +lore --robot me > tests/fixtures/lore/me_current.json +br create --title "Test" --type task --json > tests/fixtures/br/create_success.json +``` + +**Contract Validation:** On CI, compare fixture schema against current CLI version: +```rust +#[test] +fn lore_me_fixture_matches_schema() { + let fixture = include_str!("fixtures/lore/me_with_reviews.json"); + let parsed: LoreMeResponse = serde_json::from_str(fixture) + .expect("Fixture should match current schema"); +} +``` + +### State Machine Test Scenarios + +Each state transition gets an explicit test. Write these RED first. + +**Lifecycle Transitions:** + +| Test Name | Initial State | Action | Expected State | Invariants Checked | +|-----------|---------------|--------|----------------|-------------------| +| `new_event_creates_active` | (no entry) | Process new MR review event | `active` | INV-1 (no duplicates) | +| `duplicate_event_skips` | `active` | Process same event again | `active` (unchanged) | INV-1 | +| `missing_once_sets_suspect` | `active` | Reconciliation, item missing | `suspect_orphan=true` | INV-3 | +| `missing_twice_closes` | `suspect_orphan=true` | Reconciliation, item still missing | Entry removed, bead closed | INV-3 | +| `reappears_clears_suspect` | `suspect_orphan=true` | Reconciliation, item reappears | `suspect_orphan=false` | — | +| `user_close_removes` | `active` | User closes bead manually | Entry removed | — | + +**Crash Recovery Transitions:** + +| Test Name | Initial State | Simulated Crash Point | Recovery Action | Expected | +|-----------|---------------|----------------------|-----------------|----------| +| `crash_before_br_create` | `pending=true, bead_id=null` | After map write, before br | Startup recovery | Retries `br create`, updates map | +| `crash_after_br_create` | `pending=true, bead_id="br-xxx"` | After br, before pending clear | Startup recovery | Verifies bead exists, clears pending | +| `crash_before_cursor_advance` | Multiple `pending=false` | After all beads, before cursor | Startup recovery | Re-processes events (idempotent), advances cursor | + +### Invariant Tests + +Automated verification of INV-1 through INV-4 after every operation: + +```rust +fn assert_invariants(mapping: &Mapping, beads: &impl BeadsCli) -> Result<()> { + // INV-1: No duplicate beads + let bead_ids: Vec<_> = mapping.values().map(|e| &e.bead_id).collect(); + let unique: HashSet<_> = bead_ids.iter().filter_map(|id| id.as_ref()).collect(); + assert_eq!(bead_ids.len(), unique.len(), "INV-1 violated: duplicate bead IDs"); + + // INV-2: No orphan beads (every bead_id exists) + for entry in mapping.values() { + if let Some(id) = &entry.bead_id { + assert!(beads.exists(id)?, "INV-2 violated: bead {} not found", id); + } + } + + // INV-3: No false closes (tested via state machine tests) + // INV-4: Cursor monotonicity (tested via cursor tests) + + Ok(()) +} + +// Run after every test +#[test] +fn reconciliation_maintains_invariants() { + let mut bridge = setup_bridge_with_mock(); + bridge.reconcile(); + assert_invariants(&bridge.mapping, &bridge.beads).unwrap(); +} +``` + +### Error Path Tests + +Every error in the error handling table gets a test: + +| Test Name | Error Simulated | Expected Behavior | +|-----------|-----------------|-------------------| +| `lore_cli_unavailable` | Mock returns `Err(CommandNotFound)` | Logs error, skips sync, no crash | +| `br_create_fails` | Mock returns error JSON | Logs error, entry NOT added to map | +| `br_close_fails` | Mock returns error | Logs error, keeps `suspect_orphan=true` | +| `json_parse_error` | Malformed fixture | Logs error, skips event, continues | +| `map_file_corrupted` | Invalid JSON in map file | Loads backup, triggers reconciliation | +| `lock_held` | Lock file already locked | Shows error dialog, exits cleanly | + +### Frontend Test Strategy + +**Unit Tests (Vitest):** + +| Module | What to Test | +|--------|--------------| +| `store/index.ts` | State mutations, selectors, computed values | +| `lib/utils.ts` | Staleness calculation, time formatting | +| `hooks/useWorkItems.ts` | Data fetching, caching, error states | + +**Component Tests (React Testing Library):** + +| Component | Test Cases | +|-----------|------------| +| `FocusCard` | Renders item, handles Start/Defer/Skip, keyboard shortcuts | +| `QueueList` | Renders list, drag reorder, click to focus, staleness colors | +| `ReasonPrompt` | Shows prompt, captures text, handles quick tags, submit/cancel | +| `BatchMode` | Progress bar, item cycling, completion state | + +**Example Component Test (RED first):** + +```typescript +// tests/components/FocusCard.test.tsx +describe('FocusCard', () => { + it('calls onStart when Start button clicked', async () => { + const onStart = vi.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: /start/i })); + + expect(onStart).toHaveBeenCalledWith(mockItem.id); + }); + + it('calls onStart when Enter pressed', async () => { + const onStart = vi.fn(); + render(); + + await userEvent.keyboard('{Enter}'); + + expect(onStart).toHaveBeenCalledWith(mockItem.id); + }); + + it('shows amber color for 3-day-old items', () => { + const oldItem = { ...mockItem, createdAt: daysAgo(4) }; + render(); + + expect(screen.getByTestId('staleness-indicator')).toHaveClass('bg-amber-500'); + }); +}); +``` + +### E2E Test Strategy (Playwright + Tauri) + +Full app tests using `@tauri-apps/cli` test mode: + +| Test | Flow | +|------|------| +| `focus-flow.spec.ts` | Launch app → See focus card → Click Start → Verify browser opens | +| `batch-mode.spec.ts` | Queue with 4 reviews → Enter batch → Complete all → See celebration | +| `quick-capture.spec.ts` | Global hotkey → Type text → Enter → Verify bead created | +| `sync-status.spec.ts` | Mock lore failure → Verify error shown → Mock success → Verify recovers | + +**E2E Test Setup:** + +```typescript +// tests/e2e/focus-flow.spec.ts +import { test, expect } from '@playwright/test'; +import { spawn } from 'child_process'; + +test.beforeAll(async () => { + // Start Tauri app in test mode with mocked CLIs + process.env.MC_TEST_MODE = 'true'; + process.env.MC_MOCK_LORE = 'fixtures/lore/me_with_reviews.json'; +}); + +test('clicking Start opens GitLab URL', async ({ page }) => { + // Tauri exposes window at localhost:1420 in dev mode + await page.goto('http://localhost:1420'); + + // Wait for focus card to load + await expect(page.getByTestId('focus-card')).toBeVisible(); + + // Click start + const [newPage] = await Promise.all([ + page.waitForEvent('popup'), + page.click('button:has-text("Start")'), + ]); + + // Verify GitLab URL opened + expect(newPage.url()).toContain('gitlab.com'); +}); +``` + +### Test Commands + +```bash +# Rust tests +cargo test # All unit + integration tests +cargo test bridge # Bridge tests only +cargo test --test crash_recovery # Crash recovery integration tests + +# Frontend tests +npm run test # Vitest unit + component tests +npm run test:watch # Watch mode +npm run test:coverage # Coverage report + +# E2E tests +npm run test:e2e # Playwright tests +npm run test:e2e -- --headed # With browser visible + +# All tests (CI) +npm run test:all # Runs everything +``` + +### CI Test Matrix + +```yaml +# .github/workflows/test.yml +jobs: + rust-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: cargo test --all-features + + frontend-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: npm ci + - run: npm run test -- --coverage + - run: npm run lint + + e2e-tests: + runs-on: macos-latest # Tauri E2E needs native + steps: + - uses: actions/checkout@v4 + - run: npm ci + - run: npm run tauri build -- --debug + - run: npm run test:e2e + + contract-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # Install actual lore/br CLIs + - run: cargo install lore br + # Regenerate fixtures and compare + - run: ./scripts/regenerate-fixtures.sh + - run: git diff --exit-code tests/fixtures/ +``` + +### Coverage Requirements + +| Layer | Minimum Coverage | Focus Areas | +|-------|------------------|-------------| +| Rust bridge | 90% | State transitions, crash recovery, error paths | +| Rust data layer | 80% | CLI parsing, file I/O | +| Frontend hooks | 85% | Data fetching, state management | +| Frontend components | 70% | User interactions, edge cases | +| E2E | N/A (scenario coverage) | Critical user flows | + +--- + +## Open Questions + +1. **Floating widget details** — Size, position, what info to show? +2. **Notification behavior** — When to notify vs. just badge? +3. **Calendar integration** — Worth adding for energy/time awareness? +4. **Mobile surface** — Any desire for iOS widget in future? +5. **bv recommendations** — Show as separate "suggestions" section or inline hints? + +--- + +## Changelog + +| Date | Change | +|------|--------| +| 2026-02-25 | Initial plan document created | +| 2026-02-25 | Added reliability improvements: reconciliation pass, cursor semantics, CLI contract testing, atomic writes, schema versioning. Deferred Timeline to post-v1. | +| 2026-02-25 | Integrated Bridge State Machine spec: lifecycle states, two-strike close rule, stable mapping keys (project_id), cursor semantics, invariants, single-instance lock, error handling. | +| 2026-02-25 | Added crash-safe operation ordering (write-ahead pattern with pending flag). Fixed AC-002/AC-002b to match two-strike rule. Added decision log retention policy (post-v1). Clarified flock-based lock semantics. | +| 2026-02-25 | Added comprehensive Testing Architecture: TDD philosophy, test directory structure, mocking strategy (trait-based Rust, MSW frontend), fixture strategy, state machine test scenarios, invariant tests, error path tests, frontend test strategy, E2E test strategy, CI matrix, coverage requirements. Updated Implementation Phases with explicit RED→GREEN TDD tasks. |