Implement contract testing to catch CLI schema drift early: Contract Test Suite (fixture_contract_tests.rs): - parse_lore_me_empty_fixture: Validates LoreMeResponse on empty data - parse_lore_me_with_activity_fixture: Real lore output with activity - parse_br_list_empty_fixture: Empty beads list - parse_br_list_with_beads_fixture: Real br output with beads Fixtures (captured from real CLI output): - fixtures/lore/me_empty.json: Synthetic empty response - fixtures/lore/me_with_activity.json: Real 'lore --robot me' output - fixtures/br/list_empty.json: Empty array [] - fixtures/br/list_with_beads.json: Real 'br list --json' output - fixtures/br/bv_triage.json: Real 'bv --robot-triage' output Fixture Regeneration: - scripts/regenerate-fixtures.sh: Captures fresh CLI output - Run periodically to update fixtures - CI can diff against committed fixtures to detect drift Why Contract Tests Matter: MC depends on external CLIs (lore, br, bv) whose output format may change. Contract tests fail fast when our Rust types diverge from actual CLI output, preventing runtime deserialization errors. The tests use include_str!() for compile-time fixture embedding, ensuring tests fail to compile if fixtures are missing.
993 lines
264 KiB
JSON
993 lines
264 KiB
JSON
[
|
|
{
|
|
"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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 1
|
|
},
|
|
{
|
|
"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<Value>\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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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<BvTriageResponse>\n- fn robot_next(&self) -> Result<BvNextResponse>\n- fn robot_insights(&self) -> Result<BvInsightsResponse>\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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 1
|
|
},
|
|
{
|
|
"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,
|
|
"dependency_count": 2,
|
|
"dependent_count": 2
|
|
},
|
|
{
|
|
"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 <ErrorBoundary>\n <ThrowingComponent />\n </ErrorBoundary>\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 <ErrorBoundary>\n <ThrowingComponent />\n </ErrorBoundary>\n );\n \n expect(consoleSpy).toHaveBeenCalled();\n });\n \n it('shows recovery actions', () => {\n const onRecover = vi.fn();\n \n render(\n <ErrorBoundary onRecover={onRecover}>\n <ThrowingComponent />\n </ErrorBoundary>\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(<ErrorDisplay error={{ type: 'cli_not_found', cli: 'lore' }} />);\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(<ErrorDisplay error={{ type: 'sync_failed' }} onRetry={onRetry} />);\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 <div className=\"flex flex-col items-center justify-center h-screen p-8\">\n <AlertTriangle className=\"w-16 h-16 text-destructive mb-4\" />\n <h1 className=\"text-2xl font-bold mb-2\">Something went wrong</h1>\n <p className=\"text-muted-foreground mb-4 text-center max-w-md\">\n Mission Control encountered an unexpected error.\n </p>\n \n {process.env.NODE_ENV === 'development' && (\n <pre className=\"bg-muted p-4 rounded mb-4 text-xs max-w-lg overflow-auto\">\n {this.state.error?.stack}\n </pre>\n )}\n \n <div className=\"flex gap-4\">\n <Button onClick={this.handleRecover}>Try Again</Button>\n <Button variant=\"outline\" onClick={this.handleReload}>Reload App</Button>\n </div>\n </div>\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<string, { title: string; desc: string; action?: string }> = {\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 <Alert variant=\"destructive\">\n <AlertTitle>{msg.title}</AlertTitle>\n <AlertDescription>{msg.desc}</AlertDescription>\n \n {msg.action === 'retry' && onRetry && (\n <Button size=\"sm\" className=\"mt-2\" onClick={onRetry}>Retry</Button>\n )}\n </Alert>\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,
|
|
"dependency_count": 0,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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<Recommendation> = 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<Recommendation> = 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<BvTriageResponse>;\n}\n\npub struct RealBvCli;\n\nimpl BvCli for RealBvCli {\n fn triage(&self) -> Result<BvTriageResponse> {\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<dyn BvCli>>,\n) -> Vec<Recommendation> {\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<Recommendation[]>('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 <div className=\"...\">\n {/* ... item content ... */}\n \n {recommendation && (\n <Tooltip>\n <TooltipTrigger>\n <Sparkles className=\"w-3 h-3 text-amber-500\" />\n </TooltipTrigger>\n <TooltipContent>\n Suggested: {recommendation.reason}\n </TooltipContent>\n </Tooltip>\n )}\n </div>\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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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<Mutex<Mapping>>,\n orchestrator: Arc<Mutex<SyncOrchestrator>>,\n watcher: LoreDbWatcher,\n warnings: Vec<Warning>,\n}\n\nimpl App {\n pub async fn init(config: &Config) -> Result<Self, InitError> {\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,
|
|
"dependency_count": 4,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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<String>,\n beads: State<'_, Arc<dyn BeadsCli>>,\n mapping: State<'_, Arc<Mutex<Mapping>>>,\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,
|
|
"dependency_count": 3,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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,
|
|
"dependency_count": 2,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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<L: LoreCli, B: BeadsCli> {\n lore: L,\n beads: B,\n bridge: BridgeState<L, B>,\n event_tx: Option<mpsc::Sender<SyncEvent>>,\n}\n\nimpl<L: LoreCli, B: BeadsCli> SyncOrchestrator<L, B> {\n pub async fn sync(&mut self) -> Result<SyncResult> {\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::<Arc<Mutex<SyncOrchestrator>>>();\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,
|
|
"dependency_count": 4,
|
|
"dependent_count": 1
|
|
},
|
|
{
|
|
"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<Utc>,\n pub url: Option<String>,\n pub triaged: bool,\n pub snoozed_until: Option<DateTime<Utc>>,\n pub skipped_today: Option<bool>,\n pub metadata: Option<WorkItemMetadata>,\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,
|
|
"dependency_count": 2,
|
|
"dependent_count": 1
|
|
},
|
|
{
|
|
"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<WorkItem>` | Fetch all items (lore + manual) |\n| `get_focus` | — | `Option<String>` | 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<WorkItem> = 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::<Option<String>>(\"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::<MockBeadsCli>();\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::<Config>().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<dyn LoreCli>>,\n state: State<'_, AppState>,\n) -> Result<Vec<WorkItem>, 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<String> {\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<dyn BeadsCli>>,\n state: State<'_, AppState>,\n decision_log: State<'_, DecisionLogger>,\n) -> Result<String, String> {\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,
|
|
"dependency_count": 4,
|
|
"dependent_count": 1
|
|
},
|
|
{
|
|
"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(<InboxView />, { 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(<InboxView />, { 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(<InboxView />, { 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(<InboxView />, { 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(<InboxView />, { 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(<InboxView />, { 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 <div className=\"flex flex-col items-center justify-center h-full\" data-testid=\"inbox-view\">\n <motion.div\n initial={{ scale: 0 }}\n animate={{ scale: 1 }}\n className=\"text-center\"\n >\n <CheckCircle className=\"w-16 h-16 text-green-500 mx-auto mb-4\" />\n <h2 className=\"text-2xl font-bold\">Inbox Zero</h2>\n <p className=\"text-muted-foreground\">All caught up!</p>\n </motion.div>\n </div>\n );\n }\n \n return (\n <div className=\"p-4\" data-testid=\"inbox-view\">\n <h1 className=\"text-xl font-bold mb-4\">Inbox ({untriagedItems.length})</h1>\n \n <Inbox\n items={untriagedItems}\n focusIndex={focusIndex}\n onTriage={handleTriage}\n />\n \n <p className=\"text-sm text-muted-foreground mt-4\">\n Keyboard: ↑↓ navigate · A accept · D defer · X archive\n </p>\n </div>\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,
|
|
"dependency_count": 3,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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(<QueueView />, { 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(<QueueView />, { 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(<QueueView />, { 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(<QueueView />, { 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(<QueueView />, { 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<Filter | null>(null);\n const [showCommandPalette, setShowCommandPalette] = useState(false);\n const [batchType, setBatchType] = useState<ItemType | null>(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 <BatchMode\n items={batchItems}\n type={batchType}\n onComplete={handleComplete}\n onSkip={handleSkip}\n onExit={() => setBatchType(null)}\n />\n );\n }\n \n if (visibleItems.length === 0) {\n return (\n <div className=\"flex flex-col items-center justify-center h-full\">\n <Inbox className=\"w-16 h-16 text-muted-foreground mb-4\" />\n <h2 className=\"text-xl font-semibold\">Queue is empty</h2>\n <p className=\"text-muted-foreground\">All caught up!</p>\n </div>\n );\n }\n \n return (\n <div className=\"p-4\" data-testid=\"queue-view\">\n <div className=\"flex justify-between items-center mb-4\">\n <h1 className=\"text-xl font-bold\">Queue</h1>\n <Button variant=\"outline\" size=\"sm\" onClick={() => setShowCommandPalette(true)}>\n Filter <kbd className=\"ml-2\">⌘K</kbd>\n </Button>\n </div>\n \n {filter && (\n <div className=\"mb-4 flex items-center gap-2\">\n <Badge>{formatFilter(filter)}</Badge>\n <Button variant=\"ghost\" size=\"sm\" onClick={() => setFilter(null)}>\n Clear\n </Button>\n </div>\n )}\n \n <QueueList\n items={visibleItems}\n onSetFocus={handleSetFocus}\n onBatchStart={handleBatchStart}\n onReorder={handleReorder}\n />\n \n {showCommandPalette && (\n <CommandPalette\n items={visibleItems}\n onFilter={setFilter}\n onSelect={handleSetFocus}\n onClose={() => setShowCommandPalette(false)}\n />\n )}\n </div>\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,
|
|
"dependency_count": 4,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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<LoreMeResponse, _> = 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<BeadItem> = 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<LoreMeta>,\n}\n\n#[derive(Deserialize, Debug)]\n#[serde(deny_unknown_fields)]\npub struct LoreMeData {\n pub issues: Vec<LoreIssue>,\n pub mrs: LoreMrData,\n pub since_last_check: Vec<LoreEvent>,\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,
|
|
"dependency_count": 2,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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<WorkItem[]>('get_lore_items'),\n invoke<WorkItem[]>('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<TriageResult>('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,
|
|
"dependency_count": 3,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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(<BatchCelebration completed={4} total={4} />);\n \n expect(screen.getByTestId('confetti')).toBeInTheDocument();\n });\n \n it('shows completion stats', () => {\n render(<BatchCelebration completed={4} total={4} timeTaken={1200} />);\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(<BatchCelebration completed={3} total={4} skipped={1} />);\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(<BatchCelebration onClose={onClose} />);\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(<BatchCelebration soundEnabled={true} />);\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(<BatchCelebration />);\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 <div className=\"fixed inset-0 bg-background flex flex-col items-center justify-center\">\n {!prefersReducedMotion && (\n <Confetti\n data-testid=\"confetti\"\n width={width}\n height={height}\n recycle={false}\n numberOfPieces={200}\n gravity={0.3}\n />\n )}\n \n <motion.div\n initial={{ scale: 0, opacity: 0 }}\n animate={{ scale: 1, opacity: 1 }}\n transition={{ type: 'spring', duration: 0.5, delay: 0.2 }}\n className=\"text-center\"\n >\n <motion.div\n animate={{ rotate: [0, 10, -10, 0] }}\n transition={{ duration: 0.5, delay: 0.5 }}\n className=\"text-6xl mb-4\"\n >\n 🎉\n </motion.div>\n \n <h1 className=\"text-4xl font-bold mb-2\">All done!</h1>\n \n <p className=\"text-xl text-muted-foreground mb-1\">\n {completed}/{total} completed\n </p>\n \n {skipped > 0 && (\n <p className=\"text-muted-foreground\">{skipped} skipped</p>\n )}\n \n {timeTaken && (\n <p className=\"text-muted-foreground mt-2\">\n {formatDuration(timeTaken)}\n </p>\n )}\n \n <Button className=\"mt-8\" size=\"lg\" onClick={onClose}>\n Back to Focus\n </Button>\n </motion.div>\n </div>\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,
|
|
"dependency_count": 2,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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(<Navigation />);\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(<Navigation />, { 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(<Navigation />, { 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(<Navigation />, { 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(<App />, { 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(<App />, { 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(<App />, { 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 <nav className=\"flex items-center gap-1 px-4 py-2 border-b\">\n {NAV_ITEMS.map(item => (\n <Button\n key={item.id}\n variant={activeView === item.id ? 'secondary' : 'ghost'}\n size=\"sm\"\n data-active={activeView === item.id}\n onClick={() => setActiveView(item.id)}\n >\n {item.label}\n <kbd className=\"ml-2 text-xs text-muted-foreground\">{item.shortcut}</kbd>\n {item.badge && inboxCount > 0 && (\n <Badge data-testid=\"inbox-badge\" className=\"ml-2\" variant=\"destructive\">\n {inboxCount}\n </Badge>\n )}\n </Button>\n ))}\n \n <div className=\"flex-1\" />\n \n <Button variant=\"ghost\" size=\"icon\" onClick={() => setActiveView('settings')}>\n <Settings className=\"w-4 h-4\" />\n </Button>\n </nav>\n );\n}\n\n// src/App.tsx\nexport function App() {\n const { activeView } = useStore();\n \n const views = {\n focus: <FocusView />,\n queue: <QueueView />,\n inbox: <InboxView />,\n settings: <SettingsView />,\n };\n \n return (\n <div className=\"flex flex-col h-screen\">\n <Navigation />\n <main className=\"flex-1 overflow-auto\" data-testid={`${activeView}-view`}>\n {views[activeView]}\n </main>\n </div>\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,
|
|
"dependency_count": 2,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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(<QueueList items={mockItems} onSetFocus={setFocus} />);\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(<QueueList items={mockItems} />, { 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(<QueueList items={mockItems} />);\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(<QueueList items={mockItems} logDecision={logDecision} />);\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 <div \n className=\"flex items-center gap-3 p-2 rounded cursor-pointer hover:bg-muted\"\n onClick={handleClick}\n >\n <StalenessIndicator staleness={getStaleness(item.createdAt)} />\n <span className=\"flex-1 truncate\">{item.title}</span>\n <span className=\"text-muted-foreground text-sm\">{formatAge(item.createdAt)}</span>\n </div>\n \n {showReasonPrompt && (\n <ReasonPrompt\n action=\"set_focus\"\n itemTitle={item.title}\n onSubmit={handleConfirm}\n onCancel={() => 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,
|
|
"dependency_count": 3,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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<WorkItem>) => 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<MCStore>()(\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,
|
|
"dependency_count": 2,
|
|
"dependent_count": 2
|
|
},
|
|
{
|
|
"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(<FocusView />, { 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(<FocusView />, { 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(<FocusView />, { 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(<FocusView />, { 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(<FocusView />, { 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<PendingAction | null>(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 <div className=\"flex flex-col items-center justify-center h-full\">\n <CheckCircle className=\"w-16 h-16 text-green-500 mb-4\" />\n <h2 className=\"text-2xl font-bold\">All caught up!</h2>\n <p className=\"text-muted-foreground\">Nothing to focus on right now.</p>\n <Button className=\"mt-4\" onClick={() => invoke('show_quick_capture')}>\n Create a task\n </Button>\n </div>\n );\n }\n \n // Suggestion state\n if (suggestion && !focusItem) {\n return (\n <div className=\"flex flex-col items-center justify-center h-full\">\n <p className=\"text-muted-foreground mb-2\">Suggested next</p>\n <FocusCard \n item={suggestion}\n onStart={() => {\n setFocus(suggestion.id);\n start(suggestion);\n }}\n onDefer={(d) => defer(suggestion, d)}\n onSkip={() => skip(suggestion)}\n />\n <Button \n variant=\"outline\" \n className=\"mt-4\"\n onClick={() => setFocus(suggestion.id)}\n >\n Set as focus\n </Button>\n </div>\n );\n }\n \n // Focus state\n return (\n <div className=\"flex flex-col items-center justify-center h-full\">\n <FocusCard\n item={focusItem!}\n onStart={() => handleAction('start', focusItem!)}\n onDefer={(d) => handleAction('defer', focusItem!, d)}\n onSkip={() => handleAction('skip', focusItem!)}\n />\n \n {showReasonPrompt && (\n <ReasonPrompt\n action={pendingAction!.type}\n itemTitle={focusItem!.title}\n onSubmit={handleReasonSubmit}\n onCancel={() => setShowReasonPrompt(false)}\n />\n )}\n </div>\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,
|
|
"dependency_count": 3,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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(<SyncStatus status=\"synced\" lastSync={new Date(Date.now() - 2 * 60 * 1000)} />);\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(<SyncStatus status=\"syncing\" />);\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(<SyncStatus status=\"error\" error=\"lore command failed\" onRetry={onRetry} />);\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(<SyncStatus status=\"synced\" lastSync={new Date(Date.now() - 20 * 60 * 1000)} />);\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 <div className=\"flex items-center gap-2 text-sm\">\n {effectiveStatus === 'syncing' ? (\n <Loader2 data-testid=\"sync-spinner\" className=\"w-3 h-3 animate-spin\" />\n ) : (\n <div data-testid=\"sync-indicator\" className={cn('w-2 h-2 rounded-full', color)} />\n )}\n \n <span className=\"text-muted-foreground\">{text}</span>\n \n {(effectiveStatus === 'error' || effectiveStatus === 'stale') && onRetry && (\n <Button variant=\"ghost\" size=\"sm\" onClick={onRetry}>\n Retry\n </Button>\n )}\n </div>\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,
|
|
"dependency_count": 3,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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(<TrayPopover focusItem={mockItem} />);\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(<TrayPopover queueCount={4} inboxCount={3} />);\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(<TrayPopover focusItem={null} />);\n \n expect(screen.getByText(/Nothing focused/)).toBeInTheDocument();\n });\n \n it('Start action opens browser', async () => {\n const mockOpen = vi.fn();\n render(<TrayPopover focusItem={mockItem} onStart={mockOpen} />);\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 <div className=\"w-72 p-4 bg-background rounded-lg shadow-lg\">\n {focusItem ? (\n <>\n <p className=\"text-xs text-muted-foreground mb-1\">THE ONE THING</p>\n <h3 className=\"font-semibold truncate\">{focusItem.title}</h3>\n <p className=\"text-sm text-muted-foreground\">\n {formatAge(focusItem.createdAt)} · {focusItem.metadata?.author}\n </p>\n \n <div className=\"flex gap-2 mt-3\">\n <Button size=\"sm\" onClick={onStart}>Start</Button>\n <Button size=\"sm\" variant=\"outline\" onClick={() => onDefer('1h')}>Defer</Button>\n <Button size=\"sm\" variant=\"ghost\" onClick={onSkip}>Skip</Button>\n </div>\n </>\n ) : (\n <p className=\"text-muted-foreground\">Nothing focused. Pick something from the queue!</p>\n )}\n \n <Separator className=\"my-3\" />\n \n <div className=\"flex justify-between text-sm\">\n <span>Queue: {queueCount}</span>\n <span>Inbox: {inboxCount}</span>\n </div>\n \n <Button variant=\"link\" size=\"sm\" className=\"mt-2 w-full\" onClick={onOpenFull}>\n ⌘⇧F Full window\n </Button>\n </div>\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,
|
|
"dependency_count": 2,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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(<Settings />);\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(<Settings onSave={mockSave} />);\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(<Settings />);\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 <div className=\"p-6 max-w-lg mx-auto\">\n <h1 className=\"text-2xl font-bold mb-6\">Settings</h1>\n \n <div className=\"space-y-6\">\n <section>\n <h2 className=\"text-lg font-semibold mb-3\">Hotkeys</h2>\n <HotkeyInput\n label=\"Toggle window\"\n value={settings.hotkeys.toggle}\n onChange={(v) => mutate({ ...settings, hotkeys: { ...settings.hotkeys, toggle: v }})}\n />\n <HotkeyInput\n label=\"Quick capture\"\n value={settings.hotkeys.capture}\n onChange={(v) => mutate({ ...settings, hotkeys: { ...settings.hotkeys, capture: v }})}\n />\n </section>\n \n <section>\n <h2 className=\"text-lg font-semibold mb-3\">Behavior</h2>\n <Switch\n label=\"Sound effects\"\n checked={settings.sounds}\n onCheckedChange={(v) => mutate({ ...settings, sounds: v })}\n />\n <Switch\n label=\"Floating widget\"\n checked={settings.floating_widget}\n onCheckedChange={(v) => mutate({ ...settings, floating_widget: v })}\n />\n </section>\n </div>\n </div>\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,
|
|
"dependency_count": 2,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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,
|
|
"dependency_count": 2,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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(<StalenessIndicator staleness=\"fresh\" />);\n expect(screen.getByTestId('staleness-indicator')).toHaveClass('bg-green-500');\n });\n \n it('shows pulsing red for urgent', () => {\n render(<StalenessIndicator staleness=\"urgent\" />);\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 <div\n data-testid=\"staleness-indicator\"\n className={cn('w-2 h-2 rounded-full', classes[staleness])}\n aria-label={`Item is ${staleness}`}\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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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<motion.div\n initial={{ scale: 0.95, opacity: 0 }}\n animate={{ scale: 1, opacity: 1 }}\n transition={{ type: 'spring', duration: 0.3 }}\n>\n <FocusCard ... />\n</motion.div>\n\n// Queue item reorder with layout animation\n<motion.div layout transition={{ type: 'spring', stiffness: 300, damping: 30 }}>\n <QueueItem ... />\n</motion.div>\n\n// Batch progress\n<motion.div\n className=\"h-2 bg-primary rounded\"\n initial={{ width: 0 }}\n animate={{ width: `${(progress / total) * 100}%` }}\n transition={{ duration: 0.3 }}\n/>\n```\n\n**Celebration confetti:**\n```tsx\nimport Confetti from 'react-confetti';\n\nfunction BatchCelebration({ completed, total, onClose }) {\n return (\n <div className=\"fixed inset-0 flex items-center justify-center\">\n <Confetti data-testid=\"confetti\" />\n <motion.div\n initial={{ scale: 0 }}\n animate={{ scale: 1 }}\n transition={{ type: 'spring', delay: 0.2 }}\n >\n <h1 className=\"text-4xl font-bold\">All done!</h1>\n <p>{completed}/{total} completed</p>\n <Button onClick={onClose}>Back to Focus</Button>\n </motion.div>\n </div>\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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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,
|
|
"dependency_count": 2,
|
|
"dependent_count": 4
|
|
},
|
|
{
|
|
"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<R: Runtime>(app: &tauri::App<R>) -> 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 <QuickCapture \n isOpen={showCapture}\n onSave={async (text) => {\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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 1
|
|
},
|
|
{
|
|
"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(<QuickCapture isOpen={true} />);\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(<QuickCapture isOpen={true} />);\n \n expect(screen.getByRole('textbox')).toHaveFocus();\n });\n \n it('captures text input', async () => {\n render(<QuickCapture isOpen={true} />);\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(<QuickCapture isOpen={true} onSave={onSave} />);\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(<QuickCapture isOpen={true} onCancel={onCancel} />);\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(<QuickCapture isOpen={true} onSave={onSave} />);\n \n await userEvent.keyboard('{Enter}');\n \n expect(onSave).not.toHaveBeenCalled();\n });\n \n it('clears input after save', async () => {\n render(<QuickCapture isOpen={true} />);\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<HTMLTextAreaElement>(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 <AnimatePresence>\n {isOpen && (\n <motion.div\n data-testid=\"quick-capture-overlay\"\n className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50\"\n initial={{ opacity: 0 }}\n animate={{ opacity: 1 }}\n exit={{ opacity: 0 }}\n transition={{ duration: 0.15 }}\n >\n <motion.div\n role=\"dialog\"\n className=\"bg-background rounded-lg p-6 w-96 shadow-2xl\"\n initial={{ scale: 0.9, opacity: 0 }}\n animate={{ scale: 1, opacity: 1 }}\n exit={{ scale: 0.9, opacity: 0 }}\n transition={{ type: 'spring', duration: 0.2 }}\n >\n <Textarea\n ref={inputRef}\n value={text}\n onChange={(e) => setText(e.target.value)}\n onKeyDown={handleKeyDown}\n placeholder=\"Quick thought...\"\n className=\"min-h-[100px] resize-none\"\n autoFocus\n />\n \n <div className=\"flex justify-between mt-4 text-sm text-muted-foreground\">\n <span>⏎ Save & close</span>\n <span>ESC Cancel</span>\n </div>\n </motion.div>\n </motion.div>\n )}\n </AnimatePresence>\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<dyn BeadsCli>>,\n decision_log: State<'_, DecisionLogger>,\n) -> Result<String, String> {\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,
|
|
"dependency_count": 2,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 3
|
|
},
|
|
{
|
|
"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(<BatchMode items={mockItems} />);\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(<BatchMode items={mockItems} />);\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(<BatchMode items={mockItems} />);\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(<BatchMode items={mockItems} onComplete={onComplete} />);\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(<BatchMode items={mockItems} onExit={onExit} />);\n \n await userEvent.keyboard('{Escape}');\n \n expect(onExit).toHaveBeenCalled();\n });\n \n it('shows celebration on completion', async () => {\n render(<BatchMode items={mockItems} />);\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(<BatchMode items={mockItems} onComplete={onComplete} />);\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<Set<string>>(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 <BatchCelebration completed={completedIds.size} total={items.length} onClose={onExit} />;\n }\n \n return (\n <div className=\"fixed inset-0 bg-background z-50 flex flex-col\">\n <header className=\"p-4 text-center border-b\">\n <h1 className=\"text-xl font-bold\">BATCH: {getTypeLabel(type)}</h1>\n <p className=\"text-muted-foreground\">{currentIndex + 1} of {items.length}</p>\n <Progress value={progress} max={items.length} className=\"mt-2\" />\n </header>\n \n <main className=\"flex-1 flex items-center justify-center p-8\">\n <BatchCard item={currentItem} />\n </main>\n \n <footer className=\"p-4 flex justify-center gap-4 border-t\">\n <Button onClick={() => open(currentItem.url)}>\n Open in GL <kbd className=\"ml-2\">⌘O</kbd>\n </Button>\n <Button onClick={handleDone}>\n Done <kbd className=\"ml-2\">⌘D</kbd>\n </Button>\n <Button variant=\"ghost\" onClick={handleSkip}>\n Skip <kbd className=\"ml-2\">⌘S</kbd>\n </Button>\n </footer>\n \n <p className=\"text-center text-muted-foreground pb-4\">ESC to exit batch</p>\n </div>\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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 2
|
|
},
|
|
{
|
|
"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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 3
|
|
},
|
|
{
|
|
"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(<App />);\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(<App />);\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(<QueueView items={mockItems} />);\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(<App />);\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(<CommandPalette onFilter={onFilter} />);\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(<CommandPalette onFilter={onFilter} />);\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 <Command className=\"rounded-lg border shadow-md\">\n <CommandInput \n placeholder=\"Search or filter... (try type:review)\"\n value={search}\n onValueChange={setSearch}\n />\n \n <CommandList>\n {activeCommand ? (\n <CommandGroup heading={activeCommand.prefix}>\n {activeCommand.options.map(opt => (\n <CommandItem\n key={opt}\n onSelect={() => {\n onFilter(parseFilter(activeCommand.prefix + opt));\n onClose();\n }}\n >\n {activeCommand.prefix}{opt}\n </CommandItem>\n ))}\n </CommandGroup>\n ) : (\n <>\n {filteredItems.length > 0 && (\n <CommandGroup heading=\"Items\">\n {filteredItems.slice(0, 10).map(item => (\n <CommandItem\n key={item.id}\n onSelect={() => {\n onSelect(item.id);\n onClose();\n }}\n >\n <TypeIcon type={item.type} className=\"mr-2\" />\n {item.title}\n </CommandItem>\n ))}\n </CommandGroup>\n )}\n \n <CommandGroup heading=\"Commands\">\n {FILTER_COMMANDS.map(cmd => (\n <CommandItem key={cmd.prefix} onSelect={() => setSearch(cmd.prefix)}>\n {cmd.prefix}...\n </CommandItem>\n ))}\n </CommandGroup>\n </>\n )}\n </CommandList>\n </Command>\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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 1
|
|
},
|
|
{
|
|
"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(<Inbox items={items} />);\n \n expect(screen.getAllByTestId('inbox-item')).toHaveLength(2);\n });\n \n it('shows inbox zero state when empty', () => {\n render(<Inbox items={[]} />);\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(<Inbox items={mockNewItems} onTriage={onTriage} />);\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(<Inbox items={mockNewItems} />);\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(<Inbox items={mockNewItems} onTriage={onTriage} />);\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(<Inbox items={mockNewItems} onTriage={onTriage} />);\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 <div className=\"flex flex-col items-center justify-center py-12\">\n <CheckCircle className=\"w-16 h-16 text-green-500 mb-4\" />\n <h2 className=\"text-2xl font-bold\">Inbox Zero</h2>\n <p className=\"text-muted-foreground\">All caught up!</p>\n </div>\n );\n }\n \n return (\n <div className=\"space-y-2\">\n <h2 className=\"text-lg font-semibold\">Inbox ({untriagedItems.length})</h2>\n \n {untriagedItems.map((item, index) => (\n <InboxItem\n key={item.id}\n item={item}\n onAccept={() => 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 </div>\n );\n}\n\nfunction InboxItem({ item, onAccept, onDefer, onArchive, shortcuts }: InboxItemProps) {\n return (\n <Card className=\"p-4\">\n <div className=\"flex items-start gap-4\">\n <TypeIcon type={item.type} />\n \n <div className=\"flex-1\">\n <h3 className=\"font-medium\">{item.title}</h3>\n <p className=\"text-sm text-muted-foreground\">{item.metadata?.snippet}</p>\n </div>\n \n <div className=\"flex gap-2\">\n <Button size=\"sm\" onClick={onAccept}>\n Accept {shortcuts?.accept && <kbd className=\"ml-1\">{shortcuts.accept}</kbd>}\n </Button>\n <DeferPicker onSelect={onDefer} />\n <Button size=\"sm\" variant=\"ghost\" onClick={onArchive}>\n Archive {shortcuts?.archive && <kbd className=\"ml-1\">{shortcuts.archive}</kbd>}\n </Button>\n </div>\n </div>\n </Card>\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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 1
|
|
},
|
|
{
|
|
"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(<QueueList items={mockItems} onReorder={onReorder} />);\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(<QueueList items={mockItems} onReorder={mockPersist} />);\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(<QueueList items={reorderedItems} />);\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(<QueueList items={mockItems} />);\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(<QueueList items={mockItems} />);\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<ReorderData | null>(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 <DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>\n <SortableContext items={items.map(i => i.id)} strategy={verticalListSortingStrategy}>\n {items.map(item => (\n <SortableQueueItem key={item.id} item={item} onClick={() => onItemClick(item.id)} />\n ))}\n </SortableContext>\n \n {showReasonPrompt && (\n <ReasonPrompt\n action=\"reorder\"\n itemTitle={items.find(i => i.id === pendingReorder?.itemId)?.title ?? ''}\n onSubmit={handleReasonSubmit}\n onCancel={() => setShowReasonPrompt(false)}\n />\n )}\n </DndContext>\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,
|
|
"dependency_count": 3,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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(<QueueList items={mockItems} />);\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(<QueueList items={mockItems} />);\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(<QueueList items={mockItems} />);\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(<QueueList items={mockItems} onItemClick={onItemClick} />);\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(<QueueList items={mockItems} />);\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 <div className=\"space-y-4\">\n {sections.map(section => (\n <Collapsible key={section.type} defaultOpen>\n <CollapsibleTrigger className=\"flex justify-between w-full\">\n <span className=\"font-semibold\">\n {section.label} ({section.items.length})\n </span>\n {section.items.length > 1 && (\n <Button \n variant=\"ghost\" \n size=\"sm\"\n onClick={(e) => {\n e.stopPropagation();\n onBatchStart(section.type);\n }}\n >\n Batch All · {estimateDuration(section.items)}\n </Button>\n )}\n </CollapsibleTrigger>\n \n <CollapsibleContent>\n <div className=\"space-y-1 mt-2\">\n {section.items.map(item => (\n <QueueItem \n key={item.id}\n item={item}\n onClick={() => onItemClick(item.id)}\n />\n ))}\n </div>\n </CollapsibleContent>\n </Collapsible>\n ))}\n </div>\n );\n}\n\nfunction QueueItem({ item, onClick }: { item: WorkItem; onClick: () => void }) {\n const staleness = getStaleness(item.createdAt);\n \n return (\n <div \n data-testid=\"queue-item\"\n className={cn(\n 'flex items-center gap-3 p-2 rounded cursor-pointer hover:bg-muted',\n `staleness-${staleness}`\n )}\n onClick={onClick}\n >\n <StalenessIndicator staleness={staleness} />\n <span className=\"flex-1 truncate\">{item.title}</span>\n <span className=\"text-muted-foreground text-sm\">{formatAge(item.createdAt)}</span>\n {item.metadata?.author && (\n <span className=\"text-muted-foreground text-sm\">{item.metadata.author}</span>\n )}\n </div>\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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 3
|
|
},
|
|
{
|
|
"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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 8
|
|
},
|
|
{
|
|
"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(<ReasonPrompt action=\"set_focus\" itemTitle=\"Review MR !847\" />);\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(<ReasonPrompt onSubmit={onSubmit} />);\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(<ReasonPrompt onSubmit={onSubmit} />);\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(<ReasonPrompt onSubmit={onSubmit} />);\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(<ReasonPrompt onSubmit={onSubmit} />);\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(<ReasonPrompt onCancel={onCancel} />);\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<string[]>([]);\n const inputRef = useRef<HTMLTextAreaElement>(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 <Dialog open onOpenChange={(open) => !open && onCancel()}>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>\n {formatActionTitle(action)}: {itemTitle}\n </DialogTitle>\n </DialogHeader>\n \n <div className=\"space-y-4\">\n <div>\n <Label>Why? (optional)</Label>\n <Textarea\n ref={inputRef}\n value={reason}\n onChange={(e) => setReason(e.target.value)}\n onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSubmit()}\n placeholder=\"Helps learn your patterns...\"\n />\n </div>\n \n <div className=\"flex flex-wrap gap-2\">\n {QUICK_TAGS.map(tag => (\n <Badge\n key={tag.id}\n variant={selectedTags.includes(tag.id) ? 'default' : 'outline'}\n className=\"cursor-pointer\"\n onClick={() => toggleTag(tag.id)}\n >\n {tag.label}\n </Badge>\n ))}\n </div>\n </div>\n \n <DialogFooter>\n <Button variant=\"ghost\" onClick={handleSkip}>Skip reason</Button>\n <Button onClick={handleSubmit}>Confirm</Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 1
|
|
},
|
|
{
|
|
"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,
|
|
"dependency_count": 4,
|
|
"dependent_count": 1
|
|
},
|
|
{
|
|
"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(<FocusCard item={mockItem} onStart={onStart} />);\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(<FocusCard item={mockItem} onDefer={onDefer} />);\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(<FocusCard item={mockItem} onDefer={onDefer} />);\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(<FocusCard item={mockItem} onSkip={onSkip} />);\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 <FocusCard item={mockItem} onStart={onStart} />\n <input data-testid=\"other-input\" />\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<string, () => 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,
|
|
"dependency_count": 2,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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(<FocusCard item={mockItem} />);\n \n expect(screen.getByRole('heading')).toHaveTextContent('Fix authentication token refresh logic');\n });\n \n it('shows item type badge', () => {\n render(<FocusCard item={mockItem} />);\n \n expect(screen.getByTestId('type-badge')).toHaveTextContent('MR REVIEW');\n });\n \n it('displays metadata context', () => {\n render(<FocusCard item={mockItem} />);\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(<FocusCard item={mockItem} onStart={onStart} />);\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(<FocusCard item={oldItem} />);\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(<FocusCard item={veryOldItem} />);\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 <Card className=\"max-w-2xl mx-auto\">\n <CardHeader className=\"text-center\">\n <TypeBadge type={item.type} staleness={staleness} />\n <CardTitle className=\"text-2xl mt-4\">{item.title}</CardTitle>\n </CardHeader>\n \n <CardContent className=\"space-y-4\">\n <Metadata item={item} />\n \n {item.metadata.message && (\n <blockquote className=\"border-l-4 pl-4 italic\">\n {item.metadata.message}\n </blockquote>\n )}\n </CardContent>\n \n <CardFooter className=\"flex justify-center gap-4\">\n <Button onClick={() => onStart(item.id)}>\n Start <kbd className=\"ml-2\">↵</kbd>\n </Button>\n <Button variant=\"outline\" onClick={() => onDefer(item.id, '1h')}>\n 1 hour <kbd className=\"ml-2\">⌘1</kbd>\n </Button>\n <Button variant=\"outline\" onClick={() => onDefer(item.id, 'tomorrow')}>\n Tomorrow <kbd className=\"ml-2\">⌘2</kbd>\n </Button>\n <Button variant=\"ghost\" onClick={() => onSkip(item.id)}>\n Skip <kbd className=\"ml-2\">⌘S</kbd>\n </Button>\n </CardFooter>\n </Card>\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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 3
|
|
},
|
|
{
|
|
"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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 7
|
|
},
|
|
{
|
|
"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<B: BeadsCli>(\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<Utc>, curr: DateTime<Utc> },\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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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<DecisionEntry> = 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<Utc>,\n pub action: Action,\n pub bead_id: String,\n pub reason: Option<String>,\n pub tags: Vec<String>,\n pub context: DecisionContext,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct DecisionContext {\n pub queue_snapshot: Vec<QueueItem>,\n pub time_of_day: TimeOfDay,\n pub day_of_week: Weekday,\n pub focus_session_duration: Option<Duration>,\n pub items_completed_today: u32,\n pub previous_focus: Option<String>,\n}\n\npub struct DecisionLogger {\n file: std::fs::File,\n}\n\nimpl DecisionLogger {\n pub fn new(path: &Path) -> Result<Self> {\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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 6
|
|
},
|
|
{
|
|
"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<Self, LockError> {\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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 2
|
|
},
|
|
{
|
|
"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<L: LoreCli, B: BeadsCli> BridgeState<L, B> {\n pub fn recover(lore: L, beads: B, mapping: Mapping) -> Result<Self> {\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,
|
|
"dependency_count": 4,
|
|
"dependent_count": 1
|
|
},
|
|
{
|
|
"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<L: LoreCli, B: BeadsCli> BridgeState<L, B> {\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,
|
|
"dependency_count": 2,
|
|
"dependent_count": 1
|
|
},
|
|
{
|
|
"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<L: LoreCli, B: BeadsCli> BridgeState<L, B> {\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,
|
|
"dependency_count": 2,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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<BrCreateResponse, _> = 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<String> {\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<bool> {\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<String, MockResponse>,\n calls: Vec<BeadsCall>,\n next_id: u32,\n}\n\nimpl BeadsCli for MockBeadsCli {\n fn create(&mut self, title: &str, bead_type: &str) -> Result<String> {\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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 3
|
|
},
|
|
{
|
|
"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<Mapping> {\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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 3
|
|
},
|
|
{
|
|
"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<L: LoreCli, B: BeadsCli> {\n lore: L,\n beads: B,\n mapping: Mapping,\n}\n\nimpl<L: LoreCli, B: BeadsCli> BridgeState<L, B> {\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,
|
|
"dependency_count": 3,
|
|
"dependent_count": 4
|
|
},
|
|
{
|
|
"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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 14
|
|
},
|
|
{
|
|
"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<dyn LoreCli>>,\n ) -> Result<LoreMeResponse, String> {\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<LoreMeResponse>('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 <div>Loading...</div>;\n if (error) return <div>Error: {error.message}</div>;\n \n return (\n <div className=\"p-4 font-mono text-sm\">\n <h2>Reviews ({data.mrs.reviewing.length})</h2>\n <pre>{JSON.stringify(data.mrs.reviewing, null, 2)}</pre>\n \n <h2>Issues ({data.issues.length})</h2>\n <pre>{JSON.stringify(data.issues, null, 2)}</pre>\n \n <h2>Since Last Check ({data.since_last_check.length})</h2>\n <pre>{JSON.stringify(data.since_last_check, null, 2)}</pre>\n </div>\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,
|
|
"dependency_count": 3,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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<F>(db_path: &Path, on_change: F) -> Result<Self>\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<notify::Event, _>| {\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,
|
|
"dependency_count": 2,
|
|
"dependent_count": 3
|
|
},
|
|
{
|
|
"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<LoreMeta>,\n }\n \n #[derive(Deserialize, Debug)]\n pub struct LoreMeData {\n pub issues: Vec<LoreIssue>,\n pub mrs: LoreMrData,\n pub since_last_check: Vec<LoreEvent>,\n }\n \n pub struct RealLoreCli;\n impl LoreCli for RealLoreCli {\n fn me(&self) -> Result<LoreMeResponse> {\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,
|
|
"dependency_count": 2,
|
|
"dependent_count": 4
|
|
},
|
|
{
|
|
"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<R: Runtime>(app: &tauri::App<R>) -> 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,
|
|
"dependency_count": 2,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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<R: Runtime>(app: &tauri::App<R>) -> 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,
|
|
"dependency_count": 2,
|
|
"dependent_count": 1
|
|
},
|
|
{
|
|
"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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 6
|
|
},
|
|
{
|
|
"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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 14
|
|
},
|
|
{
|
|
"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,
|
|
"dependency_count": 4,
|
|
"dependent_count": 0
|
|
},
|
|
{
|
|
"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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 2
|
|
},
|
|
{
|
|
"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<LoreMeResponse>;\n fn me_issues(&self) -> Result<Vec<LoreIssue>>;\n fn me_mrs(&self) -> Result<Vec<LoreMr>>;\n}\n\npub trait BeadsCli: Send + Sync {\n fn create(&self, title: &str, bead_type: &str) -> Result<String>;\n fn close(&self, id: &str, reason: &str) -> Result<()>;\n fn exists(&self, id: &str) -> Result<bool>;\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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 1
|
|
},
|
|
{
|
|
"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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 2
|
|
},
|
|
{
|
|
"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,
|
|
"dependency_count": 1,
|
|
"dependent_count": 1
|
|
},
|
|
{
|
|
"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,
|
|
"dependency_count": 0,
|
|
"dependent_count": 7
|
|
}
|
|
]
|